mirror of
https://github.com/readest/readest
synced 2026-04-21 13:37:44 +00:00
fix(sync): correct xpointer spine index and omit certain tag index (#1781)
This commit is contained in:
parent
4298213ce4
commit
b8bb1ee71d
2 changed files with 77 additions and 39 deletions
|
|
@ -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]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue