sync: add xcfi to convert between readest epubcfi and koreader xpointer (#1774)
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { XCFI } from '@/utils/xcfi';
|
||||
|
||||
describe('CFIToXPointerConverter', () => {
|
||||
let converter: XCFI;
|
||||
let simpleDoc: Document;
|
||||
let complexDoc: Document;
|
||||
|
||||
beforeEach(() => {
|
||||
simpleDoc = new DOMParser().parseFromString(
|
||||
`
|
||||
<html>
|
||||
<head>
|
||||
<title>Simple Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<p>First paragraph</p>
|
||||
<p>Second paragraph with some text</p>
|
||||
<p>Third paragraph</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
complexDoc = new DOMParser().parseFromString(
|
||||
`
|
||||
<html>
|
||||
<head>
|
||||
<title>Complex Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<section>
|
||||
<h1>Chapter 1</h1>
|
||||
<p>First paragraph</p>
|
||||
<p>Second paragraph</p>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Chapter 2</h1>
|
||||
<p id="special">Another paragraph</p>
|
||||
<p>Final paragraph with <em>emphasis</em> and more text</p>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'text/html',
|
||||
);
|
||||
});
|
||||
|
||||
describe('static methods', () => {
|
||||
it('should extract spine index from CFI', () => {
|
||||
const cfi1 = 'epubcfi(/6/2!/4/2/4)'; // Spine index 0
|
||||
const cfi2 = 'epubcfi(/6/4!/4/2/4)'; // Spine index 1
|
||||
const cfi3 = 'epubcfi(/6/10!/4/2/4)'; // Spine index 4
|
||||
|
||||
expect(XCFI.extractSpineIndex(cfi1)).toBe(0);
|
||||
expect(XCFI.extractSpineIndex(cfi2)).toBe(1);
|
||||
expect(XCFI.extractSpineIndex(cfi3)).toBe(4);
|
||||
});
|
||||
|
||||
it('should extract spine index from range CFI', () => {
|
||||
const rangeCfi = 'epubcfi(/6/8!/4/2/2/1:5,/6/8!/4/2/4/1:10)'; // Spine index 3
|
||||
expect(XCFI.extractSpineIndex(rangeCfi)).toBe(3);
|
||||
});
|
||||
|
||||
it('should extract spine index from CFI with assertions', () => {
|
||||
const cfi = 'epubcfi(/6/1266!/4,/76,/88/1:85)'; // Complex CFI, spine index 632
|
||||
expect(XCFI.extractSpineIndex(cfi)).toBe(632);
|
||||
});
|
||||
|
||||
it('should throw error for invalid CFI in extractSpineIndex', () => {
|
||||
const invalidCfi = 'invalid-cfi';
|
||||
expect(() => XCFI.extractSpineIndex(invalidCfi)).toThrow('Cannot extract spine index');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion - point CFI', () => {
|
||||
beforeEach(() => {
|
||||
converter = new XCFI(simpleDoc, 1);
|
||||
});
|
||||
|
||||
it('should convert first element CFI round-trip', () => {
|
||||
const originalCfi = 'epubcfi(/6/4!/4/2/2)'; // First p element
|
||||
const xpointer = converter.cfiToXPointer(originalCfi);
|
||||
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
|
||||
|
||||
expect(originalCfi).toEqual(convertedCfi);
|
||||
expect(xpointer).toEqual({
|
||||
xpointer: '/body/DocFragment[1]/body/div[0]/p[0]',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert basic element CFI round-trip', () => {
|
||||
const originalCfi = 'epubcfi(/6/4!/4/2/4)'; // Second p element
|
||||
const xpointer = converter.cfiToXPointer(originalCfi);
|
||||
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
|
||||
|
||||
expect(originalCfi).toEqual(convertedCfi);
|
||||
expect(xpointer).toEqual({
|
||||
xpointer: '/body/DocFragment[1]/body/div[0]/p[1]',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert third element CFI round-trip', () => {
|
||||
const originalCfi = 'epubcfi(/6/4!/4/2/6)'; // Third p element
|
||||
const xpointer = converter.cfiToXPointer(originalCfi);
|
||||
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
|
||||
|
||||
expect(originalCfi).toEqual(convertedCfi);
|
||||
expect(xpointer).toEqual({
|
||||
xpointer: '/body/DocFragment[1]/body/div[0]/p[2]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion - range CFI', () => {
|
||||
beforeEach(() => {
|
||||
converter = new XCFI(simpleDoc, 2);
|
||||
});
|
||||
|
||||
it('should convert standard range CFI', () => {
|
||||
const originalCfi = 'epubcfi(/6/6!/4/2,/2/1:6,/4/1:16)'; // From first p:6 to second p:16
|
||||
const xpointer = converter.cfiToXPointer(originalCfi);
|
||||
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
|
||||
|
||||
expect(originalCfi).toEqual(convertedCfi);
|
||||
expect(xpointer.xpointer).toEqual('/body/DocFragment[2]/body/div[0]/p[0]/text().6');
|
||||
expect(xpointer.pos0).toEqual('/body/DocFragment[2]/body/div[0]/p[0]/text().6');
|
||||
expect(xpointer.pos1).toEqual('/body/DocFragment[2]/body/div[0]/p[1]/text().16');
|
||||
});
|
||||
|
||||
it('should convert range CFI within same element', () => {
|
||||
const originalCfi = 'epubcfi(/6/6!/4/2/4,/1:5,/1:10)'; // Within second p element
|
||||
const xpointer = converter.cfiToXPointer(originalCfi);
|
||||
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
|
||||
|
||||
expect(originalCfi).toEqual(convertedCfi);
|
||||
expect(xpointer.pos0).toMatch(/\/text\(\)\.5$/);
|
||||
expect(xpointer.pos1).toMatch(/\/text\(\)\.10$/);
|
||||
});
|
||||
|
||||
it('should handle range across multiple elements', () => {
|
||||
const originalCfi = 'epubcfi(/6/6!/4/2,/2,/6)'; // From first to third p
|
||||
const xpointer = converter.cfiToXPointer(originalCfi);
|
||||
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
|
||||
|
||||
expect(originalCfi).toEqual(convertedCfi);
|
||||
expect(xpointer.pos0).toMatch(/p\[0\]/);
|
||||
expect(xpointer.pos1).toMatch(/p\[2\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion - complex document', () => {
|
||||
beforeEach(() => {
|
||||
converter = new XCFI(complexDoc, 3);
|
||||
});
|
||||
|
||||
it('should handle nested elements', () => {
|
||||
const originalCfi = 'epubcfi(/6/8!/4/2/2)'; // First section
|
||||
const xpointer = converter.cfiToXPointer(originalCfi);
|
||||
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
|
||||
|
||||
expect(originalCfi).toEqual(convertedCfi);
|
||||
expect(xpointer.xpointer).toMatch(/\/body\/section\[0\]/);
|
||||
});
|
||||
|
||||
it('should handle elements with IDs', () => {
|
||||
const originalCfi = 'epubcfi(/6/8!/4/4/4[special])'; // Element with id="special"
|
||||
const xpointer = converter.cfiToXPointer(originalCfi);
|
||||
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
|
||||
|
||||
expect(originalCfi).toEqual(convertedCfi);
|
||||
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[0\]/);
|
||||
});
|
||||
|
||||
it('should handle inline elements', () => {
|
||||
const originalCfi = 'epubcfi(/6/8!/4/4/6)'; // Text with inline em element
|
||||
const xpointer = converter.cfiToXPointer(originalCfi);
|
||||
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
|
||||
|
||||
expect(originalCfi).toEqual(convertedCfi);
|
||||
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[1\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertCFI - error handling', () => {
|
||||
beforeEach(() => {
|
||||
converter = new XCFI(simpleDoc, 0);
|
||||
});
|
||||
|
||||
it('should throw error for invalid CFI format', () => {
|
||||
const invalidCfi = 'invalid-cfi';
|
||||
expect(() => converter.cfiToXPointer(invalidCfi)).toThrow('Failed to convert CFI');
|
||||
});
|
||||
|
||||
it('should throw error for CFI with invalid path', () => {
|
||||
const invalidCfi = 'epubcfi(/6/999!/2/2)'; // Non-existent path
|
||||
expect(() => converter.cfiToXPointer(invalidCfi)).toThrow();
|
||||
});
|
||||
|
||||
it('should handle malformed CFI gracefully', () => {
|
||||
const malformedCfi = 'epubcfi(/6/2/2';
|
||||
expect(() => converter.cfiToXPointer(malformedCfi)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('xPointerToCFI - direct XPointer input', () => {
|
||||
beforeEach(() => {
|
||||
converter = new XCFI(simpleDoc, 1);
|
||||
});
|
||||
|
||||
it('should convert XPointer to CFI for first element', () => {
|
||||
const xpointer = '/body/DocFragment[1]/body/div[0]/p[0]';
|
||||
const cfi = converter.xPointerToCFI(xpointer);
|
||||
|
||||
// Verify by converting back to XPointer
|
||||
const backToXPointer = converter.cfiToXPointer(cfi);
|
||||
expect(backToXPointer.xpointer).toBe(xpointer);
|
||||
});
|
||||
|
||||
it('should convert XPointer to CFI for second element', () => {
|
||||
const xpointer = '/body/DocFragment[1]/body/div[0]/p[1]';
|
||||
const cfi = converter.xPointerToCFI(xpointer);
|
||||
|
||||
const backToXPointer = converter.cfiToXPointer(cfi);
|
||||
expect(backToXPointer.xpointer).toBe(xpointer);
|
||||
});
|
||||
|
||||
it('should convert XPointer with text offset to CFI', () => {
|
||||
const xpointer = '/body/DocFragment[1]/body/div[0]/p[0]/text().6';
|
||||
const cfi = converter.xPointerToCFI(xpointer);
|
||||
expect(cfi).toBe('epubcfi(/6/4!/4/2/2/1:6)');
|
||||
});
|
||||
|
||||
it('should convert range XPointer to CFI', () => {
|
||||
const pos0 = '/body/DocFragment[1]/body/div[0]/p[0]/text().6';
|
||||
const pos1 = '/body/DocFragment[1]/body/div[0]/p[1]/text().16';
|
||||
const cfi = converter.xPointerToCFI(pos0, pos1);
|
||||
const xpointer = converter.cfiToXPointer(cfi);
|
||||
|
||||
expect(cfi).toMatch(/^epubcfi\([^,]+,[^,]+,[^,]+\)$/);
|
||||
expect(xpointer.pos0).toBe(pos0);
|
||||
expect(xpointer.pos1).toBe(pos1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('xPointerToCFI - error handling', () => {
|
||||
beforeEach(() => {
|
||||
converter = new XCFI(simpleDoc, 0);
|
||||
});
|
||||
|
||||
it('should throw error for invalid XPointer format', () => {
|
||||
const invalidXPointer = 'invalid-xpointer';
|
||||
expect(() => converter.xPointerToCFI(invalidXPointer)).toThrow('Failed to convert XPointer');
|
||||
});
|
||||
|
||||
it('should throw error for XPointer with non-existent path', () => {
|
||||
const invalidXPointer = '/body/DocFragment[0]/body/nonexistent[999]';
|
||||
expect(() => converter.xPointerToCFI(invalidXPointer)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for malformed XPointer', () => {
|
||||
const malformedXPointer = '/body/DocFragment[0]/body/div[';
|
||||
expect(() => converter.xPointerToCFI(malformedXPointer)).toThrow();
|
||||
});
|
||||
|
||||
it('should handle CFI without spine step prefix', () => {
|
||||
// Test the adjustSpineIndex method handles CFIs that don't start with /6/n!
|
||||
const converter = new XCFI(simpleDoc, 3); // Use different spine index
|
||||
const xpointer = '/body/DocFragment[3]/body/div[0]/p[0]';
|
||||
const cfi = converter.xPointerToCFI(xpointer);
|
||||
|
||||
// Verify the spine step is correctly added/adjusted
|
||||
expect(cfi).toMatch(/^epubcfi\(\/6\/8!/); // (3+1)*2 = 8
|
||||
|
||||
// Verify round-trip works
|
||||
const backToXPointer = converter.cfiToXPointer(cfi);
|
||||
expect(backToXPointer.xpointer).toBe(xpointer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCFI', () => {
|
||||
beforeEach(() => {
|
||||
converter = new XCFI(simpleDoc, 0);
|
||||
});
|
||||
|
||||
it('should validate correct CFI', () => {
|
||||
const validCfi = 'epubcfi(/6/2!/4/4)';
|
||||
expect(converter.validateCFI(validCfi)).toBe(true);
|
||||
});
|
||||
|
||||
it('should invalidate incorrect CFI format', () => {
|
||||
const invalidCfi = 'invalid-cfi';
|
||||
expect(converter.validateCFI(invalidCfi)).toBe(false);
|
||||
});
|
||||
|
||||
it('should invalidate CFI with wrong path', () => {
|
||||
const invalidCfi = 'epubcfi(/6/2!/998/2/2)';
|
||||
expect(converter.validateCFI(invalidCfi)).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate range CFI', () => {
|
||||
const validRangeCfi = 'epubcfi(/6/2!/4/2,/2/1:5,/4/1:10)';
|
||||
expect(converter.validateCFI(validRangeCfi)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
beforeEach(() => {
|
||||
converter = new XCFI(simpleDoc, 0);
|
||||
});
|
||||
|
||||
it('should handle empty elements', () => {
|
||||
const emptyDoc = new DOMParser().parseFromString(
|
||||
`
|
||||
<html>
|
||||
<body>
|
||||
<div>
|
||||
<p></p>
|
||||
<p>Non-empty</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const converter = new XCFI(emptyDoc, 2);
|
||||
const cfi = 'epubcfi(/6/6!/4/2/2)'; // Empty p element
|
||||
const result = converter.cfiToXPointer(cfi);
|
||||
|
||||
expect(result.xpointer).toBe('/body/DocFragment[2]/body/div[0]/p[0]');
|
||||
});
|
||||
|
||||
it('should handle whitespace-only text nodes', () => {
|
||||
const whitespaceDoc = new DOMParser().parseFromString(
|
||||
`
|
||||
<html>
|
||||
<body>
|
||||
<div>
|
||||
<p> </p>
|
||||
<p>Real content</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const converter = new XCFI(whitespaceDoc, 2);
|
||||
const cfi = 'epubcfi(/6/6!/4/2/4)'; // Second p element
|
||||
const result = converter.cfiToXPointer(cfi);
|
||||
|
||||
expect(result.xpointer).toBe('/body/DocFragment[2]/body/div[0]/p[1]');
|
||||
});
|
||||
|
||||
it('should handle deeply nested elements', () => {
|
||||
const nestedDoc = new DOMParser().parseFromString(
|
||||
`
|
||||
<html>
|
||||
<body>
|
||||
<div>
|
||||
<section>
|
||||
<article>
|
||||
<p>Deeply nested</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const converter = new XCFI(nestedDoc, 2);
|
||||
const cfi = 'epubcfi(/6/6!/4/2/2/2/2)'; // Deeply nested p
|
||||
const result = converter.cfiToXPointer(cfi);
|
||||
|
||||
expect(result.xpointer).toBe('/body/DocFragment[2]/body/div[0]/section[0]/article[0]/p[0]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* Converter between EPUB CFI and CREngine XPointer
|
||||
* Converts between Readest (foliate-js) CFI format and KOReader CREngine XPointer format
|
||||
*/
|
||||
|
||||
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(cfi: string): number {
|
||||
try {
|
||||
const collapsed = collapse(parse(cfi));
|
||||
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);
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot extract spine index from CFI: ${cfi} - ${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) {
|
||||
const segmentMatch = segment.match(/^(\w+)\[(\d+)\]$/);
|
||||
if (!segmentMatch) {
|
||||
throw new Error(`Invalid XPointer segment: ${segment}`);
|
||||
}
|
||||
|
||||
const [, tagName, indexStr] = segmentMatch;
|
||||
const index = parseInt(indexStr!, 10);
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
for (const sibling of Array.from(parent.children)) {
|
||||
if (sibling === current) break;
|
||||
if (sibling.tagName.toLowerCase() === tagName) {
|
||||
siblingIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Format as tag[index] (0-based for CREngine)
|
||||
pathParts.unshift(`${tagName}[${siblingIndex}]`);
|
||||
current = parent;
|
||||
}
|
||||
|
||||
let xpointer = `/body/DocFragment[${this.spineItemIndex}]`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user