diff --git a/apps/readest-app/src/__tests__/utils/xcfi.spec.ts b/apps/readest-app/src/__tests__/utils/xcfi.spec.ts
new file mode 100644
index 00000000..5569b93e
--- /dev/null
+++ b/apps/readest-app/src/__tests__/utils/xcfi.spec.ts
@@ -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(
+ `
+
+
+ Simple Document
+
+
+
+
First paragraph
+
Second paragraph with some text
+
Third paragraph
+
+
+
+ `,
+ 'text/html',
+ );
+
+ complexDoc = new DOMParser().parseFromString(
+ `
+
+
+ Complex Document
+
+
+
+ Chapter 1
+ First paragraph
+ Second paragraph
+
+
+ Chapter 2
+ Another paragraph
+ Final paragraph with emphasis and more text
+
+
+
+ `,
+ '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(
+ `
+
+
+
+
+
+ `,
+ '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(
+ `
+
+
+
+
+
+ `,
+ '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(
+ `
+
+
+
+
+
+ `,
+ '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]');
+ });
+ });
+});
diff --git a/apps/readest-app/src/utils/xcfi.ts b/apps/readest-app/src/utils/xcfi.ts
new file mode 100644
index 00000000..19e68c77
--- /dev/null
+++ b/apps/readest-app/src/utils/xcfi.ts
@@ -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);
+ }
+}