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
+8 -153
View File
@@ -39,158 +39,13 @@ export const getKoboSpanValue = (range) => {
return null return null
} }
const getXPathStep = (node) => { export const getReadingProgressRange = (range) => {
if (!node) return null
if (node.nodeType === 3) return 'text()'
if (node.nodeType === 1 && node.localName) return node.localName
return null
}
const getXPathIndex = (node) => {
const step = getXPathStep(node)
if (!step) return 1
const siblings = getEffectiveChildren(getEffectiveParent(node)).filter((item) => item.type === 'element' && getXPathStep(item.node) === step)
const index = siblings.findIndex((item) => item.node === node)
return index >= 0 ? index + 1 : 1
}
const getXPathStepCount = (node) => {
const step = getXPathStep(node)
if (!step) return 1
return getEffectiveChildren(getEffectiveParent(node)).filter((item) => item.type === 'element' && getXPathStep(item.node) === step).length || 1
}
const getXPathSegment = (node) => {
const step = getXPathStep(node)
if (!step) return null
return getXPathStepCount(node) > 1 ? `${step}[${getXPathIndex(node)}]` : step
}
const isFoliateLayoutWrapper = (node) => node?.nodeType === 1 && (node.id === 'book-columns' || node.id === 'book-inner')
const isKoboSpanWrapper = (node) => node?.nodeType === 1 && node.classList?.contains('koboSpan') && /^kobo\./.test(node.id ?? '')
const isTransparentXPathWrapper = (node) => isFoliateLayoutWrapper(node) || isKoboSpanWrapper(node)
const getEffectiveParent = (node) => {
let parent = node?.parentNode ?? null
while (isTransparentXPathWrapper(parent)) parent = parent.parentNode
return parent
}
const appendEffectiveChild = (items, node) => {
if (!node) return
if (isTransparentXPathWrapper(node)) {
for (const child of node.childNodes) appendEffectiveChild(items, child)
return
}
if (node.nodeType === 3) {
const last = items.at(-1)
if (last?.type === 'text') {
last.nodes.push(node)
return
}
items.push({ type: 'text', nodes: [node] })
return
}
if (node.nodeType === 1) items.push({ type: 'element', node })
}
const getEffectiveChildren = (parent) => {
const items = []
for (const child of parent?.childNodes ?? []) appendEffectiveChild(items, child)
return items
}
const getBodyXPath = (node) => {
if (!node) return null
const doc = node.ownerDocument ?? (node.nodeType === 9 ? node : null)
const body = doc?.body
if (!body) return null
const parts = []
let current = node
while (current) {
if (current === body) {
parts.unshift('body')
return `/${parts.join('/')}`
}
if (!isTransparentXPathWrapper(current)) {
const segment = getXPathSegment(current)
if (!segment) return null
parts.unshift(segment)
}
current = getEffectiveParent(current)
}
return null
}
const getKoreaderTextTarget = (point) => {
if (point?.node?.nodeType !== 3) return null
const parent = getEffectiveParent(point.node)
if (!parent) return null
const textItems = getEffectiveChildren(parent).filter((item) => item.type === 'text')
for (const [itemIndex, item] of textItems.entries()) {
let offset = 0
for (const node of item.nodes) {
if (node === point.node) {
return {
parent,
index: itemIndex + 1,
count: textItems.length,
offset: offset + (Number.isFinite(point.offset) ? point.offset : 0),
}
}
offset += node.nodeValue?.length ?? 0
}
}
return null
}
const getSectionPathBasename = (section) => {
if (typeof section?.id !== 'string') return null
const path = section.id.split(/[?#]/)[0]
return path.split('/').pop() ?? null
}
const isKepubifySyntheticSection = (section) => getSectionPathBasename(section) === 'kepubify-titlepage-dummy.xhtml'
export const getKoreaderDocFragmentIndex = (sections, index) => {
if (!Array.isArray(sections) || typeof index !== 'number') return null
const section = sections[index]
if (!section || isKepubifySyntheticSection(section)) return null
let docFragmentIndex = 0
for (let i = 0; i <= index && i < sections.length; i += 1) {
if (!isKepubifySyntheticSection(sections[i])) docFragmentIndex += 1
}
return docFragmentIndex || null
}
export const getKoreaderProgress = (docFragmentIndex, range) => {
if (!range || typeof docFragmentIndex !== 'number') return null
const point = getRangePoint(range) const point = getRangePoint(range)
if (!point) return null if (!point) return null
const progressRange = range.cloneRange()
const target = getKoreaderTextTarget(point) progressRange.setStart(point.node, point.offset)
if (!target) return null progressRange.collapse(true)
return progressRange
const xpath = getBodyXPath(target.parent)
if (!xpath) return null
const textStep = target.count > 1 ? `text()[${target.index}]` : 'text()'
return `/body/DocFragment[${docFragmentIndex}]${xpath}/${textStep}.${target.offset}`
} }
const isZip = async (file) => { const isZip = async (file) => {
@@ -583,13 +438,13 @@ export class View extends HTMLElement {
const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {} const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {}
const tocItem = this.#tocProgress?.getProgress(index, range) const tocItem = this.#tocProgress?.getProgress(index, range)
const pageItem = this.#pageProgress?.getProgress(index, range) const pageItem = this.#pageProgress?.getProgress(index, range)
const cfi = this.getCFI(index, range) const cfi = this.getCFI(index, getReadingProgressRange(range))
const source = this.book.sections[index]?.id ?? null const source = this.book.sections[index]?.id ?? null
const contentSourceProgressPercent = toPercent(fraction) const contentSourceProgressPercent = toPercent(fraction)
const koboLocationValue = getKoboSpanValue(range) const koboLocationValue = getKoboSpanValue(range)
const koboLocationType = koboLocationValue ? 'KoboSpan' : null const koboLocationType = koboLocationValue ? 'KoboSpan' : null
const koreaderDocFragmentIndex = getKoreaderDocFragmentIndex(this.book.sections, index) // Convert the source CFI on the server, where the original XHTML is available.
const koreaderProgress = getKoreaderProgress(koreaderDocFragmentIndex, range) const koreaderProgress = null
this.lastLocation = { this.lastLocation = {
...progress, ...progress,
tocItem, tocItem,
@@ -33,8 +33,11 @@ function installBrowserGlobals() {
describe('Foliate navigation', () => { describe('Foliate navigation', () => {
let View: new () => { let View: new () => {
open: (book: { sections: { id: string }[] }) => Promise<void>
addEventListener: HTMLElement['addEventListener']
book: { sections?: unknown[]; resolveHref?: (href: string) => { index: number }; dir?: string | null } book: { sections?: unknown[]; resolveHref?: (href: string) => { index: number }; dir?: string | null }
renderer: { renderer: {
dispatchEvent?: HTMLElement['dispatchEvent']
goTo?: (resolved: { index: number }) => Promise<void> goTo?: (resolved: { index: number }) => Promise<void>
getContents?: () => { index: number; text?: string }[] getContents?: () => { index: number; text?: string }[]
prev?: () => void prev?: () => void
@@ -45,8 +48,7 @@ describe('Foliate navigation', () => {
goLeft: () => Promise<void> goLeft: () => Promise<void>
goRight: () => Promise<void> goRight: () => Promise<void>
} }
let getKoreaderProgress: (index: number, range: Range | null) => string | null let getReadingProgressRange: (range: Range | null) => Range | null
let getKoreaderDocFragmentIndex: (sections: { id: string }[], index: number) => number | null
let getKoboSpanValue: (range: Range | null) => string | null let getKoboSpanValue: (range: Range | null) => string | null
let getPageProgressionRtl: (bookDir: string | null | undefined, contentRtl: boolean) => boolean let getPageProgressionRtl: (bookDir: string | null | undefined, contentRtl: boolean) => boolean
let usesNegativePageScroll: (vertical: boolean, pageProgressionRtl: boolean) => boolean let usesNegativePageScroll: (vertical: boolean, pageProgressionRtl: boolean) => boolean
@@ -69,10 +71,9 @@ describe('Foliate navigation', () => {
const viewModulePath = '../../../../../public/assets/foliate/view.js' const viewModulePath = '../../../../../public/assets/foliate/view.js'
const fixedLayoutModulePath = '../../../../../public/assets/foliate/fixed-layout.js' const fixedLayoutModulePath = '../../../../../public/assets/foliate/fixed-layout.js'
const paginatorModulePath = '../../../../../public/assets/foliate/paginator.js' const paginatorModulePath = '../../../../../public/assets/foliate/paginator.js'
;({ View, getKoreaderProgress, getKoreaderDocFragmentIndex, getKoboSpanValue } = (await import(viewModulePath)) as { ;({ View, getReadingProgressRange, getKoboSpanValue } = (await import(viewModulePath)) as {
View: typeof View View: typeof View
getKoreaderProgress: typeof getKoreaderProgress getReadingProgressRange: typeof getReadingProgressRange
getKoreaderDocFragmentIndex: typeof getKoreaderDocFragmentIndex
getKoboSpanValue: typeof getKoboSpanValue getKoboSpanValue: typeof getKoboSpanValue
}) })
;({ FixedLayout } = (await import(fixedLayoutModulePath)) as { FixedLayout: typeof FixedLayout }) ;({ FixedLayout } = (await import(fixedLayoutModulePath)) as { FixedLayout: typeof FixedLayout })
@@ -335,7 +336,35 @@ describe('Foliate navigation', () => {
expect(prev).toHaveBeenCalledTimes(1) expect(prev).toHaveBeenCalledTimes(1)
}) })
it('preserves real inline elements for KOReader XPointer progress', () => { it('emits a source point CFI and clears browser-generated KOReader progress on relocation', async () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML = '<div id="book-columns"><div id="book-inner"><div id="anchor"></div><p>ab\u{1F600}cd</p></div></div>'
const range = doc.createRange()
range.setStart(doc.querySelector('#anchor')!, 0)
range.setEnd(doc.querySelector('p')!.firstChild!, 4)
const originalCreateElement = document.createElement.bind(document)
const renderer = Object.assign(originalCreateElement('div'), { open: vi.fn<() => void>() })
const createElement = vi
.spyOn(document, 'createElement')
.mockImplementation((name: string) => (name === 'foliate-paginator' ? renderer : originalCreateElement(name)))
try {
const view = new View()
await view.open({ sections: [{ id: 'chapter.xhtml' }] })
const relocate = vi.fn<(event: Event) => void>()
view.addEventListener('relocate', relocate)
renderer.dispatchEvent(new CustomEvent('relocate', { detail: { index: 0, range, fraction: 0.5 } }))
const detail = (relocate.mock.calls[0]![0] as CustomEvent).detail
expect(detail.cfi).toBe('epubcfi(/6/2!/4/2[book-columns]/2[book-inner]/4/1:4)')
expect(detail.koreaderProgress).toBeNull()
expect(detail.range).toBe(range)
expect(range.startContainer).toBe(doc.querySelector('#anchor'))
expect(range.collapsed).toBe(false)
} finally {
createElement.mockRestore()
}
})
it('preserves the source text point inside inline elements', () => {
const doc = document.implementation.createHTMLDocument('chapter') const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML = '<p>First paragraph</p><p><span>Middle text</span> tail</p>' doc.body.innerHTML = '<p>First paragraph</p><p><span>Middle text</span> tail</p>'
@@ -346,10 +375,11 @@ describe('Foliate navigation', () => {
range.setStart(text, 2) range.setStart(text, 2)
range.setEnd(text, 8) range.setEnd(text, 8)
expect(getKoreaderProgress(8, range)).toBe('/body/DocFragment[8]/body/p[2]/span/text().2') expect(getReadingProgressRange(range)?.startContainer).toBe(text)
expect(getReadingProgressRange(range)?.startOffset).toBe(2)
}) })
it('uses the text endpoint and strips Foliate layout wrappers when the range starts on a container', () => { it('uses the text endpoint and retains source layout wrappers when the range starts on a container', () => {
const doc = document.implementation.createHTMLDocument('chapter') const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML = doc.body.innerHTML =
'<div id="book-columns"><div id="book-inner"><div id="filepos48449"></div><p><span id="kobo.10.6">Middle text</span></p></div></div>' '<div id="book-columns"><div id="book-inner"><div id="filepos48449"></div><p><span id="kobo.10.6">Middle text</span></p></div></div>'
@@ -362,10 +392,12 @@ describe('Foliate navigation', () => {
range.setStart(start, 0) range.setStart(start, 0)
range.setEnd(text, 6) range.setEnd(text, 6)
expect(getKoreaderProgress(7, range)).toBe('/body/DocFragment[7]/body/p/span/text().6') expect(getReadingProgressRange(range)?.startContainer).toBe(text)
expect(getReadingProgressRange(range)?.startOffset).toBe(6)
expect(range.startContainer).toBe(start)
}) })
it('merges adjacent KoboSpan text wrappers for KOReader text node offsets', () => { it('keeps the reading point within its source KoboSpan', () => {
const doc = document.implementation.createHTMLDocument('chapter') const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML = doc.body.innerHTML =
'<p><span class="koboSpan" id="kobo.5.1">“</span><span class="italic"><span class="koboSpan" id="kobo.5.2">Fascinating</span></span><span class="koboSpan" id="kobo.5.3">!” </span><span class="koboSpan" id="kobo.5.4">he would say as Harry talked him through using a telephone. </span><span class="koboSpan" id="kobo.5.5">“</span><span class="italic"><span class="koboSpan" id="kobo.5.6">Ingenious,</span></span><span class="koboSpan" id="kobo.5.7"> really, how many ways Muggles have found of getting along without magic.”</span></p>' '<p><span class="koboSpan" id="kobo.5.1">“</span><span class="italic"><span class="koboSpan" id="kobo.5.2">Fascinating</span></span><span class="koboSpan" id="kobo.5.3">!” </span><span class="koboSpan" id="kobo.5.4">he would say as Harry talked him through using a telephone. </span><span class="koboSpan" id="kobo.5.5">“</span><span class="italic"><span class="koboSpan" id="kobo.5.6">Ingenious,</span></span><span class="koboSpan" id="kobo.5.7"> really, how many ways Muggles have found of getting along without magic.”</span></p>'
@@ -377,7 +409,8 @@ describe('Foliate navigation', () => {
range.setStart(text, 33) range.setStart(text, 33)
range.setEnd(text, 33) range.setEnd(text, 33)
expect(getKoreaderProgress(8, range)).toBe('/body/DocFragment[8]/body/p/text()[2].36') expect(getReadingProgressRange(range)?.startContainer).toBe(text)
expect(getReadingProgressRange(range)?.startOffset).toBe(33)
}) })
it('uses the KoboSpan-local offset inside the original EPUB text node', () => { it('uses the KoboSpan-local offset inside the original EPUB text node', () => {
@@ -392,37 +425,35 @@ describe('Foliate navigation', () => {
range.setStart(text, 21) range.setStart(text, 21)
range.setEnd(text, 21) range.setEnd(text, 21)
expect(getKoreaderProgress(8, range)).toBe('/body/DocFragment[8]/body/p/text().21') expect(getReadingProgressRange(range)?.startContainer).toBe(text)
expect(getReadingProgressRange(range)?.startOffset).toBe(21)
}) })
it('maps KEPUB sections back to KOReader DocFragment indexes', () => { it('preserves raw UTF-16 offsets in long Unicode text for server conversion', () => {
const sections = [ const doc = document.implementation.createHTMLDocument('chapter')
{ id: 'kepubify-titlepage-dummy.xhtml' }, doc.body.innerHTML = '<p></p>'
{ id: 'text/part0000_split_000.html' }, const text = doc.createTextNode('word '.repeat(2000) + '\u{1F600} end')
{ id: 'text/part0000_split_000.html' }, doc.querySelector('p')!.append(text)
{ id: 'text/part0000_split_001.html' }, const range = doc.createRange()
{ id: 'text/part0001.html' }, range.setStart(text, 10002)
{ id: 'text/part0002.html' }, range.setEnd(text, 10006)
{ id: 'text/part0003.html' }, const point = getReadingProgressRange(range)
{ id: 'text/part0004.html' }, expect(point?.startContainer).toBe(text)
{ id: 'text/part0005.html' }, expect(point?.startOffset).toBe(10002)
{ id: 'text/part0006.html' }, expect(point?.collapsed).toBe(true)
{ id: 'text/part0007.html' }, expect(range.collapsed).toBe(false)
{ id: 'text/part0008.html' }, })
{ id: 'text/part0009.html' },
{ id: 'text/part0010.html' },
{ id: 'text/part0011.html' },
{ id: 'text/part0012.html' },
{ id: 'text/part0013.html' },
{ id: 'text/part0014.html' },
{ id: 'text/part0015.html' },
{ id: 'text/part0016.html' },
]
expect(getKoreaderDocFragmentIndex(sections, 0)).toBeNull() it('retains an element reading anchor when no text endpoint exists', () => {
expect(getKoreaderDocFragmentIndex(sections, 1)).toBe(1) const doc = document.implementation.createHTMLDocument('chapter')
expect(getKoreaderDocFragmentIndex(sections, 2)).toBe(2) doc.body.innerHTML = '<p><img alt="" /></p>'
expect(getKoreaderDocFragmentIndex(sections, 19)).toBe(19) const range = doc.createRange()
range.selectNode(doc.querySelector('img')!)
const point = getReadingProgressRange(range)
expect(point?.startContainer).toBe(range.startContainer)
expect(point?.startOffset).toBe(range.startOffset)
expect(point?.collapsed).toBe(true)
expect(getReadingProgressRange(null)).toBeNull()
}) })
it('uses the selected text point for KoboSpan instead of the first span in a wide range', () => { it('uses the selected text point for KoboSpan instead of the first span in a wide range', () => {
@@ -102,6 +102,7 @@ describe('KoreaderService', () => {
}; };
let mockPositionConverter: { let mockPositionConverter: {
xpointerPointToCfi: ReturnType<typeof vi.fn>; xpointerPointToCfi: ReturnType<typeof vi.fn>;
cfiPointToXpointer: ReturnType<typeof vi.fn>;
}; };
let mockBookService: { let mockBookService: {
syncKoboReadingStateForExternalProgress: ReturnType<typeof vi.fn>; syncKoboReadingStateForExternalProgress: ReturnType<typeof vi.fn>;
@@ -185,6 +186,7 @@ describe('KoreaderService', () => {
mockPositionConverter = { mockPositionConverter = {
xpointerPointToCfi: vi.fn().mockResolvedValue({ status: 'failed', reason: 'chapter_unavailable' }), xpointerPointToCfi: vi.fn().mockResolvedValue({ status: 'failed', reason: 'chapter_unavailable' }),
cfiPointToXpointer: vi.fn().mockResolvedValue({ status: 'failed', reason: 'chapter_unavailable' }),
}; };
mockBookService = { 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'); const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 }); mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null); mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue({ mockRepo.getReadingProgress.mockResolvedValue({
percentage: 50, percentage: 50,
// /6/4 -> spinePos=4 -> floor(4/2)-1 = 1 -> chapterIndex=1 -> DocFragment[2] cfi: 'epubcfi(/6/4!/4/2/1:10)',
cfi: 'epubcfi(/6/4!/4/2/2:10)',
updatedAt: readerTime, 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({ await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual({
document: 'doc-hash', document: 'doc-hash',
percentage: 0.5, percentage: 0.5,
progress: '/body/DocFragment[2]/body', progress: '/body/DocFragment[2]/body/p/text().10',
device: 'web', device: 'web',
device_id: 'bookorbit-web', device_id: 'bookorbit-web',
timestamp: Math.floor(readerTime.getTime() / 1000), 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 () => { it('returns exact web reader KOReader XPointer when it is stored', async () => {
@@ -1080,10 +1082,10 @@ describe('KoreaderService', () => {
device_id: 'bookorbit-web', device_id: 'bookorbit-web',
timestamp: Math.floor(readerTime.getTime() / 1000), 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'); const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 }); mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null); mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
@@ -1092,10 +1094,42 @@ describe('KoreaderService', () => {
cfi: 'some-unparseable-format', cfi: 'some-unparseable-format',
updatedAt: readerTime, updatedAt: readerTime,
}); });
mockChapterService.parseChapterIndexFromCfi.mockReturnValue(null); mockPositionConverter.cfiPointToXpointer.mockResolvedValue({ status: 'failed', reason: 'unparsable_cfi' });
const result = await service.getProgress(7, 'doc-hash'); 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 () => { it('returns null when neither device nor web reader progress exists', async () => {
@@ -516,10 +516,16 @@ export class KoreaderService {
if (readingProg) { if (readingProg) {
let xpointer = readingProg.koreaderProgress ?? null; let xpointer = readingProg.koreaderProgress ?? null;
if (!xpointer && readingProg.cfi) { if (!xpointer && readingProg.cfi && !isPagedReadingFormat(bookFile.format)) {
const chapterIndex = this.chapterService.parseChapterIndexFromCfi(readingProg.cfi); const startedAt = Date.now();
if (chapterIndex !== null && chapterIndex >= 0) { try {
xpointer = `/body/DocFragment[${chapterIndex + 1}]/body`; 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 * Chapter text index with separate search and CREngine offset mappings. Search
* text nodes (whitespace runs collapse to single spaces, leading whitespace after * collapses Unicode whitespace and inserts block separators; engine offsets keep
* block boundaries drops). Every collapsed CODE POINT maps back to its raw text * Unicode spaces, leading ASCII space, and source-parser text node boundaries.
* node + UTF-16 offset, so positions can be translated between: * Both map Unicode code points back to raw DOM nodes and UTF-16 CFI offsets.
* - 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.
*/ */
import { CfiNode, getDocumentElement, isElementNode, isTextNode } from './cfi.utils'; import { CfiNode, getDocumentElement, isElementNode, isTextNode } from './cfi.utils';
@@ -61,6 +57,8 @@ export interface TextRun {
runCount: number; runCount: number;
collapsedStart: number; collapsedStart: number;
collapsedLength: number; collapsedLength: number;
engineRawOffsets: Int32Array;
engineRawEnd: number;
} }
export interface RawPoint { export interface RawPoint {
@@ -95,10 +93,14 @@ export class ChapterTextIndex {
private readonly runByPoint: Int32Array; private readonly runByPoint: Int32Array;
private readonly rawOffsetByPoint: Int32Array; private readonly rawOffsetByPoint: Int32Array;
private readonly cpByUtf16: Int32Array; private readonly cpByUtf16: Int32Array;
private readonly engineRunsByRun = new Map<TextRun, TextRun[]>();
private readonly runsByParent = new Map<CfiNode, TextRun[]>(); private readonly runsByParent = new Map<CfiNode, TextRun[]>();
private readonly runInfoByNode = new Map<CfiNode, { run: TextRun; rawStart: number }>(); 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 = { const state: BuildState = {
collapsedPoints: [], collapsedPoints: [],
runByPoint: [], runByPoint: [],
@@ -131,8 +133,31 @@ export class ChapterTextIndex {
this.cpByUtf16[this.collapsed.length] = state.collapsedPoints.length; this.cpByUtf16[this.collapsed.length] = state.collapsedPoints.length;
for (const run of runs) { for (const run of runs) {
if (!run.engineRawOffsets.length) continue;
const list = this.runsByParent.get(run.parent) ?? []; 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); this.runsByParent.set(run.parent, list);
} }
for (const [, list] of this.runsByParent) { for (const [, list] of this.runsByParent) {
@@ -181,15 +206,28 @@ export class ChapterTextIndex {
runCount: 0, runCount: 0,
collapsedStart: state.collapsedPoints.length, collapsedStart: state.collapsedPoints.length,
collapsedLength: 0, collapsedLength: 0,
engineRawOffsets: new Int32Array(),
engineRawEnd: 0,
}; };
const runIdx = runs.length; const runIdx = runs.length;
let emittedAny = false; let emittedAny = false;
let rawConcatOffset = 0; 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) { for (const part of run.parts) {
this.runInfoByNode.set(part.node, { run, rawStart: rawConcatOffset }); this.runInfoByNode.set(part.node, { run, rawStart: rawConcatOffset });
const raw = part.node.data ?? ''; const raw = part.node.data ?? '';
const boundaries = new Set(this.engineBoundaries.get(part.node) ?? []);
const partStart = rawConcatOffset;
for (const cp of raw) { 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 (WHITESPACE_RE.test(cp)) {
if (!state.atBoundary && !state.lastWasSpace) { if (!state.atBoundary && !state.lastWasSpace) {
state.collapsedPoints.push(' '); state.collapsedPoints.push(' ');
@@ -223,6 +261,8 @@ export class ChapterTextIndex {
} }
run.collapsedLength = emittedAny ? state.collapsedPoints.length - run.collapsedStart : 0; run.collapsedLength = emittedAny ? state.collapsedPoints.length - run.collapsedStart : 0;
run.engineRawOffsets = Int32Array.from(engineRawOffsets);
run.engineRawEnd = rawConcatOffset;
runs.push(run); runs.push(run);
} }
@@ -247,7 +287,50 @@ export class ChapterTextIndex {
} }
collapsedForRunOffset(run: TextRun, cpOffset: number): number { 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). */ /** 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. */ /** Run-relative crengine offset (code points) for a collapsed cp index. */
runOffsetOfCollapsed(cpIndex: number, run: TextRun): number { 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 { 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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { EpubDomService, loadChapterFromZip, readEpubSpine } from './epub-dom.service'; import { EpubDomService, loadChapterFromZip, readEpubSpine } from './epub-dom.service';
import { PositionConverterService } from './position-converter.service';
const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8"?> const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> <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> <html xmlns="http://www.w3.org/1999/xhtml"><head><title>2</title></head>
<body><p>Second chapter text.</p></body></html>`; <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 archive = new ZipArchive({ zlib: { level: 0 } });
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk)); archive.on('data', (chunk: Buffer) => chunks.push(chunk));
@@ -39,8 +40,8 @@ async function buildEpub(path: string): Promise<void> {
archive.on('error', reject); archive.on('error', reject);
}); });
archive.append('application/epub+zip', { name: 'mimetype', store: true }); archive.append('application/epub+zip', { name: 'mimetype', store: true });
archive.append(CONTAINER_XML, { name: 'META-INF/container.xml' }); archive.append(containerXml, { name: 'META-INF/container.xml' });
archive.append(CONTENT_OPF, { name: 'OEBPS/content.opf' }); archive.append(contentOpf, { name: 'OEBPS/content.opf' });
archive.append(CH1, { name: 'OEBPS/text/ch1.xhtml' }); archive.append(CH1, { name: 'OEBPS/text/ch1.xhtml' });
archive.append(CH2, { name: 'OEBPS/text/ch2.xhtml' }); archive.append(CH2, { name: 'OEBPS/text/ch2.xhtml' });
await archive.finalize(); await archive.finalize();
@@ -83,6 +84,22 @@ describe('EpubDomService', () => {
expect(doc!.index.collapsed).toBe('Second chapter text.'); 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 () => { it('resolves chapters by spine index through the service and caches them', async () => {
const db = makeDb(epubPath); const db = makeDb(epubPath);
const service = new EpubDomService(db as never); const service = new EpubDomService(db as never);
@@ -19,6 +19,7 @@ const SPINE_CACHE_MAX = 24;
const xmlParser = new XMLParser({ const xmlParser = new XMLParser({
ignoreAttributes: false, ignoreAttributes: false,
removeNSPrefix: true,
attributeNamePrefix: '@_', attributeNamePrefix: '@_',
textNodeName: '#text', textNodeName: '#text',
}); });
@@ -104,7 +104,7 @@ describe('parseChapterDocument', () => {
it('indexes named entities as the characters a renderer would show', () => { 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>')); 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', () => { 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', () => { it('maps collapsed crengine offsets back to raw offsets in indented source', () => {
const doc = parseChapterDocument(INDENTED_CHAPTER); 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.status).toBe('exact');
expect((result as { pos0: string }).pos0).toBe('epubcfi(/6/2!/4/2,/1:13,/1:18)'); 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'); const result = cfiRangeToXPointer(doc, 0, 'epubcfi(/6/2!/4/2,/1:13,/1:18)', 'world');
expect(result).toEqual({ expect(result).toEqual({
status: 'exact', status: 'exact',
pos0: '/body/DocFragment[1]/body/p/text().6', pos0: '/body/DocFragment[1]/body/p/text().7',
pos1: '/body/DocFragment[1]/body/p/text().11', pos1: '/body/DocFragment[1]/body/p/text().12',
}); });
}); });
@@ -285,8 +285,8 @@ describe('round trips', () => {
}, },
{ {
chapter: INDENTED_CHAPTER, chapter: INDENTED_CHAPTER,
pos0: '/body/DocFragment[1]/body/p/text().0', pos0: '/body/DocFragment[1]/body/p/text().1',
pos1: '/body/DocFragment[1]/body/p/text().5', pos1: '/body/DocFragment[1]/body/p/text().6',
text: 'Hello', text: 'Hello',
}, },
{ {
@@ -20,9 +20,11 @@ import {
spineCfiForChapterIndex, spineCfiForChapterIndex,
} from './cfi.utils'; } from './cfi.utils';
import { decodeNamedEntities } from './xhtml-entities'; 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. */ /** KOReader sidecar text is truncated at 10000 chars; treat near-limit text as a prefix. */
const TRUNCATED_TEXT_THRESHOLD = 9990; const TRUNCATED_TEXT_THRESHOLD = 9990;
@@ -49,7 +51,42 @@ export type ConversionResult = ConversionSuccess | ConversionFailure;
export function parseChapterDocument(xhtml: string): ChapterDocument { export function parseChapterDocument(xhtml: string): ChapterDocument {
const $ = cheerio.load(decodeNamedEntities(xhtml), { xml: true }); const $ = cheerio.load(decodeNamedEntities(xhtml), { xml: true });
const root = $.root()[0] as unknown as CfiNode; 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 { interface StructuralAnchor {
@@ -70,7 +107,7 @@ function xpointerAnchor(doc: ChapterDocument, pos: string, exclusiveEnd: boolean
if (parsed.offset == null) { if (parsed.offset == null) {
return { cp: exclusiveEnd ? run.collapsedStart + run.collapsedLength : run.collapsedStart, resolved: false }; 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 }; 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 { export function collapsedPointToXPointer(doc: ChapterDocument, chapterIndex: number, cp: number): string | null {
const run = doc.index.runAtCollapsed(cp, 'forward') ?? doc.index.runAtCollapsed(cp, 'backward'); const run = doc.index.runAtCollapsed(cp, 'forward') ?? doc.index.runAtCollapsed(cp, 'backward');
if (!run) return null; 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). */ /** 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'); const endRun = doc.index.runAtCollapsed(endCp - 1, 'backward');
if (!startRun || !endRun) return null; 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 docFragmentIndex = chapterIndex + 1;
const pos0 = buildXPointer( const pos0 = buildXPointer(engineStart.run.parent, docFragmentIndex, engineStart.run.runIndex, engineStart.run.runCount, engineStart.offset);
startRun.parent, const pos1 = buildXPointer(engineEnd.run.parent, docFragmentIndex, engineEnd.run.runIndex, engineEnd.run.runCount, engineEnd.offset);
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));
if (!pos0 || !pos1) return null; if (!pos0 || !pos1) return null;
return { pos0, pos1 }; return { pos0, pos1 };
} }
@@ -98,3 +98,133 @@ describe('PositionConverterService cross-edition read-aloud positions', () => {
).resolves.toEqual({ status: 'failed', reason: 'chapter_text_mismatch', chapterIndex: 0 }); ).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, ConversionResult,
CONVERTER_VERSION, CONVERTER_VERSION,
cfiPointToCollapsedCp, cfiPointToCollapsedCp,
cfiPointToXPointer,
cfiRangeToXPointer, cfiRangeToXPointer,
collapsedPointToCfi, collapsedPointToCfi,
collapsedPointToXPointer, collapsedPointToXPointer,
xpointerPointToCollapsed, xpointerPointToCollapsed,
xpointerPointToCfi,
xpointerRangeToCfi, xpointerRangeToCfi,
} from './position-converter.core'; } from './position-converter.core';
import { parseXPointer } from './xpointer.utils'; import { parseXPointer } from './xpointer.utils';
@@ -89,10 +91,8 @@ export class PositionConverterService {
const doc = await this.epubDom.getChapter(params.bookFileId, chapterIndex); const doc = await this.epubDom.getChapter(params.bookFileId, chapterIndex);
if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex }; if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex };
const cp = xpointerPointToCollapsed(doc, params.pos); const cfi = xpointerPointToCfi(doc, chapterIndex, params.pos);
if (cp == null) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex }; if (!cfi) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
const cfi = collapsedPointToCfi(doc, chapterIndex, cp);
if (!cfi) return { status: 'failed', reason: 'cfi_generation_failed', chapterIndex };
return { status: 'exact', cfi, chapterIndex }; return { status: 'exact', cfi, chapterIndex };
} }
@@ -109,10 +109,8 @@ export class PositionConverterService {
const doc = await this.epubDom.getChapter(params.bookFileId, chapterIndex); const doc = await this.epubDom.getChapter(params.bookFileId, chapterIndex);
if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex }; if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex };
const cp = cfiPointToCollapsedCp(doc, params.cfi); const pos = cfiPointToXPointer(doc, chapterIndex, params.cfi);
if (cp == null) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex }; if (!pos) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
const pos = collapsedPointToXPointer(doc, chapterIndex, cp);
if (!pos) return { status: 'failed', reason: 'xpointer_generation_failed', chapterIndex };
return { status: 'exact', pos0: pos, chapterIndex }; return { status: 'exact', pos0: pos, chapterIndex };
} }
@@ -21,7 +21,7 @@ export interface ParsedXPointer {
offset: number | null; 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 STEP_RE = /^([A-Za-z][\w:-]*)(?:\[(\d+)\])?$/;
const TEXT_STEP_RE = /^text\(\)(?:\[(\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); 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 () => { it('never turns KOSync progress pushes into reading sessions', async () => {
await syncFromDevice(epubHash, 0.4, XPOINTER); await syncFromDevice(epubHash, 0.4, XPOINTER);
await ctx.db await ctx.db