// assign a unique ID for each TOC item const assignIDs = toc => { let id = 0 const assignID = item => { item.id = id++ if (item.subitems) for (const subitem of item.subitems) assignID(subitem) } for (const item of toc) assignID(item) return toc } const flatten = items => items .map(item => item.subitems?.length ? [item, flatten(item.subitems)].flat() : item) .flat() export class TOCProgress { async init({ toc, ids, splitHref, getFragment }) { assignIDs(toc) const items = flatten(toc) const grouped = new Map() for (const [i, item] of items.entries()) { const [id, fragment] = await splitHref(item?.href) ?? [] const value = { fragment, item } if (grouped.has(id)) grouped.get(id).items.push(value) else grouped.set(id, { prev: items[i - 1], items: [value] }) } const map = new Map() for (const [i, id] of ids.entries()) { if (grouped.has(id)) map.set(id, grouped.get(id)) else map.set(id, map.get(ids[i - 1])) } this.ids = ids this.map = map this.getFragment = getFragment } getProgress(index, range) { if (!this.ids) return const id = this.ids[index] const obj = this.map.get(id) if (!obj) return null const { prev, items } = obj if (!items) return prev if (!range || items.length === 1 && !items[0].fragment) return items[0].item const doc = range.startContainer.getRootNode() for (const [i, { fragment }] of items.entries()) { const el = this.getFragment(doc, fragment) if (!el) continue if (range.comparePoint(el, 0) > 0) return (items[i - 1]?.item ?? prev) } return items[items.length - 1].item } } export class PageProgress { #book #cache = new Map() #resolveNavigation constructor(book, resolveNavigation) { this.#book = book this.#resolveNavigation = resolveNavigation } async #getCache(index) { let cached = this.#cache.get(index) if (cached) return cached const section = this.#book.sections[index] if (!section?.createDocument) return null const doc = await section.createDocument() const root = doc.body ?? doc.documentElement const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT) const nodes = [] const offsets = [] let total = 0 for (let node = walker.nextNode(); node; node = walker.nextNode()) { const len = node.nodeValue?.length ?? 0 nodes.push(node) offsets.push(total) total += len } cached = { doc, nodes, offsets, total } this.#cache.set(index, cached) return cached } async getProgress(cfi) { try { const nav = this.#resolveNavigation(cfi) if (!nav) return null const { index, anchor } = nav if (index == null || !anchor) return null const cached = await this.#getCache(index) if (!cached) return null const { doc, nodes, offsets, total } = cached const frag = anchor(doc) if (!frag) return null const isRange = frag instanceof Range const range = isRange ? frag : doc.createRange() if (!isRange) range.selectNodeContents(frag) const offset = this.#findOffset(range, nodes, offsets, total) return { fraction: total > 0 ? offset / total : 0, index, } } catch (e) { console.error(e) return null } } #findOffset(range, nodes, offsets, total) { if (!nodes.length) return 0 const container = range.startContainer // fast path: startContainer is a text node in the index if (container.nodeType === Node.TEXT_NODE) { const i = this.#bsearchNode(container, nodes) if (i >= 0) return offsets[i] + range.startOffset } // element container: collapse to start and binary search const collapsed = range.cloneRange() collapsed.collapse(true) const i = this.#bsearchCollapsed(collapsed, nodes) return i >= 0 ? offsets[i] : total } // binary search for an exact text node by document position #bsearchNode(target, nodes) { let low = 0, high = nodes.length - 1 while (low <= high) { const mid = (low + high) >> 1 const node = nodes[mid] if (node === target) return mid const pos = node.compareDocumentPosition(target) if (pos & Node.DOCUMENT_POSITION_FOLLOWING) low = mid + 1 else high = mid - 1 } return -1 } // binary search for the first text node at or after a collapsed range point // collapsed.comparePoint returns: -1 = node is before, 1 = node is at/after #bsearchCollapsed(collapsed, nodes) { let low = 0, high = nodes.length - 1, result = -1 while (low <= high) { const mid = (low + high) >> 1 if (collapsed.comparePoint(nodes[mid], 0) > 0) { result = mid high = mid - 1 } else { low = mid + 1 } } return result } } export class SectionProgress { constructor(sections, sizePerLoc, sizePerTimeUnit) { this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0) this.sizePerLoc = sizePerLoc this.sizePerTimeUnit = sizePerTimeUnit this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0) this.sectionFractions = this.#getSectionFractions() } #getSectionFractions() { const { sizeTotal } = this const results = [0] let sum = 0 for (const size of this.sizes) results.push((sum += size) / sizeTotal) return results } // get progress given index of and fractions within a section getProgress(index, fractionInSection, pageFraction = 0) { const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this const sizeInSection = sizes[index] ?? 0 const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0) const size = sizeBefore + fractionInSection * sizeInSection const nextSize = size + pageFraction * sizeInSection const remainingTotal = sizeTotal - size const remainingSection = (1 - fractionInSection) * sizeInSection return { fraction: nextSize / sizeTotal, section: { current: index, total: sizes.length, }, location: { current: Math.floor(size / sizePerLoc), next: Math.floor(nextSize / sizePerLoc), total: Math.ceil(sizeTotal / sizePerLoc), }, time: { section: remainingSection / sizePerTimeUnit, total: remainingTotal / sizePerTimeUnit, }, } } // the inverse of `getProgress` // get index of and fraction in section based on total fraction getSection(fraction) { if (fraction <= 0) return [0, 0] if (fraction >= 1) return [this.sizes.length - 1, 1] fraction = fraction + Number.EPSILON const { sizeTotal } = this let index = this.sectionFractions.findIndex(x => x > fraction) - 1 if (index < 0) return [0, 0] while (!this.sizes[index]) index++ const fractionInSection = (fraction - this.sectionFractions[index]) / (this.sizes[index] / sizeTotal) return [index, fractionInSection] } }