forked from akai/readest
3a81e09911
Wide or tall tables, code blocks and display equations overflowed the reading column and a scroll gesture over them turned the page instead of scrolling the content (#4400). - Wrap tables and display equations in a horizontally/vertically scrollable container; route touch + wheel along the box's scrollable axis so it scrolls the box and never turns the page, even at the edge (both axes). - A box that fits its column is marked fit (overflow:visible) so it never clips or captures gestures; the fit decision is measured once after layout via a self-disconnecting ResizeObserver, so it never relayerizes during a page turn. - The scroll wrapper carries a new cfi-skip attribute that makes it transparent to CFI: epubcfi.js hoists a cfi-skip node's children into its parent (unlike cfi-inert which drops the subtree), and xcfi.ts mirrors this for CFI<->XPointer so existing highlights, bookmarks and KOSync positions inside a wrapped table or equation still resolve. The sanitizer whitelists cfi-skip. - Bump foliate-js submodule (cfi-skip support + raf fallback for large sections). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
657 lines
22 KiB
TypeScript
657 lines
22 KiB
TypeScript
/**
|
|
* Converter between EPUB CFI and CREngine XPointer
|
|
* Converts between Readest (foliate-js) CFI format and KOReader CREngine XPointer format
|
|
*/
|
|
|
|
import { BookDoc } from '@/libs/document';
|
|
import { parse, fake, collapse, fromRange, toRange, toElement } from 'foliate-js/epubcfi.js';
|
|
|
|
type XPointer = {
|
|
xpointer: string;
|
|
pos0?: string;
|
|
pos1?: string;
|
|
};
|
|
|
|
export class XCFI {
|
|
private document: Document;
|
|
private spineItemIndex: number;
|
|
|
|
constructor(htmlDocument: Document, spineIndex: number = 0) {
|
|
this.document = htmlDocument;
|
|
this.spineItemIndex = spineIndex;
|
|
}
|
|
|
|
static extractSpineIndex(cfiOrXPath: string): number {
|
|
try {
|
|
if (cfiOrXPath.startsWith('epubcfi(')) {
|
|
const collapsed = collapse(parse(cfiOrXPath));
|
|
const spineStep = collapsed[0]?.[1]?.index;
|
|
if (spineStep === undefined) {
|
|
throw new Error('Cannot extract spine index from CFI');
|
|
}
|
|
|
|
// Convert CFI spine step to 0-based index
|
|
// CFI uses even numbers starting from 2: 2, 4, 6, 8, ...
|
|
// Convert to 0-based: (step - 2) / 2 = 0, 1, 2, 3, ...
|
|
return Math.floor((spineStep - 2) / 2);
|
|
} else if (cfiOrXPath.startsWith('/body/DocFragment[')) {
|
|
// Note that all indices in XPointer/XPath are 1-based
|
|
// but the text() offsets are 0-based
|
|
const match = cfiOrXPath.match(/DocFragment\[(\d+)\]/);
|
|
if (match) {
|
|
return parseInt(match[1]!, 10) - 1;
|
|
}
|
|
throw new Error('Cannot extract spine index from XPath');
|
|
} else {
|
|
throw new Error('Unsupported format for spine index extraction');
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`Cannot extract spine index from CFI/XPointer: ${cfiOrXPath} - ${error}`);
|
|
}
|
|
}
|
|
|
|
xPointerToCFI(startXPointer: string, endXPointer?: string): string {
|
|
try {
|
|
if (endXPointer) {
|
|
return this.convertRangeXPointerToCFI(startXPointer, endXPointer);
|
|
}
|
|
|
|
return this.convertPointXPointerToCFI(startXPointer);
|
|
} catch (error) {
|
|
throw new Error(`Failed to convert XPointer ${startXPointer}: ${error}`);
|
|
}
|
|
}
|
|
|
|
cfiToXPointer(cfi: string): XPointer {
|
|
try {
|
|
const parts = parse(cfi);
|
|
if (parts.parent) {
|
|
const index = fake.toIndex(parts.parent.shift()); // Remove the spine step
|
|
if (index !== this.spineItemIndex) {
|
|
throw new Error(
|
|
`CFI spine index ${index} does not match converter spine index ${this.spineItemIndex}`,
|
|
);
|
|
}
|
|
const range = toRange(this.document, parts);
|
|
const startXPointer = this.rangePointToXPointer(range.startContainer, range.startOffset);
|
|
const endXPointer = this.rangePointToXPointer(range.endContainer, range.endOffset);
|
|
|
|
return {
|
|
xpointer: startXPointer,
|
|
pos0: startXPointer,
|
|
pos1: endXPointer,
|
|
};
|
|
}
|
|
|
|
const collapsed = collapse(parts);
|
|
const index = fake.toIndex(parts.shift());
|
|
if (index !== this.spineItemIndex) {
|
|
throw new Error(
|
|
`CFI spine index ${index} does not match converter spine index ${this.spineItemIndex}`,
|
|
);
|
|
}
|
|
const element = toElement(this.document, parts[0]) as Element;
|
|
if (!element) {
|
|
throw new Error(`Element not found for CFI: ${cfi}`);
|
|
}
|
|
const lastPart =
|
|
collapsed[collapsed.length - 1]?.[collapsed[collapsed.length - 1].length - 1];
|
|
const textOffset = lastPart?.offset;
|
|
|
|
const xpointer =
|
|
textOffset !== undefined
|
|
? this.handleTextOffset(element, textOffset)
|
|
: this.buildXPointerPath(element);
|
|
|
|
return { xpointer };
|
|
} catch (error) {
|
|
throw new Error(`Failed to convert CFI ${cfi}: ${error}`);
|
|
}
|
|
}
|
|
|
|
validateCFI(cfi: string): boolean {
|
|
try {
|
|
parse(cfi);
|
|
this.cfiToXPointer(cfi);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
validateXPointer(xpointer: string, pos1?: string): boolean {
|
|
try {
|
|
this.xPointerToCFI(xpointer, pos1);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a single point XPointer to CFI
|
|
*/
|
|
private convertPointXPointerToCFI(xpointer: string): string {
|
|
const { element, textOffset } = this.parseXPointer(xpointer);
|
|
|
|
const range = this.document.createRange();
|
|
if (textOffset !== undefined) {
|
|
const textNode = this.findTextNodeAtOffset(element, textOffset);
|
|
if (textNode) {
|
|
range.setStart(textNode.node, textNode.offset);
|
|
range.setEnd(textNode.node, textNode.offset);
|
|
} else {
|
|
// Fallback to element positioning
|
|
range.setStart(element, 0);
|
|
range.setEnd(element, 0);
|
|
}
|
|
} else {
|
|
range.setStart(element, 0);
|
|
range.setEnd(element, 0);
|
|
}
|
|
|
|
const cfi = fromRange(range);
|
|
return this.adjustSpineIndex(cfi);
|
|
}
|
|
|
|
private convertRangeXPointerToCFI(startXPointer: string, endXPointer: string): string {
|
|
const startInfo = this.parseXPointer(startXPointer);
|
|
const endInfo = this.parseXPointer(endXPointer);
|
|
|
|
const range = this.document.createRange();
|
|
if (startInfo.textOffset !== undefined) {
|
|
const startTextNode = this.findTextNodeAtOffset(startInfo.element, startInfo.textOffset);
|
|
if (startTextNode) {
|
|
range.setStart(startTextNode.node, startTextNode.offset);
|
|
} else {
|
|
range.setStart(startInfo.element, 0);
|
|
}
|
|
} else {
|
|
range.setStart(startInfo.element, 0);
|
|
}
|
|
|
|
if (endInfo.textOffset !== undefined) {
|
|
const endTextNode = this.findTextNodeAtOffset(endInfo.element, endInfo.textOffset);
|
|
if (endTextNode) {
|
|
range.setEnd(endTextNode.node, endTextNode.offset);
|
|
} else {
|
|
range.setEnd(endInfo.element, 0);
|
|
}
|
|
} else {
|
|
range.setEnd(endInfo.element, 0);
|
|
}
|
|
|
|
const cfi = fromRange(range);
|
|
return this.adjustSpineIndex(cfi);
|
|
}
|
|
|
|
/**
|
|
* Parse XPointer string to extract element and text offset
|
|
*
|
|
* Supports two KOReader text reference formats:
|
|
* - `/text().N` — cumulative character offset across all text in the element
|
|
* - `/text()[K].N` — Kth direct text node child (1-based), offset N within that node
|
|
*/
|
|
private parseXPointer(xpointer: string): { element: Element; textOffset?: number } {
|
|
// Format: /text()[K].N — indexed text node with offset
|
|
const indexedTextMatch = xpointer.match(/\/text\(\)\[(\d+)\]\.(\d+)$/);
|
|
if (indexedTextMatch) {
|
|
const textNodeIndex = parseInt(indexedTextMatch[1]!, 10); // 1-based
|
|
const offsetInNode = parseInt(indexedTextMatch[2]!, 10);
|
|
const elementPath = xpointer.replace(/\/text\(\)\[\d+\]\.\d+$/, '');
|
|
|
|
const element = this.resolveXPointerPath(elementPath);
|
|
if (!element) {
|
|
throw new Error(`Cannot resolve XPointer path: ${elementPath}`);
|
|
}
|
|
|
|
// Find the Kth direct text node child and compute cumulative offset
|
|
const textOffset = this.resolveIndexedTextNode(element, textNodeIndex, offsetInNode);
|
|
return { element, textOffset };
|
|
}
|
|
|
|
// Format: /text().N — cumulative character offset
|
|
const textOffsetMatch = xpointer.match(/\/text\(\)\.(\d+)$/);
|
|
const textOffset = textOffsetMatch ? parseInt(textOffsetMatch[1]!, 10) : undefined;
|
|
|
|
const elementPath =
|
|
textOffset !== undefined ? xpointer.replace(/\/text\(\)\.\d+$/, '') : xpointer;
|
|
|
|
const element = this.resolveXPointerPath(elementPath);
|
|
if (!element) {
|
|
throw new Error(`Cannot resolve XPointer path: ${elementPath}`);
|
|
}
|
|
|
|
return { element, textOffset };
|
|
}
|
|
|
|
/**
|
|
* Resolve text()[K].N to a cumulative character offset within the element.
|
|
* K is the 1-based index of direct text node children of the element
|
|
* (counting only Text nodes that are immediate children, skipping element children).
|
|
* N is the character offset within that specific text node.
|
|
*/
|
|
private resolveIndexedTextNode(
|
|
element: Element,
|
|
textNodeIndex: number,
|
|
offsetInNode: number,
|
|
): number {
|
|
let directTextCount = 0;
|
|
let cumulativeOffset = 0;
|
|
|
|
for (const child of Array.from(element.childNodes)) {
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
directTextCount++;
|
|
if (directTextCount === textNodeIndex) {
|
|
return cumulativeOffset + offsetInNode;
|
|
}
|
|
cumulativeOffset += (child.textContent || '').length;
|
|
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
// Count text length inside child elements for cumulative offset
|
|
cumulativeOffset += (child.textContent || '').length;
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Text node index ${textNodeIndex} out of bounds (found ${directTextCount} direct text nodes)`,
|
|
);
|
|
}
|
|
|
|
private resolveXPointerPath(path: string): Element | null {
|
|
const pathMatch = path.match(/^\/body\/DocFragment\[\d+\]\/body(.*)$/);
|
|
if (!pathMatch) {
|
|
throw new Error(`Invalid XPointer format: ${path}`);
|
|
}
|
|
|
|
const elementPath = pathMatch[1]!;
|
|
let current: Element = this.document.body;
|
|
|
|
if (!elementPath || elementPath === '') {
|
|
return current;
|
|
}
|
|
|
|
const segments = elementPath.split('/').filter(Boolean);
|
|
for (const segment of segments) {
|
|
// Match both formats: tag[index] or just tag
|
|
const segmentWithIndexMatch = segment.match(/^(\w+)\[(\d+)\]$/);
|
|
const segmentWithoutIndexMatch = segment.match(/^(\w+)$/);
|
|
|
|
let tagName: string;
|
|
let index: number;
|
|
|
|
if (segmentWithIndexMatch) {
|
|
// Format: tag[index] (1-based index)
|
|
const [, tag, indexStr] = segmentWithIndexMatch;
|
|
tagName = tag!;
|
|
index = Math.max(0, parseInt(indexStr!, 10) - 1);
|
|
} else if (segmentWithoutIndexMatch) {
|
|
// Format: tag (implicit index 0)
|
|
const [, tag] = segmentWithoutIndexMatch;
|
|
tagName = tag!;
|
|
index = 0;
|
|
} else {
|
|
throw new Error(`Invalid XPointer segment: ${segment}`);
|
|
}
|
|
|
|
// Find child elements with matching tag name. effectiveChildren drops
|
|
// cfi-inert nodes and hoists cfi-skip wrappers, so a layout-only wrapper
|
|
// doesn't shift indices relative to KOReader's wrapper-less DOM.
|
|
const children = this.effectiveChildren(current).filter(
|
|
(child) => child.tagName.toLowerCase() === tagName?.toLowerCase(),
|
|
);
|
|
|
|
if (index >= children.length) {
|
|
throw new Error(`Element index ${index} out of bounds for tag ${tagName}`);
|
|
}
|
|
|
|
current = children[index]!;
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Find text node and offset within element based on cumulative character offset
|
|
*/
|
|
private findTextNodeAtOffset(
|
|
element: Element,
|
|
offset: number,
|
|
): { node: Text; offset: number } | null {
|
|
const textNodes: Text[] = [];
|
|
this.collectTextNodes(element, textNodes);
|
|
|
|
let currentOffset = 0;
|
|
|
|
for (const textNode of textNodes) {
|
|
const nodeText = textNode.textContent || '';
|
|
const nodeLength = nodeText.length;
|
|
|
|
if (currentOffset + nodeLength >= offset) {
|
|
return {
|
|
node: textNode,
|
|
offset: offset - currentOffset,
|
|
};
|
|
}
|
|
|
|
currentOffset += nodeLength;
|
|
}
|
|
|
|
// If offset is beyond all text, return the last text node at its end
|
|
if (textNodes.length > 0) {
|
|
const lastNode = textNodes[textNodes.length - 1]!;
|
|
return {
|
|
node: lastNode,
|
|
offset: (lastNode.textContent || '').length,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private adjustSpineIndex(cfi: string): string {
|
|
const cfiMatch = cfi.match(/^epubcfi\((.+)\)$/);
|
|
if (!cfiMatch) {
|
|
throw new Error(`Invalid CFI format: ${cfi}`);
|
|
}
|
|
|
|
const innerCfi = cfiMatch[1]!;
|
|
const spineStep = (this.spineItemIndex + 1) * 2; // Convert 0-based to CFI format
|
|
|
|
if (innerCfi.match(/^\/6\/\d+!/)) {
|
|
const adjustedInner = innerCfi.replace(/^\/6\/\d+!/, `/6/${spineStep}!`);
|
|
return `epubcfi(${adjustedInner})`;
|
|
} else {
|
|
const adjustedInner = `/6/${spineStep}!${innerCfi}`;
|
|
return `epubcfi(${adjustedInner})`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a range point (container + offset) to XPointer
|
|
*/
|
|
private rangePointToXPointer(container: Node, offset: number): string {
|
|
if (container.nodeType === Node.TEXT_NODE) {
|
|
// For text nodes, find the containing element
|
|
const element = container.parentElement || this.document.documentElement;
|
|
return this.handleTextOffsetInElement(element, container as Text, offset);
|
|
} else if (container.nodeType === Node.ELEMENT_NODE) {
|
|
const element = container as Element;
|
|
if (offset === 0) {
|
|
if (element.childNodes.length > 0) {
|
|
const firstChild = element.childNodes[0] as Element;
|
|
if (firstChild.nodeType === Node.ELEMENT_NODE) {
|
|
return this.buildXPointerPath(element.childNodes[0] as Element);
|
|
}
|
|
}
|
|
return this.buildXPointerPath(element);
|
|
} else {
|
|
// Offset points to a child node
|
|
const childNodes = Array.from(element.childNodes);
|
|
const targetChild = childNodes[offset - 1] || childNodes[childNodes.length - 1];
|
|
|
|
if (targetChild?.nodeType === Node.ELEMENT_NODE) {
|
|
return this.buildXPointerPath(targetChild as Element);
|
|
} else if (targetChild?.nodeType === Node.TEXT_NODE) {
|
|
return this.handleTextOffsetInElement(
|
|
element,
|
|
targetChild as Text,
|
|
(targetChild as Text).textContent?.length || 0,
|
|
);
|
|
} else {
|
|
return this.buildXPointerPath(element);
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback to document element
|
|
return this.buildXPointerPath(this.document.documentElement);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an element is injected by Readest at runtime and should be
|
|
* invisible to XPointer path building / resolution (e.g. skip-link div).
|
|
*/
|
|
private static isCfiInert(element: Element): boolean {
|
|
return element.hasAttribute('cfi-inert');
|
|
}
|
|
|
|
/**
|
|
* A cfi-skip element (e.g. the layout-only scroll wrapper applyScrollableStyle
|
|
* adds around a table/equation) must be transparent to XPointer paths: KOReader's
|
|
* CREngine DOM has no such wrapper, so its children must keep the indices they'd
|
|
* have without it. Unlike cfi-inert (drops the node AND its subtree), cfi-skip
|
|
* hoists the node's children into its parent. Must match epubcfi.js's cfi-skip
|
|
* handling so CFI ↔ XPointer round-trips through the same logical structure.
|
|
*/
|
|
private static isCfiSkip(element: Element): boolean {
|
|
return element.hasAttribute('cfi-skip');
|
|
}
|
|
|
|
/** Nearest ancestor-or-self element that is not a cfi-skip wrapper. */
|
|
private static skipTransparentParent(element: Element): Element {
|
|
let el: Element = element;
|
|
while (XCFI.isCfiSkip(el) && el.parentElement) el = el.parentElement;
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* Element children of `parent` as XPointer sees them: cfi-inert nodes removed and
|
|
* cfi-skip wrappers spliced out (their own children hoisted in place, recursively).
|
|
*/
|
|
private effectiveChildren(parent: Element): Element[] {
|
|
const result: Element[] = [];
|
|
for (const child of Array.from(parent.children)) {
|
|
if (XCFI.isCfiInert(child)) continue;
|
|
if (XCFI.isCfiSkip(child)) result.push(...this.effectiveChildren(child));
|
|
else result.push(child);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Build XPointer path from DOM element
|
|
*/
|
|
private buildXPointerPath(targetElement: Element): string {
|
|
const pathParts: string[] = [];
|
|
let current: Element | null = targetElement;
|
|
|
|
// Build path from target back to root
|
|
while (current && current !== this.document.documentElement) {
|
|
// A cfi-skip wrapper contributes no path segment: it is hoisted away, so
|
|
// just continue from its parent (matches KOReader's wrapper-less DOM).
|
|
if (XCFI.isCfiSkip(current)) {
|
|
current = current.parentElement;
|
|
continue;
|
|
}
|
|
const parent: Element | null = current.parentElement;
|
|
if (!parent) break;
|
|
|
|
const tagName = current.tagName.toLowerCase();
|
|
// Count preceding siblings with the same tag name among the effective
|
|
// (cfi-inert removed, cfi-skip hoisted) children of the nearest non-skip
|
|
// ancestor, so a layout-only wrapper doesn't shift the index.
|
|
const siblings = this.effectiveChildren(XCFI.skipTransparentParent(parent));
|
|
let siblingIndex = 0;
|
|
let totalSameTagSiblings = 0;
|
|
for (const sibling of siblings) {
|
|
if (sibling.tagName.toLowerCase() === tagName) {
|
|
if (sibling === current) {
|
|
siblingIndex = totalSameTagSiblings;
|
|
}
|
|
totalSameTagSiblings++;
|
|
}
|
|
}
|
|
|
|
// Format as tag[index] (0-based for CREngine)
|
|
// Omit [0] if there's only one element with this tag name
|
|
if (totalSameTagSiblings === 1) {
|
|
pathParts.unshift(tagName);
|
|
} else {
|
|
pathParts.unshift(`${tagName}[${siblingIndex + 1}]`); // Convert to 1-based index for XPointer
|
|
}
|
|
current = parent;
|
|
}
|
|
|
|
let xpointer = `/body/DocFragment[${this.spineItemIndex + 1}]`;
|
|
if (pathParts.length > 0 && pathParts[0]!.startsWith('body')) {
|
|
pathParts.shift();
|
|
}
|
|
xpointer += '/body';
|
|
|
|
if (pathParts.length > 0) {
|
|
xpointer += '/' + pathParts.join('/');
|
|
}
|
|
|
|
return xpointer;
|
|
}
|
|
|
|
/**
|
|
* Handle text offset within an element by finding character position.
|
|
* Produces KOReader-compatible text()[K].N format where K is the 1-based
|
|
* index of the direct text node child within its parent element.
|
|
*/
|
|
private handleTextOffset(element: Element, cfiOffset: number): string {
|
|
const textNodes: Text[] = [];
|
|
this.collectTextNodes(element, textNodes);
|
|
|
|
let totalChars = 0;
|
|
let targetTextNode: Text | null = null;
|
|
let offsetInNode = 0;
|
|
|
|
for (const textNode of textNodes) {
|
|
const nodeText = textNode.textContent || '';
|
|
const nodeLength = nodeText.length;
|
|
|
|
if (totalChars + nodeLength >= cfiOffset) {
|
|
targetTextNode = textNode;
|
|
offsetInNode = cfiOffset - totalChars;
|
|
break;
|
|
}
|
|
|
|
totalChars += nodeLength;
|
|
}
|
|
|
|
if (!targetTextNode) {
|
|
// Offset beyond text content, use element end
|
|
return this.buildXPointerPath(element);
|
|
}
|
|
|
|
// Use the text node's direct parent for both path and indexing.
|
|
// This produces correct XPointers even when inline elements (like <a>)
|
|
// split text into multiple direct text node children.
|
|
const textParent = targetTextNode.parentElement || element;
|
|
const basePath = this.buildXPointerPath(textParent);
|
|
|
|
// Count direct text node children and find which one the target is
|
|
let directTextCount = 0;
|
|
let directTextIndex = 0;
|
|
for (const child of Array.from(textParent.childNodes)) {
|
|
if (child.nodeType === Node.TEXT_NODE && (child.textContent || '').length > 0) {
|
|
directTextCount++;
|
|
if (child === targetTextNode) {
|
|
directTextIndex = directTextCount;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Omit [1] when there is only one direct text node (matches KOReader format)
|
|
if (directTextCount <= 1) {
|
|
return `${basePath}/text().${offsetInNode}`;
|
|
}
|
|
return `${basePath}/text()[${directTextIndex || 1}].${offsetInNode}`;
|
|
}
|
|
|
|
/**
|
|
* Handle text offset for a specific text node within an element
|
|
*/
|
|
private handleTextOffsetInElement(element: Element, textNode: Text, offset: number): string {
|
|
// Find all text nodes in the element to calculate cumulative offset
|
|
const textNodes: Text[] = [];
|
|
this.collectTextNodes(element, textNodes);
|
|
|
|
let cumulativeOffset = 0;
|
|
for (const node of textNodes) {
|
|
if (node === textNode) {
|
|
cumulativeOffset += offset;
|
|
break;
|
|
}
|
|
cumulativeOffset += (node.textContent || '').length;
|
|
}
|
|
|
|
return this.handleTextOffset(element, cumulativeOffset);
|
|
}
|
|
|
|
/**
|
|
* Collect all text nodes in document order
|
|
*/
|
|
private collectTextNodes(element: Element, textNodes: Text[]): void {
|
|
for (const child of Array.from(element.childNodes)) {
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
const text = child.textContent || '';
|
|
if (text.length > 0) {
|
|
textNodes.push(child as Text);
|
|
}
|
|
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
this.collectTextNodes(child as Element, textNodes);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const getCFIFromXPointer = async (
|
|
xpointer: string,
|
|
doc?: Document,
|
|
index?: number,
|
|
bookDoc?: BookDoc,
|
|
) => {
|
|
const xSpineIndex = XCFI.extractSpineIndex(xpointer);
|
|
let converter: XCFI;
|
|
if (index === xSpineIndex && doc) {
|
|
converter = new XCFI(doc, index || 0);
|
|
} else {
|
|
const doc = await bookDoc?.sections?.[xSpineIndex]?.createDocument();
|
|
if (!doc) throw new Error('Failed to load document for XPointer conversion.');
|
|
converter = new XCFI(doc, xSpineIndex || 0);
|
|
}
|
|
|
|
const cfi = converter.xPointerToCFI(xpointer);
|
|
return cfi;
|
|
};
|
|
|
|
export const getXPointerFromCFI = async (
|
|
cfi: string,
|
|
doc?: Document,
|
|
index?: number,
|
|
bookDoc?: BookDoc,
|
|
): Promise<XPointer> => {
|
|
const xSpineIndex = XCFI.extractSpineIndex(cfi);
|
|
let converter: XCFI;
|
|
if (index === xSpineIndex && doc) {
|
|
converter = new XCFI(doc, index || 0);
|
|
} else {
|
|
const doc = await bookDoc?.sections?.[xSpineIndex]?.createDocument();
|
|
if (!doc) throw new Error('Failed to load document for CFI conversion.');
|
|
converter = new XCFI(doc, xSpineIndex || 0);
|
|
}
|
|
|
|
const xpointer = converter.cfiToXPointer(cfi);
|
|
return xpointer;
|
|
};
|
|
|
|
// Koreader sometimes cannot recognize totally valid XPointer.
|
|
// Workaround this by cleaning up any trailing /text().N segments.
|
|
// Also remove any trailing .N suffixes after node steps.
|
|
// This has neglectable effect on position accuracy as the XPointer still point to the correct element
|
|
// while offset within the text node is usually ignored by pagination.
|
|
export const normalizeProgressXPointer = (xpointer: string): string => {
|
|
const tailingTextOffset = /\/text\(\).*$/;
|
|
if (xpointer.match(tailingTextOffset)) {
|
|
xpointer = xpointer.replace(tailingTextOffset, '');
|
|
}
|
|
const suffixNodeOffset = /\.\d+$/;
|
|
if (xpointer.match(suffixNodeOffset)) {
|
|
xpointer = xpointer.replace(suffixNodeOffset, '');
|
|
}
|
|
return xpointer;
|
|
};
|