fix(sync): correct xpointer spine index and omit certain tag index (#1781)

This commit is contained in:
Huang Xin 2025-08-11 21:25:16 +08:00 committed by GitHub
parent 4298213ce4
commit b8bb1ee71d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 77 additions and 39 deletions

View file

@ -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', () => {
<div>
<section>
<article>
<p>Deeply nested</p>
<p>Deeply nested p0</p>
<p>Deeply nested p1</p>
</article>
</section>
</div>
@ -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]');
});
});
});

View file

@ -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';