Files
readest/apps/readest-app/src/utils/xcfi.ts
T

554 lines
17 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[')) {
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
*/
private parseXPointer(xpointer: string): { element: Element; textOffset?: number } {
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 };
}
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]
const [, tag, indexStr] = segmentWithIndexMatch;
tagName = tag!;
index = parseInt(indexStr!, 10);
} 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
const children = Array.from(current.children).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);
}
}
/**
* 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) {
const parent: Element | null = current.parentElement;
if (!parent) break;
const tagName = current.tagName.toLowerCase();
// Count preceding siblings with same tag name (0-based for CREngine)
let siblingIndex = 0;
let totalSameTagSiblings = 0;
for (const sibling of Array.from(parent.children)) {
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}]`);
}
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
*/
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);
}
// Find the containing element for this text node
let textParent = targetTextNode.parentElement;
while (textParent && !this.isSignificantElement(textParent)) {
textParent = textParent.parentElement;
}
if (!textParent) {
textParent = element as HTMLElement;
}
const basePath = this.buildXPointerPath(textParent);
return `${basePath}/text().${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);
}
}
}
/**
* Check if an element is significant for XPointer path building
*/
private isSignificantElement(element: Element): boolean {
const tagName = element.tagName.toLowerCase();
// Skip inline formatting elements that don't affect structure
const inlineElements = new Set([
'span',
'em',
'strong',
'i',
'b',
'u',
'small',
'mark',
'sup',
'sub',
]);
return !inlineElements.has(tagName);
}
}
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.
// 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 tailingTextRange = /\/text\(\)\.\d+$/;
if (xpointer.match(tailingTextRange)) {
xpointer = xpointer.replace(tailingTextRange, '');
}
return xpointer;
};