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
|
||||
}
|
||||
|
||||
const getXPathStep = (node) => {
|
||||
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
|
||||
|
||||
export const getReadingProgressRange = (range) => {
|
||||
const point = getRangePoint(range)
|
||||
if (!point) return null
|
||||
|
||||
const target = getKoreaderTextTarget(point)
|
||||
if (!target) return null
|
||||
|
||||
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 progressRange = range.cloneRange()
|
||||
progressRange.setStart(point.node, point.offset)
|
||||
progressRange.collapse(true)
|
||||
return progressRange
|
||||
}
|
||||
|
||||
const isZip = async (file) => {
|
||||
@@ -583,13 +438,13 @@ export class View extends HTMLElement {
|
||||
const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {}
|
||||
const tocItem = this.#tocProgress?.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 contentSourceProgressPercent = toPercent(fraction)
|
||||
const koboLocationValue = getKoboSpanValue(range)
|
||||
const koboLocationType = koboLocationValue ? 'KoboSpan' : null
|
||||
const koreaderDocFragmentIndex = getKoreaderDocFragmentIndex(this.book.sections, index)
|
||||
const koreaderProgress = getKoreaderProgress(koreaderDocFragmentIndex, range)
|
||||
// Convert the source CFI on the server, where the original XHTML is available.
|
||||
const koreaderProgress = null
|
||||
this.lastLocation = {
|
||||
...progress,
|
||||
tocItem,
|
||||
|
||||
@@ -33,8 +33,11 @@ function installBrowserGlobals() {
|
||||
|
||||
describe('Foliate navigation', () => {
|
||||
let View: new () => {
|
||||
open: (book: { sections: { id: string }[] }) => Promise<void>
|
||||
addEventListener: HTMLElement['addEventListener']
|
||||
book: { sections?: unknown[]; resolveHref?: (href: string) => { index: number }; dir?: string | null }
|
||||
renderer: {
|
||||
dispatchEvent?: HTMLElement['dispatchEvent']
|
||||
goTo?: (resolved: { index: number }) => Promise<void>
|
||||
getContents?: () => { index: number; text?: string }[]
|
||||
prev?: () => void
|
||||
@@ -45,8 +48,7 @@ describe('Foliate navigation', () => {
|
||||
goLeft: () => Promise<void>
|
||||
goRight: () => Promise<void>
|
||||
}
|
||||
let getKoreaderProgress: (index: number, range: Range | null) => string | null
|
||||
let getKoreaderDocFragmentIndex: (sections: { id: string }[], index: number) => number | null
|
||||
let getReadingProgressRange: (range: Range | null) => Range | null
|
||||
let getKoboSpanValue: (range: Range | null) => string | null
|
||||
let getPageProgressionRtl: (bookDir: string | null | undefined, contentRtl: boolean) => boolean
|
||||
let usesNegativePageScroll: (vertical: boolean, pageProgressionRtl: boolean) => boolean
|
||||
@@ -69,10 +71,9 @@ describe('Foliate navigation', () => {
|
||||
const viewModulePath = '../../../../../public/assets/foliate/view.js'
|
||||
const fixedLayoutModulePath = '../../../../../public/assets/foliate/fixed-layout.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
|
||||
getKoreaderProgress: typeof getKoreaderProgress
|
||||
getKoreaderDocFragmentIndex: typeof getKoreaderDocFragmentIndex
|
||||
getReadingProgressRange: typeof getReadingProgressRange
|
||||
getKoboSpanValue: typeof getKoboSpanValue
|
||||
})
|
||||
;({ FixedLayout } = (await import(fixedLayoutModulePath)) as { FixedLayout: typeof FixedLayout })
|
||||
@@ -335,7 +336,35 @@ describe('Foliate navigation', () => {
|
||||
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')
|
||||
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.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')
|
||||
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>'
|
||||
@@ -362,10 +392,12 @@ describe('Foliate navigation', () => {
|
||||
range.setStart(start, 0)
|
||||
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')
|
||||
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>'
|
||||
@@ -377,7 +409,8 @@ describe('Foliate navigation', () => {
|
||||
range.setStart(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', () => {
|
||||
@@ -392,37 +425,35 @@ describe('Foliate navigation', () => {
|
||||
range.setStart(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', () => {
|
||||
const sections = [
|
||||
{ id: 'kepubify-titlepage-dummy.xhtml' },
|
||||
{ id: 'text/part0000_split_000.html' },
|
||||
{ id: 'text/part0000_split_000.html' },
|
||||
{ id: 'text/part0000_split_001.html' },
|
||||
{ id: 'text/part0001.html' },
|
||||
{ id: 'text/part0002.html' },
|
||||
{ id: 'text/part0003.html' },
|
||||
{ id: 'text/part0004.html' },
|
||||
{ id: 'text/part0005.html' },
|
||||
{ id: 'text/part0006.html' },
|
||||
{ id: 'text/part0007.html' },
|
||||
{ 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' },
|
||||
]
|
||||
it('preserves raw UTF-16 offsets in long Unicode text for server conversion', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter')
|
||||
doc.body.innerHTML = '<p></p>'
|
||||
const text = doc.createTextNode('word '.repeat(2000) + '\u{1F600} end')
|
||||
doc.querySelector('p')!.append(text)
|
||||
const range = doc.createRange()
|
||||
range.setStart(text, 10002)
|
||||
range.setEnd(text, 10006)
|
||||
const point = getReadingProgressRange(range)
|
||||
expect(point?.startContainer).toBe(text)
|
||||
expect(point?.startOffset).toBe(10002)
|
||||
expect(point?.collapsed).toBe(true)
|
||||
expect(range.collapsed).toBe(false)
|
||||
})
|
||||
|
||||
expect(getKoreaderDocFragmentIndex(sections, 0)).toBeNull()
|
||||
expect(getKoreaderDocFragmentIndex(sections, 1)).toBe(1)
|
||||
expect(getKoreaderDocFragmentIndex(sections, 2)).toBe(2)
|
||||
expect(getKoreaderDocFragmentIndex(sections, 19)).toBe(19)
|
||||
it('retains an element reading anchor when no text endpoint exists', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter')
|
||||
doc.body.innerHTML = '<p><img alt="" /></p>'
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user