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( + ` + + +
+

+

Non-empty

+
+ + + `, + '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( + ` + + +
+

+

Real content

+
+ + + `, + '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( + ` + + +
+
+
+

Deeply nested

+
+
+
+ + + `, + '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); + } +}