fix(reader): preserve exact positions when syncing with koreader (#1694)

Convert complete source CFIs on the server to avoid chapter-start resets
and browser-generated locators that disagree with CREngine. Preserve
engine whitespace, Unicode offsets, element anchors and long text nodes.
Handle prefixed OPF namespaces and retry outdated annotation conversions.

Closes #1621
Closes #1288
This commit is contained in:
neon
2026-10-06 11:22:55 -06:00
committed by GitHub
parent 8fecae94e6
commit e253f7ffca
13 changed files with 524 additions and 249 deletions
@@ -102,6 +102,7 @@ describe('KoreaderService', () => {
};
let mockPositionConverter: {
xpointerPointToCfi: ReturnType<typeof vi.fn>;
cfiPointToXpointer: ReturnType<typeof vi.fn>;
};
let mockBookService: {
syncKoboReadingStateForExternalProgress: ReturnType<typeof vi.fn>;
@@ -185,6 +186,7 @@ describe('KoreaderService', () => {
mockPositionConverter = {
xpointerPointToCfi: vi.fn().mockResolvedValue({ status: 'failed', reason: 'chapter_unavailable' }),
cfiPointToXpointer: vi.fn().mockResolvedValue({ status: 'failed', reason: 'chapter_unavailable' }),
};
mockBookService = {
@@ -1038,27 +1040,27 @@ describe('KoreaderService', () => {
});
});
it('converts CFI to DocFragment XPointer using chapter service (no file I/O)', async () => {
it('converts the complete reader CFI rather than returning the chapter start', async () => {
const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue({
percentage: 50,
// /6/4 -> spinePos=4 -> floor(4/2)-1 = 1 -> chapterIndex=1 -> DocFragment[2]
cfi: 'epubcfi(/6/4!/4/2/2:10)',
cfi: 'epubcfi(/6/4!/4/2/1:10)',
updatedAt: readerTime,
});
mockChapterService.parseChapterIndexFromCfi.mockReturnValue(1);
mockPositionConverter.cfiPointToXpointer.mockResolvedValue({ status: 'exact', pos0: '/body/DocFragment[2]/body/p/text().10' });
await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual({
document: 'doc-hash',
percentage: 0.5,
progress: '/body/DocFragment[2]/body',
progress: '/body/DocFragment[2]/body/p/text().10',
device: 'web',
device_id: 'bookorbit-web',
timestamp: Math.floor(readerTime.getTime() / 1000),
});
expect(mockChapterService.parseChapterIndexFromCfi).toHaveBeenCalledWith('epubcfi(/6/4!/4/2/2:10)');
expect(mockPositionConverter.cfiPointToXpointer).toHaveBeenCalledWith({ bookFileId: 10, cfi: 'epubcfi(/6/4!/4/2/1:10)' });
expect(mockChapterService.parseChapterIndexFromCfi).not.toHaveBeenCalled();
});
it('returns exact web reader KOReader XPointer when it is stored', async () => {
@@ -1080,10 +1082,10 @@ describe('KoreaderService', () => {
device_id: 'bookorbit-web',
timestamp: Math.floor(readerTime.getTime() / 1000),
});
expect(mockChapterService.parseChapterIndexFromCfi).not.toHaveBeenCalled();
expect(mockPositionConverter.cfiPointToXpointer).not.toHaveBeenCalled();
});
it('returns null XPointer when chapter service cannot parse CFI spine index', async () => {
it('returns percentage fallback when the converter cannot resolve a CFI', async () => {
const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
@@ -1092,10 +1094,42 @@ describe('KoreaderService', () => {
cfi: 'some-unparseable-format',
updatedAt: readerTime,
});
mockChapterService.parseChapterIndexFromCfi.mockReturnValue(null);
mockPositionConverter.cfiPointToXpointer.mockResolvedValue({ status: 'failed', reason: 'unparsable_cfi' });
const result = await service.getProgress(7, 'doc-hash');
expect(result?.progress).toBeNull();
expect(result).toEqual(expect.objectContaining({ progress: null, percentage: 0.3, device: 'web' }));
});
it.each([
{ status: 'failed', reason: 'chapter_unavailable', pos0: '/body/DocFragment[1]/body' },
{ status: 'exact' },
{ status: 'exact', pos0: '' },
])('never serves a guessed or missing position: %j', async (outcome) => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, format: 'epub' });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue({ percentage: 10.53, cfi: 'epubcfi(/6/2!/4/82/1:50)', updatedAt: new Date() });
mockPositionConverter.cfiPointToXpointer.mockResolvedValue(outcome);
await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual(expect.objectContaining({ progress: null, percentage: 0.1053 }));
});
it('keeps percentage fallback available when chapter loading throws', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, format: 'epub' });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue({ percentage: 10.53, cfi: 'epubcfi(/6/2!/4/82/1:50)', updatedAt: new Date() });
mockPositionConverter.cfiPointToXpointer.mockRejectedValue(new Error('chapter unavailable'));
await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual(expect.objectContaining({ progress: null, percentage: 0.1053 }));
expect(Logger.prototype.warn).toHaveBeenCalled();
});
it.each(['pdf', 'cbz'])('does not send an EPUB pointer to a paged %s reader', async (format) => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, format });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue({ percentage: 25, cfi: 'epubcfi(/6/2!/4/2/1:5)', updatedAt: new Date() });
await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual(expect.objectContaining({ progress: null, percentage: 0.25 }));
expect(mockPositionConverter.cfiPointToXpointer).not.toHaveBeenCalled();
});
it('returns null when neither device nor web reader progress exists', async () => {
@@ -516,10 +516,16 @@ export class KoreaderService {
if (readingProg) {
let xpointer = readingProg.koreaderProgress ?? null;
if (!xpointer && readingProg.cfi) {
const chapterIndex = this.chapterService.parseChapterIndexFromCfi(readingProg.cfi);
if (chapterIndex !== null && chapterIndex >= 0) {
xpointer = `/body/DocFragment[${chapterIndex + 1}]/body`;
if (!xpointer && readingProg.cfi && !isPagedReadingFormat(bookFile.format)) {
const startedAt = Date.now();
try {
const outcome = await this.positionConverter.cfiPointToXpointer({ bookFileId: bookFile.id, cfi: readingProg.cfi });
// A chapter start would override the percentage fallback and discard the text offset.
if (outcome.status !== 'failed' && outcome.pos0) xpointer = outcome.pos0;
} catch (error) {
this.logger.warn(
`[${SYNC_EVENT}] [fail] userId=${userId} bookFileId=${bookFile.id} durationMs=${Date.now() - startedAt} errorClass=${error instanceof Error ? sanitizeLogValue(error.name) : 'unknown'} - position conversion failed; serving percentage fallback`,
);
}
}
@@ -1,12 +1,8 @@
/**
* Collapsed-text index over a chapter DOM, approximating how crengine builds its
* text nodes (whitespace runs collapse to single spaces, leading whitespace after
* block boundaries drops). Every collapsed CODE POINT maps back to its raw text
* node + UTF-16 offset, so positions can be translated between:
* - crengine xpointer offsets (code points in the collapsed view of a text run)
* - browser/CFI offsets (UTF-16 code units in the raw text nodes)
* Synthetic separators are inserted between blocks for search quality; they map to
* no raw position and are skipped when resolving range endpoints.
* Chapter text index with separate search and CREngine offset mappings. Search
* collapses Unicode whitespace and inserts block separators; engine offsets keep
* Unicode spaces, leading ASCII space, and source-parser text node boundaries.
* Both map Unicode code points back to raw DOM nodes and UTF-16 CFI offsets.
*/
import { CfiNode, getDocumentElement, isElementNode, isTextNode } from './cfi.utils';
@@ -61,6 +57,8 @@ export interface TextRun {
runCount: number;
collapsedStart: number;
collapsedLength: number;
engineRawOffsets: Int32Array;
engineRawEnd: number;
}
export interface RawPoint {
@@ -95,10 +93,14 @@ export class ChapterTextIndex {
private readonly runByPoint: Int32Array;
private readonly rawOffsetByPoint: Int32Array;
private readonly cpByUtf16: Int32Array;
private readonly engineRunsByRun = new Map<TextRun, TextRun[]>();
private readonly runsByParent = new Map<CfiNode, TextRun[]>();
private readonly runInfoByNode = new Map<CfiNode, { run: TextRun; rawStart: number }>();
constructor(root: CfiNode) {
constructor(
root: CfiNode,
private readonly engineBoundaries = new Map<CfiNode, number[]>(),
) {
const state: BuildState = {
collapsedPoints: [],
runByPoint: [],
@@ -131,8 +133,31 @@ export class ChapterTextIndex {
this.cpByUtf16[this.collapsed.length] = state.collapsedPoints.length;
for (const run of runs) {
if (!run.engineRawOffsets.length) continue;
const list = this.runsByParent.get(run.parent) ?? [];
list.push(run);
const splitOffsets = [0];
let partStart = 0;
let supported = true;
for (const part of run.parts) {
const boundaries = this.engineBoundaries.get(part.node);
if (boundaries?.length === 0) supported = false;
for (const offset of boundaries?.slice(1, -1) ?? []) splitOffsets.push(partStart + offset);
partStart += part.rawLength;
}
splitOffsets.push(partStart);
const engineRuns: TextRun[] = [];
if (supported) {
let nextOffset = 0;
for (let i = 0; i < splitOffsets.length - 1; i += 1) {
const firstOffset = nextOffset;
while (nextOffset < run.engineRawOffsets.length && run.engineRawOffsets[nextOffset] < splitOffsets[i + 1]) nextOffset += 1;
const offsets = run.engineRawOffsets.subarray(firstOffset, nextOffset);
if (!offsets.length) continue;
engineRuns.push(splitOffsets.length === 2 ? run : { ...run, engineRawOffsets: offsets, engineRawEnd: splitOffsets[i + 1] });
}
}
this.engineRunsByRun.set(run, engineRuns);
list.push(...engineRuns);
this.runsByParent.set(run.parent, list);
}
for (const [, list] of this.runsByParent) {
@@ -181,15 +206,28 @@ export class ChapterTextIndex {
runCount: 0,
collapsedStart: state.collapsedPoints.length,
collapsedLength: 0,
engineRawOffsets: new Int32Array(),
engineRawEnd: 0,
};
const runIdx = runs.length;
let emittedAny = false;
let rawConcatOffset = 0;
// Search drops block indentation; CREngine keeps one leading ASCII space and
// preserves Unicode spaces. Keep its offsets independent of the search index.
const engineRawOffsets: number[] = [];
let engineLastWasSpace = false;
const whitespaceOnly = run.parts.every((part) => /^[ \t\r\n]*$/.test(part.node.data ?? ''));
for (const part of run.parts) {
this.runInfoByNode.set(part.node, { run, rawStart: rawConcatOffset });
const raw = part.node.data ?? '';
const boundaries = new Set(this.engineBoundaries.get(part.node) ?? []);
const partStart = rawConcatOffset;
for (const cp of raw) {
if (boundaries.has(rawConcatOffset - partStart)) engineLastWasSpace = false;
const engineSpace = /^[ \t\r\n]$/.test(cp);
if (!whitespaceOnly && (!engineSpace || !engineLastWasSpace)) engineRawOffsets.push(rawConcatOffset);
engineLastWasSpace = engineSpace;
if (WHITESPACE_RE.test(cp)) {
if (!state.atBoundary && !state.lastWasSpace) {
state.collapsedPoints.push(' ');
@@ -223,6 +261,8 @@ export class ChapterTextIndex {
}
run.collapsedLength = emittedAny ? state.collapsedPoints.length - run.collapsedStart : 0;
run.engineRawOffsets = Int32Array.from(engineRawOffsets);
run.engineRawEnd = rawConcatOffset;
runs.push(run);
}
@@ -247,7 +287,50 @@ export class ChapterTextIndex {
}
collapsedForRunOffset(run: TextRun, cpOffset: number): number {
return run.collapsedStart + Math.max(0, Math.min(cpOffset, run.collapsedLength));
const point = this.rawPointForEngineOffset(run, cpOffset);
return point ? (this.collapsedFromNodePoint(point.node, point.offset) ?? run.collapsedStart) : run.collapsedStart;
}
enginePointOfRawPoint(node: CfiNode, offset: number): { run: TextRun; offset: number } | null {
const info = this.runInfoByNode.get(node);
if (!info) return null;
const target = info.rawStart + offset;
const runs = this.engineRunsByRun.get(info.run) ?? [];
const run = runs.find((candidate) => target < candidate.engineRawEnd) ?? runs.at(-1);
if (!run) return null;
let lo = 0;
let hi = run.engineRawOffsets.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (run.engineRawOffsets[mid] < target) lo = mid + 1;
else hi = mid;
}
return { run, offset: lo };
}
engineOffsetOfRawPoint(node: CfiNode, offset: number): number | null {
const info = this.runInfoByNode.get(node);
if (!info || !info.run.engineRawOffsets.length) return null;
const target = info.rawStart + offset;
const offsets = info.run.engineRawOffsets;
let lo = 0;
let hi = offsets.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (offsets[mid] < target) lo = mid + 1;
else hi = mid;
}
return lo;
}
rawPointForEngineOffset(run: TextRun, offset: number): RawPoint | null {
if (!run.engineRawOffsets.length || offset < 0 || offset > run.engineRawOffsets.length) return null;
let rawOffset = run.engineRawOffsets[offset] ?? run.engineRawEnd;
for (const part of run.parts) {
if (rawOffset < part.rawLength || part === run.parts[run.parts.length - 1]) return { node: part.node, offset: rawOffset };
rawOffset -= part.rawLength;
}
return null;
}
/** Inclusive start point: resolves the collapsed cp index to (raw text node, UTF-16 offset). */
@@ -319,7 +402,9 @@ export class ChapterTextIndex {
/** Run-relative crengine offset (code points) for a collapsed cp index. */
runOffsetOfCollapsed(cpIndex: number, run: TextRun): number {
return Math.max(0, Math.min(cpIndex, run.collapsedStart + run.collapsedLength) - run.collapsedStart);
const index = Math.max(run.collapsedStart, Math.min(cpIndex, run.collapsedStart + run.collapsedLength));
const point = index < run.collapsedStart + run.collapsedLength ? this.rawPointAt(index) : this.endPointFromCollapsed(index);
return point ? (this.engineOffsetOfRawPoint(point.node, point.offset) ?? 0) : 0;
}
runAtCollapsed(cpIndex: number, direction: 'forward' | 'backward' = 'forward'): TextRun | null {
@@ -6,6 +6,7 @@ import { ZipArchive } from 'archiver';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { EpubDomService, loadChapterFromZip, readEpubSpine } from './epub-dom.service';
import { PositionConverterService } from './position-converter.service';
const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
@@ -30,7 +31,7 @@ const CH2 = `<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>2</title></head>
<body><p>Second chapter text.</p></body></html>`;
async function buildEpub(path: string): Promise<void> {
async function buildEpub(path: string, containerXml = CONTAINER_XML, contentOpf = CONTENT_OPF): Promise<void> {
const archive = new ZipArchive({ zlib: { level: 0 } });
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
@@ -39,8 +40,8 @@ async function buildEpub(path: string): Promise<void> {
archive.on('error', reject);
});
archive.append('application/epub+zip', { name: 'mimetype', store: true });
archive.append(CONTAINER_XML, { name: 'META-INF/container.xml' });
archive.append(CONTENT_OPF, { name: 'OEBPS/content.opf' });
archive.append(containerXml, { name: 'META-INF/container.xml' });
archive.append(contentOpf, { name: 'OEBPS/content.opf' });
archive.append(CH1, { name: 'OEBPS/text/ch1.xhtml' });
archive.append(CH2, { name: 'OEBPS/text/ch2.xhtml' });
await archive.finalize();
@@ -83,6 +84,22 @@ describe('EpubDomService', () => {
expect(doc!.index.collapsed).toBe('Second chapter text.');
});
it('reads prefixed container and OPF tags and converts a position in the correct spine chapter', async () => {
const prefixedPath = join(dir, 'prefixed.epub');
const container = CONTAINER_XML.replace(/(<\/?)(container|rootfiles|rootfile)(?=[\s>])/g, '$1ocf:$2').replace('xmlns=', 'xmlns:ocf=');
const opf = CONTENT_OPF.replace(/(<\/?)(package|metadata|manifest|item|spine|itemref)(?=[\s/>])/g, '$1opf:$2').replace('xmlns=', 'xmlns:opf=');
await buildEpub(prefixedPath, container, opf);
const service = new EpubDomService(makeDb(prefixedPath) as never);
expect(await service.getSpineHrefs(1)).toEqual(['OEBPS/text/ch1.xhtml', 'OEBPS/text/ch2.xhtml']);
expect((await service.getChapter(1, 1))?.index.collapsed).toBe('Second chapter text.');
await expect(new PositionConverterService(service).cfiPointToXpointer({ bookFileId: 1, cfi: 'epubcfi(/6/4!/4/2/1:7)' })).resolves.toEqual({
status: 'exact',
pos0: '/body/DocFragment[2]/body/p/text().7',
chapterIndex: 1,
});
});
it('resolves chapters by spine index through the service and caches them', async () => {
const db = makeDb(epubPath);
const service = new EpubDomService(db as never);
@@ -19,6 +19,7 @@ const SPINE_CACHE_MAX = 24;
const xmlParser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
attributeNamePrefix: '@_',
textNodeName: '#text',
});
@@ -104,7 +104,7 @@ describe('parseChapterDocument', () => {
it('indexes named entities as the characters a renderer would show', () => {
const doc = parseChapterDocument(chapter('<p>a&nbsp;b pa&shy;sakyti c&mdash;d</p>'));
expect(doc.index.collapsed).toBe('a b pa­sakyti c—d');
expect(doc.index.collapsed).toBe('a b pa­sakyti c\u2014d');
});
it('keeps escaped entity text literal instead of resolving it twice', () => {
@@ -128,7 +128,7 @@ describe('xpointerRangeToCfi', () => {
it('maps collapsed crengine offsets back to raw offsets in indented source', () => {
const doc = parseChapterDocument(INDENTED_CHAPTER);
const result = xpointerRangeToCfi(doc, 0, '/body/DocFragment[1]/body/p/text().6', '/body/DocFragment[1]/body/p/text().11', 'world');
const result = xpointerRangeToCfi(doc, 0, '/body/DocFragment[1]/body/p/text().7', '/body/DocFragment[1]/body/p/text().12', 'world');
expect(result.status).toBe('exact');
expect((result as { pos0: string }).pos0).toBe('epubcfi(/6/2!/4/2,/1:13,/1:18)');
});
@@ -216,8 +216,8 @@ describe('cfiRangeToXPointer', () => {
const result = cfiRangeToXPointer(doc, 0, 'epubcfi(/6/2!/4/2,/1:13,/1:18)', 'world');
expect(result).toEqual({
status: 'exact',
pos0: '/body/DocFragment[1]/body/p/text().6',
pos1: '/body/DocFragment[1]/body/p/text().11',
pos0: '/body/DocFragment[1]/body/p/text().7',
pos1: '/body/DocFragment[1]/body/p/text().12',
});
});
@@ -285,8 +285,8 @@ describe('round trips', () => {
},
{
chapter: INDENTED_CHAPTER,
pos0: '/body/DocFragment[1]/body/p/text().0',
pos1: '/body/DocFragment[1]/body/p/text().5',
pos0: '/body/DocFragment[1]/body/p/text().1',
pos1: '/body/DocFragment[1]/body/p/text().6',
text: 'Hello',
},
{
@@ -20,9 +20,11 @@ import {
spineCfiForChapterIndex,
} from './cfi.utils';
import { decodeNamedEntities } from './xhtml-entities';
import { buildXPointer, parseXPointer, resolveXPointerElement } from './xpointer.utils';
import { buildXPointer, buildXPointerPath, parseXPointer, resolveXPointerElement } from './xpointer.utils';
export const CONVERTER_VERSION = 1;
export const CONVERTER_VERSION = 2;
const ENGINE_TEXT_SPLIT_SIZE = 8192;
/** KOReader sidecar text is truncated at 10000 chars; treat near-limit text as a prefix. */
const TRUNCATED_TEXT_THRESHOLD = 9990;
@@ -49,7 +51,42 @@ export type ConversionResult = ConversionSuccess | ConversionFailure;
export function parseChapterDocument(xhtml: string): ChapterDocument {
const $ = cheerio.load(decodeNamedEntities(xhtml), { xml: true });
const root = $.root()[0] as unknown as CfiNode;
return { root, index: new ChapterTextIndex(root) };
if (xhtml.length < ENGINE_TEXT_SPLIT_SIZE) return { root, index: new ChapterTextIndex(root) };
// CREngine splits source before entity decoding; decoded text lengths cannot determine its node indexes.
const rawRoot = cheerio.load(xhtml, { xml: { decodeEntities: false } }).root()[0] as unknown as CfiNode;
const boundaries = new Map<CfiNode, number[]>();
const visit = (node: CfiNode, raw: CfiNode) => {
if (isTextNode(node) && Array.from(raw.data ?? '').length >= ENGINE_TEXT_SPLIT_SIZE) {
const chars = Array.from(raw.data ?? '');
const offsets = [0];
let consumed = 0;
let decoded = '';
while (consumed < chars.length) {
let end = Math.min(consumed + ENGINE_TEXT_SPLIT_SIZE, chars.length);
if (end - consumed === ENGINE_TEXT_SPLIT_SIZE) {
for (let i = end - 1; i >= consumed; i -= 1) {
if (chars[i] === ' ' || chars[i] === '\n' || chars[i] === '\r') {
end = i + 1;
break;
}
}
}
const chunk = chars.slice(consumed, end).join('');
decoded += cheerio
.load(`<p>${decodeNamedEntities(chunk)}</p>`, { xml: true })('p')
.text();
offsets.push(decoded.length);
consumed = end;
}
boundaries.set(node, decoded === node.data ? offsets : []);
}
for (let i = 0; i < (node.children?.length ?? 0); i += 1) {
const rawChild = raw.children?.[i];
if (rawChild) visit(node.children![i], rawChild);
}
};
visit(root, rawRoot);
return { root, index: new ChapterTextIndex(root, boundaries) };
}
interface StructuralAnchor {
@@ -70,7 +107,7 @@ function xpointerAnchor(doc: ChapterDocument, pos: string, exclusiveEnd: boolean
if (parsed.offset == null) {
return { cp: exclusiveEnd ? run.collapsedStart + run.collapsedLength : run.collapsedStart, resolved: false };
}
const withinRange = parsed.offset <= run.collapsedLength;
const withinRange = parsed.offset <= run.engineRawOffsets.length;
return { cp: doc.index.collapsedForRunOffset(run, parsed.offset), resolved: withinRange };
}
@@ -159,7 +196,36 @@ export function collapsedPointToCfi(doc: ChapterDocument, chapterIndex: number,
export function collapsedPointToXPointer(doc: ChapterDocument, chapterIndex: number, cp: number): string | null {
const run = doc.index.runAtCollapsed(cp, 'forward') ?? doc.index.runAtCollapsed(cp, 'backward');
if (!run) return null;
return buildXPointer(run.parent, chapterIndex + 1, run.runIndex, run.runCount, doc.index.runOffsetOfCollapsed(cp, run));
const point = doc.index.startPointFromCollapsed(cp);
const engine = point ? doc.index.enginePointOfRawPoint(point.node, point.offset) : null;
return engine ? buildXPointer(engine.run.parent, chapterIndex + 1, engine.run.runIndex, engine.run.runCount, engine.offset) : null;
}
export function cfiPointToXPointer(doc: ChapterDocument, chapterIndex: number, cfi: string): string | null {
const parsed = parseCfi(cfi);
const point = parsed ? resolveCfiParts(doc.root, parsed.start) : null;
if (!point?.node) return null;
if (!isTextNode(point.node)) return buildXPointerPath(point.node, chapterIndex + 1);
const engine = doc.index.enginePointOfRawPoint(point.node, point.offset ?? 0);
return engine ? buildXPointer(engine.run.parent, chapterIndex + 1, engine.run.runIndex, engine.run.runCount, engine.offset) : null;
}
export function xpointerPointToCfi(doc: ChapterDocument, chapterIndex: number, pos: string): string | null {
const parsed = parseXPointer(pos);
if (!parsed || parsed.docFragmentIndex !== chapterIndex + 1) return null;
const element = resolveXPointerElement(doc.root, parsed.steps);
if (!element) return null;
if (!/\/text\(\)/.test(pos)) {
if (parsed.offset != null && parsed.offset !== 0) return null;
return joinCfiIndirection(spineCfiForChapterIndex(chapterIndex), cfiFromPoint({ node: element, offset: 0 }));
}
if (parsed.textIndex == null || parsed.offset == null) {
const cp = xpointerPointToCollapsed(doc, pos);
return cp == null ? null : collapsedPointToCfi(doc, chapterIndex, cp);
}
const run = doc.index.runsOfParent(element)[parsed.textIndex - 1];
const point = run ? doc.index.rawPointForEngineOffset(run, parsed.offset) : null;
return point ? joinCfiIndirection(spineCfiForChapterIndex(chapterIndex), cfiFromPoint(point)) : null;
}
/** Resolves a point xpointer to its collapsed cp index (null when structure is unresolvable). */
@@ -235,15 +301,14 @@ export function collapsedRangeToXPointerPair(
const endRun = doc.index.runAtCollapsed(endCp - 1, 'backward');
if (!startRun || !endRun) return null;
const start = doc.index.startPointFromCollapsed(startCp);
const end = doc.index.endPointFromCollapsed(endCp);
const engineStart = start ? doc.index.enginePointOfRawPoint(start.node, start.offset) : null;
const engineEnd = end ? doc.index.enginePointOfRawPoint(end.node, end.offset) : null;
if (!engineStart || !engineEnd) return null;
const docFragmentIndex = chapterIndex + 1;
const pos0 = buildXPointer(
startRun.parent,
docFragmentIndex,
startRun.runIndex,
startRun.runCount,
doc.index.runOffsetOfCollapsed(startCp, startRun),
);
const pos1 = buildXPointer(endRun.parent, docFragmentIndex, endRun.runIndex, endRun.runCount, doc.index.runOffsetOfCollapsed(endCp, endRun));
const pos0 = buildXPointer(engineStart.run.parent, docFragmentIndex, engineStart.run.runIndex, engineStart.run.runCount, engineStart.offset);
const pos1 = buildXPointer(engineEnd.run.parent, docFragmentIndex, engineEnd.run.runIndex, engineEnd.run.runCount, engineEnd.offset);
if (!pos0 || !pos1) return null;
return { pos0, pos1 };
}
@@ -98,3 +98,133 @@ describe('PositionConverterService cross-edition read-aloud positions', () => {
).resolves.toEqual({ status: 'failed', reason: 'chapter_text_mismatch', chapterIndex: 0 });
});
});
describe('PositionConverterService reading progress points', () => {
const chapter = (body: string) => parseChapterDocument(`<html><head><title>Position</title></head><body>${body}</body></html>`);
it.each([
{
body: '<p>First paragraph.</p><p>Second paragraph.</p>',
cfi: 'epubcfi(/6/2!/4/4/1:7)',
pos: '/body/DocFragment[1]/body/p[2]/text().7',
index: 0,
},
{
body: '<p>First paragraph.</p><p>Second paragraph.</p>',
cfi: 'epubcfi(/6/8!/4/4/1:7)',
pos: '/body/DocFragment[4]/body/p[2]/text().7',
index: 3,
},
{ body: '<p>First paragraph.</p>', cfi: 'epubcfi(/6/2!/4/2/1:0)', pos: '/body/DocFragment[1]/body/p/text().0', index: 0 },
{ body: '<p>one <em>two</em> three</p>', cfi: 'epubcfi(/6/2!/4/2/2/1:1)', pos: '/body/DocFragment[1]/body/p/em/text().1', index: 0 },
{ body: '<p>one <em>two</em> three</p>', cfi: 'epubcfi(/6/2!/4/2/3:2)', pos: '/body/DocFragment[1]/body/p/text()[2].2', index: 0 },
{ body: '<p>ab\u{1F600}cd efgh</p>', cfi: 'epubcfi(/6/2!/4/2/1:4)', pos: '/body/DocFragment[1]/body/p/text().3', index: 0 },
{ body: '<p>\n Hello world\n </p>', cfi: 'epubcfi(/6/2!/4/2/1:13)', pos: '/body/DocFragment[1]/body/p/text().7', index: 0 },
{ body: '<p> Leading text.</p>', cfi: 'epubcfi(/6/2!/4/2/1:5)', pos: '/body/DocFragment[1]/body/p/text().5', index: 0 },
{ body: '<p>\n Leading text.</p>', cfi: 'epubcfi(/6/2!/4/2/1:9)', pos: '/body/DocFragment[1]/body/p/text().5', index: 0 },
{ body: '<p>a\u00a0\u00a0bc</p>', cfi: 'epubcfi(/6/2!/4/2/1:3)', pos: '/body/DocFragment[1]/body/p/text().3', index: 0 },
{ body: '<p>a\u2003\u2003bc</p>', cfi: 'epubcfi(/6/2!/4/2/1:3)', pos: '/body/DocFragment[1]/body/p/text().3', index: 0 },
{ body: '<p>ab\u{1F600}</p>', cfi: 'epubcfi(/6/2!/4/2/1:4)', pos: '/body/DocFragment[1]/body/p/text().3', index: 0 },
{ body: '<div><img src="page.png"/></div>', cfi: 'epubcfi(/6/2!/4/2/2)', pos: '/body/DocFragment[1]/body/div/img', index: 0 },
{ body: '<p>Text</p>', cfi: 'epubcfi(/6/2!/4/2)', pos: '/body/DocFragment[1]/body/p', index: 0 },
{
body: '<div>\n<p>First</p>\n<p>Second</p>\nThird<br/> fourth</div>',
cfi: 'epubcfi(/6/2!/4/2/7:3)',
pos: '/body/DocFragment[1]/body/div/text()[2].3',
index: 0,
},
])('preserves the text point in $cfi and round trips it', async ({ body, cfi, pos, index }) => {
const { service, epubDom } = makeService({ 10: chapter(body) });
await expect(service.cfiPointToXpointer({ bookFileId: 10, cfi })).resolves.toEqual({ status: 'exact', pos0: pos, chapterIndex: index });
expect(epubDom.getChapter).toHaveBeenCalledWith(10, index);
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos })).resolves.toEqual({ status: 'exact', cfi, chapterIndex: index });
});
it('converts the explicit singleton indexes emitted after CREngine page turns', async () => {
const { service } = makeService({ 10: chapter('<div><p>First paragraph.</p><p>Second paragraph.</p></div>') });
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body[1]/DocFragment[1]/body[1]/div[1]/p[2]/text()[1].7' })).resolves.toEqual({
status: 'exact',
cfi: 'epubcfi(/6/2!/4/2/4/1:7)',
chapterIndex: 0,
});
});
it('rejects an invalid root body index', async () => {
const { service } = makeService({ 10: chapter('<p>Text</p>') });
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body[2]/DocFragment[1]/body/p/text().1' })).resolves.toMatchObject({
status: 'failed',
});
});
it.each([
{ source: 'word '.repeat(2000), rawOffset: 9000, engineOffset: 810 },
{ source: 'word&amp; '.repeat(1100), rawOffset: 6300, engineOffset: 1386 },
{ source: 'a'.repeat(8200), rawOffset: 8195, engineOffset: 3 },
{ source: 'word '.repeat(4000), rawOffset: 17000, engineOffset: 620 },
])('maps source-parser text chunks in long paragraphs at $rawOffset', async ({ source, rawOffset, engineOffset }) => {
const { service } = makeService({ 10: chapter(`<p>${source}</p>`) });
const converted = await service.cfiPointToXpointer({ bookFileId: 10, cfi: `epubcfi(/6/2!/4/2/1:${rawOffset})` });
const textIndex = rawOffset === 17000 ? 3 : 2;
expect(converted).toEqual({ status: 'exact', pos0: `/body/DocFragment[1]/body/p/text()[${textIndex}].${engineOffset}`, chapterIndex: 0 });
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: converted.status === 'failed' ? '' : converted.pos0! })).resolves.toEqual({
status: 'exact',
cfi: `epubcfi(/6/2!/4/2/1:${rawOffset})`,
chapterIndex: 0,
});
});
it('resolves paragraph 40 in the issue 1621 reproduction instead of the chapter start', async () => {
const body = '<h1>Chapter One</h1>' + Array.from({ length: 100 }, (_, i) => `<p>${`Paragraph ${i + 1}. `.repeat(20)}</p>`).join('');
const { service } = makeService({ 10: chapter(body) });
await expect(service.cfiPointToXpointer({ bookFileId: 10, cfi: 'epubcfi(/6/2!/4/82/1:50)' })).resolves.toEqual({
status: 'exact',
pos0: '/body/DocFragment[1]/body/p[40]/text().50',
chapterIndex: 0,
});
});
it('rejects a text-node index removed by CREngine instead of choosing a different node', async () => {
const { service } = makeService({ 10: chapter('<div>\n<p>First</p>\nThird</div>') });
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body/DocFragment[1]/body/div/text()[2].1' })).resolves.toMatchObject({
status: 'failed',
});
});
it('rejects an offset beyond the engine text node', async () => {
const { service } = makeService({ 10: chapter('<p>Text</p>') });
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body/DocFragment[1]/body/p/text().5' })).resolves.toMatchObject({
status: 'failed',
});
});
it('converts the zero-offset element pointer CREngine emits for an image', async () => {
const { service } = makeService({ 10: chapter('<div><img src="page.png"/></div>') });
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body/DocFragment[1]/body/div/img.0' })).resolves.toEqual({
status: 'exact',
cfi: 'epubcfi(/6/2!/4/2/2)',
chapterIndex: 0,
});
});
it.each([
{ cfi: 'tts:0:40', reason: 'missing_spine_step' },
{ cfi: 'epubcfi(/4/2/1:3)', reason: 'missing_spine_step' },
{ cfi: 'epubcfi(/6/2!/4/999/1:3)', reason: 'unresolvable_structure', chapterIndex: 0 },
])('fails explicitly for $cfi', async ({ cfi, reason, chapterIndex }) => {
const { service } = makeService({ 10: chapter('<p>Text</p>') });
await expect(service.cfiPointToXpointer({ bookFileId: 10, cfi })).resolves.toEqual({
status: 'failed',
reason,
...(chapterIndex === undefined ? {} : { chapterIndex }),
});
});
it('does not manufacture a position when the requested chapter is unavailable', async () => {
const { service } = makeService({});
await expect(service.cfiPointToXpointer({ bookFileId: 10, cfi: 'epubcfi(/6/8!/4/2/1:3)' })).resolves.toEqual({
status: 'failed',
reason: 'chapter_unavailable',
chapterIndex: 3,
});
});
});
@@ -8,10 +8,12 @@ import {
ConversionResult,
CONVERTER_VERSION,
cfiPointToCollapsedCp,
cfiPointToXPointer,
cfiRangeToXPointer,
collapsedPointToCfi,
collapsedPointToXPointer,
xpointerPointToCollapsed,
xpointerPointToCfi,
xpointerRangeToCfi,
} from './position-converter.core';
import { parseXPointer } from './xpointer.utils';
@@ -89,10 +91,8 @@ export class PositionConverterService {
const doc = await this.epubDom.getChapter(params.bookFileId, chapterIndex);
if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex };
const cp = xpointerPointToCollapsed(doc, params.pos);
if (cp == null) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
const cfi = collapsedPointToCfi(doc, chapterIndex, cp);
if (!cfi) return { status: 'failed', reason: 'cfi_generation_failed', chapterIndex };
const cfi = xpointerPointToCfi(doc, chapterIndex, params.pos);
if (!cfi) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
return { status: 'exact', cfi, chapterIndex };
}
@@ -109,10 +109,8 @@ export class PositionConverterService {
const doc = await this.epubDom.getChapter(params.bookFileId, chapterIndex);
if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex };
const cp = cfiPointToCollapsedCp(doc, params.cfi);
if (cp == null) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
const pos = collapsedPointToXPointer(doc, chapterIndex, cp);
if (!pos) return { status: 'failed', reason: 'xpointer_generation_failed', chapterIndex };
const pos = cfiPointToXPointer(doc, chapterIndex, params.cfi);
if (!pos) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
return { status: 'exact', pos0: pos, chapterIndex };
}
@@ -21,7 +21,7 @@ export interface ParsedXPointer {
offset: number | null;
}
const XPOINTER_RE = /^\/body\/DocFragment(?:\[(\d+)\])?(.*)$/;
const XPOINTER_RE = /^\/body(?:\[1\])?\/DocFragment(?:\[(\d+)\])?(.*)$/;
const STEP_RE = /^([A-Za-z][\w:-]*)(?:\[(\d+)\])?$/;
const TEXT_STEP_RE = /^text\(\)(?:\[(\d+)\])?$/;
@@ -204,6 +204,59 @@ describe('KOReader progress position routing (e2e)', { timeout: 180_000 }, () =>
expect(stored?.percentage).toBeCloseTo(80, 5);
});
it.each(['web', 'narration'])('restores the complete %s reader position and retains its text offset on device upload', async (source) => {
await ctx.db
.update(schema.koreaderDeviceProgress)
.set({ updatedAt: new Date('2020-01-01T00:00:00.000Z') })
.where(eq(schema.koreaderDeviceProgress.bookFileId, epub.bookFileId));
const cfi = 'epubcfi(/6/2!/4/2/1:3)';
// Narration only advances a position; begin behind the position being restored.
await saveFromWebReader(epub.bookFileId, { percentage: 10, cfi: 'epubcfi(/6/2!/4/2/1:0)' });
await saveFromWebReader(epub.bookFileId, { percentage: 55, cfi, ...(source === 'narration' ? { source } : {}) });
const pull = await ctx.app.inject({
method: 'GET',
url: `/api/v1/koreader/syncs/progress/${epubHash}`,
headers: deviceHeaders(),
});
expect(pull.statusCode).toBe(200);
const remote = pull.json() as { percentage: number; progress: string; device: string };
expect(remote).toEqual(expect.objectContaining({ percentage: 0.55, progress: '/body/DocFragment[1]/body/p/text().3', device: 'web' }));
// Readers paginate differently, but the text point must survive the lifecycle upload.
await syncFromDevice(epubHash, 0.52, remote.progress);
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ cfi, percentage: 52 }));
expect((await storedProgress(epub.bookFileId))?.koreaderProgress).toBe(remote.progress);
});
it('preserves positions through repeated web and native-engine handoffs', async () => {
for (let offset = 1; offset <= 4; offset += 1) {
const cfi = `epubcfi(/6/2!/4/2/1:${offset})`;
await saveFromWebReader(epub.bookFileId, { percentage: 20 + offset, cfi });
const pull = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${epubHash}`, headers: deviceHeaders() });
expect(pull.statusCode).toBe(200);
expect(pull.json().progress).toBe(`/body/DocFragment[1]/body/p/text().${offset}`);
const nextOffset = offset + 1;
await syncFromDevice(epubHash, (25 + offset) / 100, `/body[1]/DocFragment[1]/body[1]/p[1]/text()[1].${nextOffset}`);
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ cfi: `epubcfi(/6/2!/4/2/1:${nextOffset})` }));
}
});
it('serves percentage fallback for an unresolvable reader position', async () => {
await ctx.db
.update(schema.koreaderDeviceProgress)
.set({ updatedAt: new Date('2020-01-01T00:00:00.000Z') })
.where(eq(schema.koreaderDeviceProgress.bookFileId, epub.bookFileId));
await saveFromWebReader(epub.bookFileId, { percentage: 30, cfi: 'epubcfi(/6/2!/4/999/1:3)' });
const pull = await ctx.app.inject({
method: 'GET',
url: `/api/v1/koreader/syncs/progress/${epubHash}`,
headers: deviceHeaders(),
});
expect(pull.statusCode).toBe(200);
expect(pull.json()).toEqual(expect.objectContaining({ progress: null, percentage: 0.3, device: 'web' }));
});
it('never turns KOSync progress pushes into reading sessions', async () => {
await syncFromDevice(epubHash, 0.4, XPOINTER);
await ctx.db