mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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 b pa­sakyti c—d</p>'));
|
const doc = parseChapterDocument(chapter('<p>a b pa­sakyti c—d</p>'));
|
||||||
expect(doc.index.collapsed).toBe('a b pasakyti c—d');
|
expect(doc.index.collapsed).toBe('a b pasakyti 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& '.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
|
||||||
|
|||||||
Reference in New Issue
Block a user