openreader/src/lib/client/epub/epub-rendered-text-maps.ts

237 lines
6.8 KiB
TypeScript

'use client';
import type { Rendition } from 'epubjs';
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
type EpubMappedPosition = {
node: Text;
offset: number;
};
type EpubMappedChar = {
char: string;
position: EpubMappedPosition;
};
export type EpubRenderedTextMap = {
sourceKey: string;
chars: EpubMappedPosition[];
content: {
cfiFromRange: (range: Range) => string;
};
};
const cloneMappedChar = (char: string, source: EpubMappedChar): EpubMappedChar => ({
char,
position: source.position,
});
const replaceMappedUrls = (tokens: EpubMappedChar[]): EpubMappedChar[] => {
const text = tokens.map((token) => token.char).join('');
const urlPattern = /\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi;
const replaced: EpubMappedChar[] = [];
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = urlPattern.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
replaced.push(...tokens.slice(cursor, start));
const anchor = tokens[start] ?? tokens[Math.max(0, end - 1)];
if (anchor) {
const replacement = `- (link to ${match[1]}) -`;
for (const char of replacement) {
replaced.push(cloneMappedChar(char, anchor));
}
}
cursor = end;
}
replaced.push(...tokens.slice(cursor));
return replaced;
};
const removeMappedHyphenation = (tokens: EpubMappedChar[]): EpubMappedChar[] => {
const text = tokens.map((token) => token.char).join('');
const hyphenPattern = /(\w+)-\s+(\w+)/g;
const replaced: EpubMappedChar[] = [];
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = hyphenPattern.exec(text)) !== null) {
const start = match.index;
const full = match[0];
const first = match[1];
const second = match[2];
const secondOffset = full.lastIndexOf(second);
replaced.push(...tokens.slice(cursor, start));
replaced.push(...tokens.slice(start, start + first.length));
replaced.push(...tokens.slice(start + secondOffset, start + secondOffset + second.length));
cursor = start + full.length;
}
replaced.push(...tokens.slice(cursor));
return replaced;
};
const normalizeMappedTokensForTts = (tokens: EpubMappedChar[]): EpubMappedChar[] => {
const withoutLinks = replaceMappedUrls(tokens);
const withoutHyphenation = removeMappedHyphenation(withoutLinks);
const normalized: EpubMappedChar[] = [];
let pendingWhitespace: EpubMappedChar | null = null;
const flushWhitespace = () => {
if (!pendingWhitespace || normalized.length === 0 || normalized[normalized.length - 1].char === ' ') {
pendingWhitespace = null;
return;
}
normalized.push(cloneMappedChar(' ', pendingWhitespace));
pendingWhitespace = null;
};
for (const token of withoutHyphenation) {
if (token.char === '*') continue;
if (/\s/.test(token.char)) {
pendingWhitespace ??= token;
continue;
}
flushWhitespace();
normalized.push(token);
}
if (normalized[normalized.length - 1]?.char === ' ') {
normalized.pop();
}
return normalized;
};
const collectMappedTextFromRange = (range: Range): EpubMappedChar[] => {
const root = range.commonAncestorContainer;
const doc = range.startContainer.ownerDocument ?? (range.startContainer as Document);
const mapped: EpubMappedChar[] = [];
const addTextSlice = (textNode: Text, start: number, end: number) => {
const text = textNode.textContent || '';
const safeStart = Math.max(0, Math.min(start, text.length));
const safeEnd = Math.max(safeStart, Math.min(end, text.length));
for (let offset = safeStart; offset < safeEnd; offset += 1) {
mapped.push({
char: text[offset],
position: { node: textNode, offset },
});
}
};
if (root.nodeType === Node.TEXT_NODE) {
addTextSlice(root as Text, range.startOffset, range.endOffset);
return mapped;
}
const nodeFilter = doc.defaultView?.NodeFilter ?? NodeFilter;
const walker = doc.createTreeWalker(
root,
nodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
try {
return range.intersectsNode(node)
? nodeFilter.FILTER_ACCEPT
: nodeFilter.FILTER_REJECT;
} catch {
return nodeFilter.FILTER_REJECT;
}
},
},
);
let textNode = walker.nextNode() as Text | null;
while (textNode) {
const text = textNode.textContent || '';
let start = 0;
let end = text.length;
if (textNode === range.startContainer) start = range.startOffset;
if (textNode === range.endContainer) end = range.endOffset;
addTextSlice(textNode, start, end);
textNode = walker.nextNode() as Text | null;
}
return mapped;
};
export const buildRenderedTextMaps = (
rendition: Rendition,
rangeCfi: string,
sourceKey: string,
): EpubRenderedTextMap[] => {
const contents = rendition.getContents();
const contentsArray = Array.isArray(contents) ? contents : [contents];
const maps: EpubRenderedTextMap[] = [];
for (const content of contentsArray) {
try {
const range = content.range(rangeCfi);
if (!range) continue;
const normalized = normalizeMappedTokensForTts(collectMappedTextFromRange(range));
if (!normalized.length) continue;
maps.push({
sourceKey,
chars: normalized.map((token) => token.position),
content,
});
} catch {
// Not every displayed iframe can resolve every CFI in spread mode.
}
}
return maps;
};
export const createRangeFromMappedOffsets = (
map: EpubRenderedTextMap,
startOffset: number,
endOffset: number,
): Range | null => {
const start = Math.max(0, Math.min(startOffset, map.chars.length));
const end = Math.max(start, Math.min(endOffset, map.chars.length));
if (end <= start) return null;
const startPosition = map.chars[start];
const endPosition = map.chars[end - 1];
if (!startPosition || !endPosition) return null;
const doc = startPosition.node.ownerDocument;
const range = doc.createRange();
range.setStart(startPosition.node, startPosition.offset);
range.setEnd(endPosition.node, endPosition.offset + 1);
return range;
};
export const resolveVisibleSegmentRange = (
maps: EpubRenderedTextMap[],
segment: CanonicalTtsSegment | null | undefined,
): { map: EpubRenderedTextMap; range: Range; startOffset: number; endOffset: number } | null => {
if (!segment) return null;
for (const map of maps) {
const startsInMap = segment.startAnchor.sourceKey === map.sourceKey;
const endsInMap = segment.endAnchor.sourceKey === map.sourceKey;
if (!startsInMap && !endsInMap) continue;
const startOffset = startsInMap ? segment.startAnchor.offset : 0;
const endOffset = endsInMap ? segment.endAnchor.offset : map.chars.length;
const range = createRangeFromMappedOffsets(map, startOffset, endOffset);
if (range) {
return { map, range, startOffset, endOffset };
}
}
return null;
};