/** * 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 ) // 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 => { 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; };