diff --git a/apps/readest-app/src/__tests__/utils/xcfi.spec.ts b/apps/readest-app/src/__tests__/utils/xcfi.spec.ts index 5569b93e..c2d37da2 100644 --- a/apps/readest-app/src/__tests__/utils/xcfi.spec.ts +++ b/apps/readest-app/src/__tests__/utils/xcfi.spec.ts @@ -88,7 +88,7 @@ describe('CFIToXPointerConverter', () => { expect(originalCfi).toEqual(convertedCfi); expect(xpointer).toEqual({ - xpointer: '/body/DocFragment[1]/body/div[0]/p[0]', + xpointer: '/body/DocFragment[2]/body/div/p[0]', }); }); @@ -99,7 +99,7 @@ describe('CFIToXPointerConverter', () => { expect(originalCfi).toEqual(convertedCfi); expect(xpointer).toEqual({ - xpointer: '/body/DocFragment[1]/body/div[0]/p[1]', + xpointer: '/body/DocFragment[2]/body/div/p[1]', }); }); @@ -110,7 +110,7 @@ describe('CFIToXPointerConverter', () => { expect(originalCfi).toEqual(convertedCfi); expect(xpointer).toEqual({ - xpointer: '/body/DocFragment[1]/body/div[0]/p[2]', + xpointer: '/body/DocFragment[2]/body/div/p[2]', }); }); }); @@ -126,9 +126,9 @@ describe('CFIToXPointerConverter', () => { 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'); + expect(xpointer.xpointer).toEqual('/body/DocFragment[3]/body/div/p[0]/text().6'); + expect(xpointer.pos0).toEqual('/body/DocFragment[3]/body/div/p[0]/text().6'); + expect(xpointer.pos1).toEqual('/body/DocFragment[3]/body/div/p[1]/text().16'); }); it('should convert range CFI within same element', () => { @@ -212,7 +212,7 @@ describe('CFIToXPointerConverter', () => { }); it('should convert XPointer to CFI for first element', () => { - const xpointer = '/body/DocFragment[1]/body/div[0]/p[0]'; + const xpointer = '/body/DocFragment[2]/body/div/p[0]'; const cfi = converter.xPointerToCFI(xpointer); // Verify by converting back to XPointer @@ -221,7 +221,7 @@ describe('CFIToXPointerConverter', () => { }); it('should convert XPointer to CFI for second element', () => { - const xpointer = '/body/DocFragment[1]/body/div[0]/p[1]'; + const xpointer = '/body/DocFragment[2]/body/div/p[1]'; const cfi = converter.xPointerToCFI(xpointer); const backToXPointer = converter.cfiToXPointer(cfi); @@ -229,14 +229,14 @@ describe('CFIToXPointerConverter', () => { }); it('should convert XPointer with text offset to CFI', () => { - const xpointer = '/body/DocFragment[1]/body/div[0]/p[0]/text().6'; + const xpointer = '/body/DocFragment[2]/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 pos0 = '/body/DocFragment[2]/body/div/p[0]/text().6'; + const pos1 = '/body/DocFragment[2]/body/div/p[1]/text().16'; const cfi = converter.xPointerToCFI(pos0, pos1); const xpointer = converter.cfiToXPointer(cfi); @@ -257,19 +257,19 @@ describe('CFIToXPointerConverter', () => { }); it('should throw error for XPointer with non-existent path', () => { - const invalidXPointer = '/body/DocFragment[0]/body/nonexistent[999]'; + const invalidXPointer = '/body/DocFragment[1]/body/nonexistent[999]'; expect(() => converter.xPointerToCFI(invalidXPointer)).toThrow(); }); it('should throw error for malformed XPointer', () => { - const malformedXPointer = '/body/DocFragment[0]/body/div['; + const malformedXPointer = '/body/DocFragment[1]/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 xpointer = '/body/DocFragment[4]/body/div/p[0]'; const cfi = converter.xPointerToCFI(xpointer); // Verify the spine step is correctly added/adjusted @@ -331,7 +331,7 @@ describe('CFIToXPointerConverter', () => { 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]'); + expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[0]'); }); it('should handle whitespace-only text nodes', () => { @@ -353,7 +353,7 @@ describe('CFIToXPointerConverter', () => { 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]'); + expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[1]'); }); it('should handle deeply nested elements', () => { @@ -364,7 +364,8 @@ describe('CFIToXPointerConverter', () => {
-

Deeply nested

+

Deeply nested p0

+

Deeply nested p1

@@ -378,7 +379,7 @@ describe('CFIToXPointerConverter', () => { 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]'); + expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/section/article/p[0]'); }); }); }); diff --git a/apps/readest-app/src/utils/xcfi.ts b/apps/readest-app/src/utils/xcfi.ts index 19e68c77..5d3da004 100644 --- a/apps/readest-app/src/utils/xcfi.ts +++ b/apps/readest-app/src/utils/xcfi.ts @@ -20,20 +20,30 @@ export class XCFI { this.spineItemIndex = spineIndex; } - static extractSpineIndex(cfi: string): number { + static extractSpineIndex(cfiOrXPath: 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'); - } + 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); + // 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: ${cfi} - ${error}`); + throw new Error(`Cannot extract spine index from CFI/XPointer: ${cfiOrXPath} - ${error}`); } } @@ -205,14 +215,27 @@ export class XCFI { const segments = elementPath.split('/').filter(Boolean); for (const segment of segments) { - const segmentMatch = segment.match(/^(\w+)\[(\d+)\]$/); - if (!segmentMatch) { + // 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}`); } - 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(), @@ -295,6 +318,12 @@ export class XCFI { } 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 @@ -334,20 +363,28 @@ export class XCFI { 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 === current) break; if (sibling.tagName.toLowerCase() === tagName) { - siblingIndex++; + if (sibling === current) { + siblingIndex = totalSameTagSiblings; + } + totalSameTagSiblings++; } } // Format as tag[index] (0-based for CREngine) - pathParts.unshift(`${tagName}[${siblingIndex}]`); + // 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}]`; - if (pathParts.length > 0 && pathParts[0]!.startsWith('body[')) { + let xpointer = `/body/DocFragment[${this.spineItemIndex + 1}]`; + if (pathParts.length > 0 && pathParts[0]!.startsWith('body')) { pathParts.shift(); } xpointer += '/body';