fix(kosync): use 1-based indices in xpointer, closes #1968 (#1973)

This commit is contained in:
Huang Xin
2025-09-06 13:40:32 +08:00
committed by GitHub
parent d0264de3eb
commit dfe68988aa
4 changed files with 31 additions and 31 deletions
@@ -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]');
});
});
});
@@ -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;
@@ -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,
+6 -4
View File
@@ -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, '');
}