diff --git a/apps/readest-app/src/__tests__/utils/xcfi.spec.ts b/apps/readest-app/src/__tests__/utils/xcfi.spec.ts index c2d37da2..93411ffe 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[2]/body/div/p[0]', + xpointer: '/body/DocFragment[2]/body/div/p[1]', }); }); @@ -99,7 +99,7 @@ describe('CFIToXPointerConverter', () => { expect(originalCfi).toEqual(convertedCfi); expect(xpointer).toEqual({ - xpointer: '/body/DocFragment[2]/body/div/p[1]', + xpointer: '/body/DocFragment[2]/body/div/p[2]', }); }); @@ -110,7 +110,7 @@ describe('CFIToXPointerConverter', () => { expect(originalCfi).toEqual(convertedCfi); expect(xpointer).toEqual({ - xpointer: '/body/DocFragment[2]/body/div/p[2]', + xpointer: '/body/DocFragment[2]/body/div/p[3]', }); }); }); @@ -126,9 +126,9 @@ describe('CFIToXPointerConverter', () => { const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!); expect(originalCfi).toEqual(convertedCfi); - 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'); + expect(xpointer.xpointer).toEqual('/body/DocFragment[3]/body/div/p[1]/text().6'); + expect(xpointer.pos0).toEqual('/body/DocFragment[3]/body/div/p[1]/text().6'); + expect(xpointer.pos1).toEqual('/body/DocFragment[3]/body/div/p[2]/text().16'); }); it('should convert range CFI within same element', () => { @@ -147,8 +147,8 @@ describe('CFIToXPointerConverter', () => { const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!); expect(originalCfi).toEqual(convertedCfi); - expect(xpointer.pos0).toMatch(/p\[0\]/); - expect(xpointer.pos1).toMatch(/p\[2\]/); + expect(xpointer.pos0).toMatch(/p\[1\]/); + expect(xpointer.pos1).toMatch(/p\[3\]/); }); }); @@ -163,7 +163,7 @@ describe('CFIToXPointerConverter', () => { const convertedCfi = converter.xPointerToCFI(xpointer.xpointer); expect(originalCfi).toEqual(convertedCfi); - expect(xpointer.xpointer).toMatch(/\/body\/section\[0\]/); + expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]/); }); it('should handle elements with IDs', () => { @@ -172,7 +172,7 @@ describe('CFIToXPointerConverter', () => { const convertedCfi = converter.xPointerToCFI(xpointer.xpointer); expect(originalCfi).toEqual(convertedCfi); - expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[0\]/); + expect(xpointer.xpointer).toMatch(/\/body\/section\[2\]\/p\[1\]/); }); it('should handle inline elements', () => { @@ -181,7 +181,7 @@ describe('CFIToXPointerConverter', () => { const convertedCfi = converter.xPointerToCFI(xpointer.xpointer); expect(originalCfi).toEqual(convertedCfi); - expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[1\]/); + expect(xpointer.xpointer).toMatch(/\/body\/section\[2\]\/p\[2\]/); }); }); @@ -212,7 +212,7 @@ describe('CFIToXPointerConverter', () => { }); it('should convert XPointer to CFI for first element', () => { - const xpointer = '/body/DocFragment[2]/body/div/p[0]'; + const xpointer = '/body/DocFragment[2]/body/div/p[1]'; 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[2]/body/div/p[1]'; + const xpointer = '/body/DocFragment[2]/body/div/p[2]'; 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[2]/body/div[0]/p[0]/text().6'; + const xpointer = '/body/DocFragment[2]/body/div[0]/p[1]/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[2]/body/div/p[0]/text().6'; - const pos1 = '/body/DocFragment[2]/body/div/p[1]/text().16'; + const pos0 = '/body/DocFragment[2]/body/div/p[1]/text().6'; + const pos1 = '/body/DocFragment[2]/body/div/p[2]/text().16'; const cfi = converter.xPointerToCFI(pos0, pos1); const xpointer = converter.cfiToXPointer(cfi); @@ -269,7 +269,7 @@ describe('CFIToXPointerConverter', () => { 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[4]/body/div/p[0]'; + const xpointer = '/body/DocFragment[4]/body/div/p[1]'; 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[3]/body/div/p[0]'); + expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[1]'); }); 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[3]/body/div/p[1]'); + expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[2]'); }); it('should handle deeply nested elements', () => { @@ -379,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[3]/body/div/section/article/p[0]'); + expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/section/article/p[1]'); }); }); }); diff --git a/apps/readest-app/src/app/reader/hooks/useKOSync.ts b/apps/readest-app/src/app/reader/hooks/useKOSync.ts index b9ab71b2..0f9e484f 100644 --- a/apps/readest-app/src/app/reader/hooks/useKOSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useKOSync.ts @@ -96,12 +96,8 @@ export const useKOSync = (bookKey: string) => { if (!remote.progress?.startsWith('/body')) return; try { const content = view?.renderer.getContents()[0]; - const cfi = await getCFIFromXPointer( - remote.progress!, - content?.doc, - content?.index, - bookDoc, - ); + const koProgress = normalizeProgressXPointer(remote.progress); + const cfi = await getCFIFromXPointer(koProgress, content?.doc, content?.index, bookDoc); view?.goTo(cfi); } catch (error) { console.error('Failed to convert XPointer to CFI', error); @@ -242,6 +238,7 @@ export const useKOSync = (bookKey: string) => { const handlePushProgress = (event: CustomEvent) => { if (event.detail.bookKey !== bookKey) return; pushProgress(); + pushProgress.flush(); }; const handleFlush = (event: CustomEvent) => { if (event.detail.bookKey !== bookKey) return; diff --git a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts index 99388809..6690c619 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts @@ -116,8 +116,9 @@ export const useProgressSync = (bookKey: string) => { const view = getView(bookKey); if (xPointer && view && bookData && bookData.bookDoc) { const content = view.renderer.getContents()[0]; + const koProgress = normalizeProgressXPointer(xPointer); const candidateCFI = await getCFIFromXPointer( - xPointer, + koProgress, content?.doc, content?.index, bookData.bookDoc, diff --git a/apps/readest-app/src/utils/xcfi.ts b/apps/readest-app/src/utils/xcfi.ts index 68b5e04b..94c8a736 100644 --- a/apps/readest-app/src/utils/xcfi.ts +++ b/apps/readest-app/src/utils/xcfi.ts @@ -35,6 +35,8 @@ export class XCFI { // Convert to 0-based: (step - 2) / 2 = 0, 1, 2, 3, ... return Math.floor((spineStep - 2) / 2); } else if (cfiOrXPath.startsWith('/body/DocFragment[')) { + // Note that all indices in XPointer/XPath are 1-based + // but the text() offsets are 0-based const match = cfiOrXPath.match(/DocFragment\[(\d+)\]/); if (match) { return parseInt(match[1]!, 10) - 1; @@ -224,10 +226,10 @@ export class XCFI { let index: number; if (segmentWithIndexMatch) { - // Format: tag[index] + // Format: tag[index] (1-based index) const [, tag, indexStr] = segmentWithIndexMatch; tagName = tag!; - index = parseInt(indexStr!, 10); + index = Math.max(0, parseInt(indexStr!, 10) - 1); } else if (segmentWithoutIndexMatch) { // Format: tag (implicit index 0) const [, tag] = segmentWithoutIndexMatch; @@ -379,7 +381,7 @@ export class XCFI { if (totalSameTagSiblings === 1) { pathParts.unshift(tagName); } else { - pathParts.unshift(`${tagName}[${siblingIndex}]`); + pathParts.unshift(`${tagName}[${siblingIndex + 1}]`); // Convert to 1-based index for XPointer } current = parent; } @@ -545,7 +547,7 @@ export const getXPointerFromCFI = async ( // 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+$/; + const tailingTextRange = /\/text\(\).*$/; if (xpointer.match(tailingTextRange)) { xpointer = xpointer.replace(tailingTextRange, ''); }