diff --git a/comic-book.js b/comic-book.js
new file mode 100644
index 0000000..a554526
--- /dev/null
+++ b/comic-book.js
@@ -0,0 +1,40 @@
+export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
+ const cache = new Map()
+ const urls = new Map()
+ const load = async name => {
+ if (cache.has(name)) return cache.get(name)
+ const src = URL.createObjectURL(await loadBlob(name))
+ const page = URL.createObjectURL(
+ new Blob([`
`], { type: 'text/html' }))
+ urls.set(name, [src, page])
+ cache.set(name, page)
+ return page
+ }
+ const unload = name => {
+ urls.get(name)?.forEach?.(url => URL.revokeObjectURL(url))
+ urls.delete(name)
+ cache.delete(name)
+ }
+
+ const exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
+ const files = entries
+ .map(entry => entry.filename)
+ .filter(name => exts.some(ext => name.endsWith(ext)))
+ .sort()
+
+ const book = {}
+ book.getCover = () => loadBlob(files[0])
+ book.metadata = { title: file.name }
+ book.sections = files.map(name => ({
+ id: name,
+ load: () => load(name),
+ unload: () => unload(name),
+ size: getSize(name),
+ }))
+ book.toc = files.map(name => ({ label: name, href: name }))
+ book.rendition = { layout: 'pre-paginated' }
+ book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) })
+ book.splitTOCHref = href => [href, null]
+ book.getTOCFragment = doc => doc.documentElement
+ return book
+}
diff --git a/epub.js b/epub.js
new file mode 100644
index 0000000..80e8a71
--- /dev/null
+++ b/epub.js
@@ -0,0 +1,722 @@
+import * as CFI from './epubcfi.js'
+
+const NS = {
+ CONTAINER: 'urn:oasis:names:tc:opendocument:xmlns:container',
+ XHTML: 'http://www.w3.org/1999/xhtml',
+ OPF: 'http://www.idpf.org/2007/opf',
+ EPUB: 'http://www.idpf.org/2007/ops',
+ DC: 'http://purl.org/dc/elements/1.1/',
+ DCTERMS: 'http://purl.org/dc/terms/',
+ ENC: 'http://www.w3.org/2001/04/xmlenc#',
+ NCX: 'http://www.daisy.org/z3986/2005/ncx/',
+ XLINK: 'http://www.w3.org/1999/xlink',
+}
+
+const MIME = {
+ XML: 'application/xml',
+ NCX: 'application/x-dtbncx+xml',
+ XHTML: 'application/xhtml+xml',
+ HTML: 'text/html',
+ CSS: 'text/css',
+ SVG: 'image/svg+xml',
+ JS: /\/(x-)?(javascript|ecmascript)/,
+}
+
+// convert to camel case
+const camel = x => x.toLowerCase().replace(/[-:](.)/g, (_, g) => g.toUpperCase())
+
+// remove leading, trailing, and excess internal whitespace
+const whitespacePreLine = str => str ? str.trim().replace(/\s{2,}/g, ' ') : ''
+
+const filterAttribute = (attr, value, isList) => isList
+ ? el => el.getAttribute(attr)?.split(/\s/)?.includes(value)
+ : typeof value === 'function'
+ ? el => value(el.getAttribute(attr))
+ : el => el.getAttribute(attr) === value
+
+const getAttributes = (...xs) => el =>
+ el ? Object.fromEntries(xs.map(x => [camel(x), el.getAttribute(x)])) : null
+
+const getElementText = el => whitespacePreLine(el?.textContent)
+
+const childGetter = (doc, ns) => {
+ // ignore the namespace if it doesn't appear in document at all
+ const useNS = doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns)
+ const f = useNS
+ ? (el, name) => el => el.namespaceURI === ns && el.localName === name
+ : (el, name) => el => el.localName === name
+ return {
+ $: (el, name) => [...el.children].find(f(el, name)),
+ $$: (el, name) => [...el.children].filter(f(el, name)),
+ $$$: useNS
+ ? (el, name) => [...el.getElementsByTagNameNS(ns, name)]
+ : (el, name) => [...el.getElementsByTagName(ns, name)],
+ }
+}
+
+const resolveURL = (url, relativeTo) => {
+ try {
+ if (relativeTo.includes(':')) return new URL(url, relativeTo)
+ // the base needs to be a valid URL, so set a base URL and then remove it
+ const root = 'whatever:///'
+ return decodeURI(new URL(url, root + relativeTo).href.replace(root, ''))
+ } catch(e) {
+ console.warn(e)
+ return url
+ }
+}
+
+const isExternal = uri => /^(?!blob)\w+:/i.test(uri)
+
+// like `path.relative()` in Node.js
+const pathRelative = (from, to) => {
+ if (!from) return to
+ const as = from.replace(/\/$/, '').split('/')
+ const bs = to.replace(/\/$/, '').split('/')
+ const i = (as.length > bs.length ? as : bs).findIndex((_, i) => as[i] !== bs[i])
+ return i < 0 ? '' : Array(as.length - i).fill('..').concat(bs.slice(i)).join('/')
+}
+
+const pathDirname = str => str.slice(0, str.lastIndexOf('/') + 1)
+
+// replace asynchronously and sequentially
+// same techinque as https://stackoverflow.com/a/48032528
+const replaceSeries = async (str, regex, f) => {
+ const matches = []
+ str.replace(regex, (...args) => (matches.push(args), null))
+ const results = []
+ for (const args of matches) results.push(await f(...args))
+ return str.replace(regex, () => results.shift())
+}
+
+const regexEscape = str => str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
+
+const LANGS = { attrs: ['dir', 'xml:lang'] }
+const ALTS = { name: 'alternate-script', many: true, ...LANGS, props: ['file-as'] }
+const CONTRIB = {
+ many: true, ...LANGS,
+ props: [{ name: 'role', many: true, attrs: ['scheme'] }, 'file-as', ALTS],
+}
+const METADATA = [
+ {
+ name: 'title', many: true, ...LANGS,
+ props: ['title-type', 'display-seq', 'file-as', ALTS],
+ },
+ {
+ name: 'identifier', many: true,
+ props: [{ name: 'identifier-type', attrs: ['scheme'] }],
+ },
+ { name: 'language', many: true },
+ { name: 'creator', ...CONTRIB },
+ { name: 'contributor', ...CONTRIB },
+ { name: 'publisher', ...LANGS, props: ['file-as', ALTS] },
+ { name: 'description', ...LANGS, props: [ALTS] },
+ { name: 'rights', ...LANGS, props: [ALTS] },
+ { name: 'date' },
+ { name: 'dcterms:modified', type: 'meta' },
+ { name: 'subject', many: true, ...LANGS, props: ['term', 'authority', ALTS] },
+ {
+ name: 'belongs-to-collection', type: 'meta', many: true, ...LANGS,
+ props: [
+ 'collection-type', 'group-position', 'dcterms:identifier', 'file-as',
+ ALTS, { name: 'belongs-to-collection', recursive: true },
+ ],
+ },
+]
+
+// NOTE: this only gets properties defined with the `refines` attribute,
+// which is used in EPUB 3.0, deprecated in 3.1, then restored in 3.2;
+// no support for `opf:` attributes of 2.0 and 3.1
+const getMetadata = opf => {
+ const { $, $$ } = childGetter(opf, NS.OPF)
+ const $metadata = $(opf.documentElement, 'metadata')
+ const els = Array.from($metadata.children)
+ const getValue = (obj, el) => {
+ if (!el) return null
+ const { props = [], attrs = [] } = obj
+ const value = getElementText(el)
+ if (!props.length && !attrs.length) return value
+ const id = el.getAttribute('id')
+ const refines = id ? els.filter(filterAttribute('refines', '#' + id)) : []
+ return Object.fromEntries([['value', value]]
+ .concat(props.map(prop => {
+ const { many, recursive } = prop
+ const name = typeof prop === 'string' ? prop : prop.name
+ const filter = filterAttribute('property', name)
+ const subobj = recursive ? obj : prop
+ return [camel(name), many
+ ? refines.filter(filter).map(el => getValue(subobj, el))
+ : getValue(subobj, refines.find(filter))]
+ }))
+ .concat(attrs.map(attr => [camel(attr), el.getAttribute(attr)])))
+ }
+ const arr = els.filter(filterAttribute('refines', null))
+ const metadata = Object.fromEntries(METADATA.map(obj => {
+ const { type, name, many } = obj
+ const filter = type === 'meta'
+ ? el => el.namespaceURI === NS.OPF && el.getAttribute('property') === name
+ : el => el.namespaceURI === NS.DC && el.localName === name
+ return [camel(name), many ? arr.filter(filter).map(el => getValue(obj, el))
+ : getValue(obj, arr.find(filter))]
+ }))
+
+ const prefix = 'rendition:'
+ const rendition = Object.fromEntries($$($metadata, 'meta')
+ .filter(filterAttribute('property', x => x?.startsWith(prefix)))
+ .map(el => [el.getAttribute('property').replace(prefix, ''),
+ getElementText(el)]))
+ return { metadata, rendition }
+}
+
+const parseNav = (doc, resolve = f => f) => {
+ const { $, $$, $$$ } = childGetter(doc, NS.XHTML)
+ const resolveHref = href => href ? decodeURI(resolve(href)) : null
+ const parseLI = getType => $li => {
+ const $a = $($li, 'a') ?? $($li, 'span')
+ const $ol = $($li, 'ol')
+ const href = resolveHref($a?.getAttribute('href'))
+ const label = getElementText($a) || $a?.getAttribute('title')
+ // TODO: get and concat alt/title texts in content
+ const result = { label, href, subitems: parseOL($ol) }
+ if (getType) result.type = $a?.getAttributeNS(NS.EPUB, 'type')?.split(/\s/)
+ return result
+ }
+ const parseOL = ($ol, getType) => $ol ? $$($ol, 'li').map(parseLI(getType)) : null
+ const parseNav = ($nav, getType) => parseOL($($nav, 'ol'), getType)
+
+ const $$nav = $$$(doc, 'nav')
+ let toc = null, pageList = null, landmarks = null, others = []
+ for (const $nav of $$nav) {
+ const type = $nav.getAttributeNS(NS.EPUB, 'type')?.split(/\s/) ?? []
+ if (type.includes('toc')) toc ??= parseNav($nav)
+ else if (type.includes('page-list')) pageList ??= parseNav($nav)
+ else if (type.includes('landmarks')) landmarks ??= parseNav($nav, true)
+ else others.push({
+ label: getElementText($nav.firstElementChild), type,
+ list: parseNav($nav),
+ })
+ }
+ return { toc, pageList, landmarks, others }
+}
+
+const parseNCX = (doc, resolve = f => f) => {
+ const { $, $$ } = childGetter(doc, NS.NCX)
+ const resolveHref = href => href ? decodeURI(resolve(href)) : null
+ const parseItem = el => {
+ const $label = $(el, 'navLabel')
+ const $content = $(el, 'content')
+ const label = getElementText($label)
+ const href = resolveHref($content.getAttribute('src'))
+ if (el.localName === 'navPoint') {
+ const els = $$(el, 'navPoint')
+ return { label, href, subitems: els.length ? els.map(parseItem) : null }
+ }
+ return { label, href }
+ }
+ const parseList = (el, itemName) => $$(el, itemName).map(parseItem)
+ const getSingle = (container, itemName) => {
+ const $container = $(doc.documentElement, container)
+ return $container ? parseList($container, itemName) : null
+ }
+ return {
+ toc: getSingle('navMap', 'navPoint'),
+ pageList: getSingle('pageList', 'pageTarget'),
+ others: $$(doc.documentElement, 'navList').map(el => ({
+ label: getElementText($(el, 'navLabel')),
+ list: parseList(el, 'navTarget'),
+ })),
+ }
+}
+
+const isUUID = /([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})/
+
+const getUUID = opf => {
+ for (const el of opf.getElementsByTagNameNS(NS.DC, 'identifier')) {
+ const [id] = getElementText(el).split(':').slice(-1)
+ if (isUUID.test(id)) return id
+ }
+ return ''
+}
+
+const getIdentifier = opf => getElementText(
+ opf.getElementById(opf.documentElement.getAttribute('unique-identifier'))
+ ?? opf.getElementsByTagNameNS(NS.DC, 'identifier')[0])
+
+// https://www.w3.org/publishing/epub32/epub-ocf.html#sec-resource-obfuscation
+const deobfuscate = async (key, length, blob) => {
+ const array = new Uint8Array(await blob.slice(0, length).arrayBuffer())
+ length = Math.min(length, array.length)
+ for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length]
+ return new Blob([array, blob.slice(length)], { type: blob.type })
+}
+
+const WebCryptoSHA1 = async str => {
+ const data = new TextEncoder().encode(str)
+ const buffer = await globalThis.crypto.subtle.digest('SHA-1', data)
+ return new Uint8Array(buffer)
+}
+
+const deobfuscators = (sha1 = WebCryptoSHA1) => ({
+ 'http://www.idpf.org/2008/embedding': {
+ key: opf => sha1(getIdentifier(opf)
+ // eslint-disable-next-line no-control-regex
+ .replaceAll(/[\u0020\u0009\u000d\u000a]/g, '')),
+ decode: (key, blob) => deobfuscate(key, 1040, blob),
+ },
+ 'http://ns.adobe.com/pdf/enc#RC': {
+ key: opf => {
+ const uuid = getUUID(opf).replaceAll('-', '')
+ return Uint8Array.from({ length: 16 }, (_, i) =>
+ parseInt(uuid.slice(i * 2, i * 2 + 2), 16))
+ },
+ decode: (key, blob) => deobfuscate(key, 1024, blob),
+ },
+})
+
+class Encryption {
+ #uris = new Map()
+ #decoders = new Map()
+ #algorithms
+ constructor(algorithms) {
+ this.#algorithms = algorithms
+ }
+ async init(encryption, opf) {
+ if (!encryption) return
+ const data = Array.from(
+ encryption.getElementsByTagNameNS(NS.ENC, 'EncryptedData'), el => ({
+ algorithm: el.getElementsByTagNameNS(NS.ENC, 'EncryptionMethod')[0]
+ ?.getAttribute('Algorithm'),
+ uri: el.getElementsByTagNameNS(NS.ENC, 'CipherReference')[0]
+ ?.getAttribute('URI'),
+ }))
+ for (const { algorithm, uri } of data) {
+ if (!this.#decoders.has(algorithm)) {
+ const algo = this.#algorithms[algorithm]
+ if (!algo) {
+ console.warn('Unknown encryption algorithm')
+ continue
+ }
+ const key = await algo.key(opf)
+ this.#decoders.set(algorithm, blob => algo.decode(key, blob))
+ }
+ this.#uris.set(uri, algorithm)
+ }
+ }
+ getDecoder(uri) {
+ return this.#decoders.get(this.#uris.get(uri)) ?? (x => x)
+ }
+}
+
+class Resources {
+ constructor({ opf, resolveHref }) {
+ this.opf = opf
+ const { $, $$, $$$ } = childGetter(opf, NS.OPF)
+
+ const $manifest = $(opf.documentElement, 'manifest')
+ const $spine = $(opf.documentElement, 'spine')
+ const $$itemref = $$($spine, 'itemref')
+
+ this.manifest = $$($manifest, 'item')
+ .map(getAttributes('href', 'id', 'media-type', 'properties'))
+ .map(item => {
+ item.href = resolveHref(item.href)
+ item.properties = item.properties?.split(/\s/)
+ return item
+ })
+ this.spine = $$itemref
+ .map(getAttributes('idref', 'id', 'linear', 'properties'))
+ .map(item => (item.properties = item.properties?.split(/\s/), item))
+ this.pageProgressionDirection = $spine
+ .getAttribute('page-progression-direction')
+
+ this.navPath = this.getItemByProperty('nav')?.href
+ this.ncxPath = (this.getItemByID($spine.getAttribute('toc'))
+ ?? this.manifest.find(item => item.mediaType === MIME.NCX))?.href
+
+ const $guide = $(opf.documentElement, 'guide')
+ if ($guide) this.guide = $$($guide, 'reference')
+ .map(getAttributes('type', 'title', 'href'))
+ .map(({ type, title, href }) => ({
+ label: title,
+ type: type.split(/\s/),
+ href: resolveHref(href),
+ }))
+
+ this.cover = this.getItemByProperty('cover-image')
+ // EPUB 2 compat
+ ?? this.getItemByID($$$(opf, 'meta')
+ .find(filterAttribute('name', 'cover'))
+ ?.getAttribute('content'))
+ ?? this.getItemByHref(this.guide
+ ?.find(ref => ref.type.includes('cover'))?.href)
+
+ this.cfis = CFI.fromElements($$itemref)
+ }
+ getItemByID(id) {
+ return this.manifest.find(item => item.id === id)
+ }
+ getItemByHref(href) {
+ return this.manifest.find(item => item.href === href)
+ }
+ getItemByProperty(prop) {
+ return this.manifest.find(item => item.properties?.includes(prop))
+ }
+ resolveCFI(cfi) {
+ const parts = CFI.parse(cfi)
+ const top = (parts.parent ?? parts).shift()
+ let $itemref = CFI.toElement(this.opf, top)
+ // make sure it's an idref; if not, try again without the ID assertion
+ // mainly because Epub.js used to generate wrong ID assertions
+ // https://github.com/futurepress/epub.js/issues/1236
+ if ($itemref && $itemref.nodeName !== 'idref') {
+ top.at(-1).id = null
+ $itemref = CFI.toElement(this.opf, top)
+ }
+ const idref = $itemref?.getAttribute('idref')
+ const index = this.spine.findIndex(item => item.idref === idref)
+ const anchor = doc => CFI.toRange(doc, parts)
+ return { index, anchor }
+ }
+}
+
+class Loader {
+ #cache = new Map()
+ #children = new Map()
+ #refCount = new Map()
+ allowScript = false
+ constructor({ loadText, loadBlob, resources }) {
+ this.loadText = loadText
+ this.loadBlob = loadBlob
+ this.manifest = resources.manifest
+ this.assets = resources.manifest
+ // needed only when replacing in (X)HTML w/o parsing (see below)
+ //.filter(({ mediaType }) => ![MIME.XHTML, MIME.HTML].includes(mediaType))
+ }
+ createURL(href, data, type, parent) {
+ if (!data) return ''
+ const url = URL.createObjectURL(new Blob([data], { type }))
+ this.#cache.set(href, url)
+ this.#refCount.set(href, 1)
+ if (parent) {
+ const childList = this.#children.get(parent)
+ if (childList) childList.push(href)
+ else this.#children.set(parent, [href])
+ }
+ return url
+ }
+ ref(href, parent) {
+ const childList = this.#children.get(parent)
+ if (!childList?.includes(href)) {
+ this.#refCount.set(href, this.#refCount.get(href) + 1)
+ //console.log(`referencing ${href}, now ${this.#refCount.get(href)}`)
+ if (childList) childList.push(href)
+ else this.#children.set(parent, [href])
+ }
+ return this.#cache.get(href)
+ }
+ unref(href) {
+ if (!this.#refCount.has(href)) return
+ const count = this.#refCount.get(href) - 1
+ //console.log(`unreferencing ${href}, now ${count}`)
+ if (count < 1) {
+ //console.log(`unloading ${href}`)
+ URL.revokeObjectURL(this.#cache.get(href))
+ this.#cache.delete(href)
+ this.#refCount.delete(href)
+ // unref children
+ const childList = this.#children.get(href)
+ if (childList) while (childList.length) this.unref(childList.pop())
+ this.#children.delete(href)
+ } else this.#refCount.set(href, count)
+ }
+ // load manifest item, recursively loading all resources as needed
+ async loadItem(item, parents = []) {
+ if (!item) return null
+ const { href, mediaType } = item
+
+ const isScript = MIME.JS.test(item.mediaType)
+ if (isScript && !this.allowScript) return null
+
+ const parent = parents.at(-1)
+ if (this.#cache.has(href)) return this.ref(href, parent)
+
+ const shouldReplace =
+ (isScript || [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(mediaType))
+ // prevent circular references
+ && parents.every(p => p !== href)
+ if (shouldReplace) return this.loadReplaced(item, parents)
+ return this.createURL(href, await this.loadBlob(href), mediaType, parent)
+ }
+ async loadHref(href, base, parents = []) {
+ if (isExternal(href)) return href
+ const path = resolveURL(href, base)
+ const item = this.manifest.find(item => item.href === path)
+ if (!item) return href
+ return this.loadItem(item, parents.concat(base))
+ }
+ async loadReplaced(item, parents = []) {
+ const { href, mediaType } = item
+ const parent = parents.at(-1)
+ const str = await this.loadText(href)
+ if (!str) return null
+
+ // note that one can also just use `replaceString` for everything:
+ // ```
+ // const replaced = await this.replaceString(str, href, parents)
+ // return this.createURL(href, replaced, mediaType, parent)
+ // ```
+ // which is basically what Epub.js does, which is simpler, but will
+ // break things like iframes (because you don't want to replace links)
+ // or text that just happen to be paths
+
+ // parse and replace in HTML
+ if ([MIME.XHTML, MIME.HTML, MIME.SVG].includes(mediaType)) {
+ let doc = new DOMParser().parseFromString(str, mediaType)
+ // change to HTML if it's not valid XHTML
+ if (mediaType === MIME.XHTML && doc.querySelector('parsererror')) {
+ item.mediaType = MIME.HTML
+ doc = new DOMParser().parseFromString(str, item.mediaType)
+ }
+ // replace hrefs in XML processing instructions
+ // this is mainly for SVGs that use xml-stylesheet
+ if ([MIME.XHTML, MIME.SVG].includes(item.mediaType)) {
+ let child = doc.firstChild
+ while (child instanceof ProcessingInstruction) {
+ if (child.data) {
+ const replacedData = await replaceSeries(child.data,
+ /(?:^|\s*)(href\s*=\s*['"])([^'"]*)(['"])/i,
+ (_, p1, p2, p3) => this.loadHref(p2, href, parents)
+ .then(p2 => `${p1}${p2}${p3}`))
+ child.replaceWith(doc.createProcessingInstruction(
+ child.target, replacedData))
+ }
+ child = child.nextSibling
+ }
+ }
+ // replace hrefs (excluding anchors)
+ // TODO: srcset?
+ const replace = async (el, attr) => el.setAttribute(attr,
+ await this.loadHref(el.getAttribute(attr), href, parents))
+ for (const el of doc.querySelectorAll('link[href]')) await replace(el, 'href')
+ for (const el of doc.querySelectorAll('[src]')) await replace(el, 'src')
+ for (const el of doc.querySelectorAll('[poster]')) await replace(el, 'poster')
+ for (const el of doc.querySelectorAll('[*|href]:not([href]'))
+ el.setAttributeNS(NS.XLINK, 'href', await this.loadHref(
+ el.getAttributeNS(NS.XLINK, 'href'), href, parents))
+ // replace inline styles
+ for (const el of doc.querySelectorAll('style'))
+ if (el.textContent) el.textContent =
+ await this.replaceCSS(el.textContent, href, parents)
+ for (const el of doc.querySelectorAll('[style]'))
+ el.setAttribute('style',
+ await this.replaceCSS(el.getAttribute('style'), href, parents))
+ // TODO: replace inline scripts? probably not worth the trouble
+ const result = new XMLSerializer().serializeToString(doc)
+ return this.createURL(href, result, item.mediaType, parent)
+ }
+
+ const result = mediaType === MIME.CSS
+ ? await this.replaceCSS(str, href, parents)
+ : await this.replaceString(str, href, parents)
+ return this.createURL(href, result, mediaType, parent)
+ }
+ async replaceCSS(str, href, parents = []) {
+ const replacedUrls = await replaceSeries(str,
+ /url\(\s*["']?([^'"\n]*?)\s*["']?\s*\)/gi,
+ (_, url) => this.loadHref(url, href, parents)
+ .then(url => `url("${url}")`))
+ // apart from `url()`, strings can be used for `@import` (but why?!)
+ const replacedImports = await replaceSeries(replacedUrls,
+ /@import\s*["']([^"'\n]*?)["']/gi,
+ (_, url) => this.loadHref(url, href, parents)
+ .then(url => `@import "${url}"`))
+ return replacedImports.replaceAll('-epub-', '')
+ }
+ // find & replace all possible relative paths for all assets without parsing
+ replaceString(str, href, parents = []) {
+ const assetMap = new Map()
+ const urls = this.assets.map(asset => {
+ // do not replace references to the file itself
+ if (asset.href === href) return
+ // href was decoded and resolved when parsing the manifest
+ const relative = pathRelative(pathDirname(href), asset.href)
+ const relativeEnc = encodeURI(relative)
+ const rootRelative = '/' + asset.href
+ const rootRelativeEnc = encodeURI(rootRelative)
+ const set = new Set([relative, relativeEnc, rootRelative, rootRelativeEnc])
+ for (const url of set) assetMap.set(url, asset)
+ return Array.from(set)
+ }).flat().filter(x => x)
+ if (!urls.length) return str
+ const regex = new RegExp(urls.map(regexEscape).join('|'), 'g')
+ return replaceSeries(str, regex, async match =>
+ this.loadItem(assetMap.get(match.replace(/^\//, '')),
+ parents.concat(href)))
+ }
+ unloadItem(item) {
+ this.unref(item?.href)
+ }
+}
+
+const getHTMLFragment = (doc, id) => doc.getElementById(id)
+ ?? doc.querySelector(`[name="${CSS.escape(id)}"]`)
+
+export class EPUB {
+ parser = new DOMParser()
+ #encryption
+ constructor({ loadText, loadBlob, getSize, sha1 }) {
+ this.loadText = loadText
+ this.loadBlob = loadBlob
+ this.getSize = getSize
+ this.#encryption = new Encryption(deobfuscators(sha1))
+ }
+ #parseXML(str) {
+ return str ? this.parser.parseFromString(str, MIME.XML) : null
+ }
+ async #loadXML(uri) {
+ return this.#parseXML(await this.loadText(uri))
+ }
+ async init() {
+ const $container = await this.#loadXML('META-INF/container.xml')
+ if (!$container) throw new Error('Failed to load container file')
+
+ const opfs = Array.from(
+ $container.getElementsByTagNameNS(NS.CONTAINER, 'rootfile'),
+ getAttributes('full-path', 'media-type'))
+ .filter(file => file.mediaType === 'application/oebps-package+xml')
+
+ if (!opfs.length) throw new Error('No package document defined in container')
+ const opfPath = opfs[0].fullPath
+ const opf = await this.#loadXML(opfPath)
+ if (!opf) throw new Error('Failed to load package document')
+
+ const $encryption = await this.#loadXML('META-INF/encryption.xml')
+ await this.#encryption.init($encryption, opf)
+
+ this.resources = new Resources({
+ opf,
+ resolveHref: url => resolveURL(url, opfPath),
+ })
+ const loader = new Loader({
+ loadText: this.loadText,
+ loadBlob: uri => this.loadBlob(uri)
+ .then(this.#encryption.getDecoder(uri)),
+ resources: this.resources,
+ })
+ this.sections = this.resources.spine.map((spineItem, index) => {
+ const { idref, linear, properties = [] } = spineItem
+ const item = this.resources.getItemByID(idref)
+ if (!item) {
+ console.warn(`Could not find item with ID "${idref}" in manifest`)
+ return null
+ }
+ return {
+ id: this.resources.getItemByID(idref)?.href,
+ load: () => loader.loadItem(item),
+ createDocument: () => this.loadDocument(item),
+ size: this.getSize(item.href),
+ cfi: this.resources.cfis[index],
+ linear,
+ forceLeft: properties.includes('page-spread-left'),
+ forceRight: properties.includes('page-spread-right'),
+ forceCenter: properties.includes('page-spread-center'),
+ resolveHref: href => resolveURL(href, item.href),
+ }
+ }).filter(s => s)
+
+ const { navPath, ncxPath } = this.resources
+ if (navPath) try {
+ const resolve = url => resolveURL(url, navPath)
+ const nav = parseNav(await this.#loadXML(navPath), resolve)
+ this.toc = nav.toc
+ this.pageList = nav.pageList
+ this.landmarks = nav.landmarks
+ } catch(e) {
+ console.warn(e)
+ }
+ if (!this.toc && ncxPath) try {
+ const resolve = url => resolveURL(url, ncxPath)
+ const ncx = parseNCX(await this.#loadXML(ncxPath), resolve)
+ this.toc = ncx.toc
+ this.pageList = ncx.pageList
+ } catch(e) {
+ console.warn(e)
+ }
+ this.landmarks ??= this.resources.guide
+
+ const { metadata, rendition } = getMetadata(opf)
+ this.rendition = rendition
+ this.dir = this.resources.pageProgressionDirection
+
+ this.rawMetadata = metadata // useful for debugging, i guess
+ const title = metadata?.title?.[0]
+ this.metadata = {
+ title: title?.value,
+ sortAs: title?.fileAs,
+ language: metadata?.language,
+ identifier: getIdentifier(opf),
+ description: metadata?.description?.value,
+ publisher: metadata?.publisher?.value,
+ published: metadata?.date,
+ modified: metadata?.dctermsModified,
+ subject: metadata?.subject
+ ?.filter(({ value, code }) => value || code)
+ ?.map(({ value, code, scheme }) => ({ name: value, code, scheme })),
+ rights: metadata?.rights?.value,
+ }
+ const relators = {
+ art: 'artist',
+ aut: 'author',
+ bkp: 'producer',
+ clr: 'colorist',
+ edt: 'editor',
+ ill: 'illustrator',
+ trl: 'translator',
+ pbl: 'publisher',
+ }
+ const mapContributor = defaultKey => obj => {
+ const keys = [...new Set(obj.role?.map(({ value, scheme }) =>
+ (!scheme || scheme === 'marc:relators' ? relators[value] : null)
+ ?? defaultKey))]
+ const value = { name: obj.value, sortAs: obj.fileAs }
+ return [keys?.length ? keys : [defaultKey], value]
+ }
+ metadata?.creator?.map(mapContributor('author'))
+ ?.concat(metadata?.contributor?.map?.(mapContributor('contributor')))
+ ?.forEach(([keys, value]) => keys.forEach(key => {
+ if (this.metadata[key]) this.metadata[key].push(value)
+ else this.metadata[key] = [value]
+ }))
+
+ this.getCover = () => {
+ const href = this.resources?.cover?.href
+ return href ? this.loadBlob(href) : null
+ }
+ return this
+ }
+ async loadDocument(item) {
+ const str = await this.loadText(item.href)
+ return this.parser.parseFromString(str, item.mediaType)
+ }
+ resolveCFI(cfi) {
+ return this.resources.resolveCFI(cfi)
+ }
+ resolveHref(href) {
+ const [path, hash] = href.split('#')
+ const item = this.resources.getItemByHref(decodeURI(path))
+ if (!item) return null
+ const index = this.resources.spine.findIndex(({ idref }) => idref === item.id)
+ const anchor = hash ? doc => getHTMLFragment(doc, hash) : () => 0
+ return { index, anchor }
+ }
+ splitTOCHref(href) {
+ return href?.split('#') ?? []
+ }
+ getTOCFragment(doc, id) {
+ return doc.getElementById(id)
+ ?? doc.querySelector(`[name="${CSS.escape(id)}"]`)
+ }
+ isExternal(uri) {
+ return isExternal(uri)
+ }
+}
diff --git a/epubcfi.js b/epubcfi.js
new file mode 100644
index 0000000..dac0771
--- /dev/null
+++ b/epubcfi.js
@@ -0,0 +1,323 @@
+const findIndices = (arr, f) => arr
+ .map((x, i, a) => f(x, i, a) ? i : null).filter(x => x != null)
+const splitAt = (arr, is) => [-1, ...is, arr.length].reduce(({ xs, a }, b) =>
+ ({ xs: xs?.concat([arr.slice(a + 1, b)]) ?? [], a: b }), {}).xs
+const concatArrays = (a, b) =>
+ a.slice(0, -1).concat([a[a.length - 1].concat(b[0])]).concat(b.slice(1))
+
+const isNumber = /\d/
+export const isCFI = /^epubcfi\((.*)\)$/
+const escapeCFI = str => str.replace(/[\^[\](),;=]/g, '^$&')
+
+const wrap = x => isCFI.test(x) ? x : `epubcfi(${x})`
+const unwrap = x => x.match(isCFI)?.[1] ?? x
+const lift = f => (...xs) =>
+ `epubcfi(${f(...xs.map(x => x.match(isCFI)?.[1] ?? x))})`
+export const joinIndir = lift((...xs) => xs.join('!'))
+
+const tokenizer = str => {
+ const tokens = []
+ let state, escape, value = ''
+ const push = x => (tokens.push(x), state = null, value = '')
+ const cat = x => (value += x, escape = false)
+ for (const char of Array.from(str.trim()).concat('')) {
+ if (char === '^' && !escape) {
+ escape = true
+ continue
+ }
+ if (state === '!') push(['!'])
+ else if (state === ',') push([','])
+ else if (state === '/' || state === ':') {
+ if (isNumber.test(char)) {
+ cat(char)
+ continue
+ } else push([state, parseInt(value)])
+ } else if (state === '~') {
+ if (isNumber.test(char) || char === '.') {
+ cat(char)
+ continue
+ } else push(['~', parseFloat(value)])
+ } else if (state === '@') {
+ if (char === ':') {
+ push(['@', parseFloat(value)])
+ state = '@'
+ continue
+ }
+ if (isNumber.test(char) || char === '.') {
+ cat(char)
+ continue
+ } else push(['@', parseFloat(value)])
+ } else if (state === '[') {
+ if (char === ';' && !escape) {
+ push(['[', value])
+ state = ';'
+ } else if (char === ',' && !escape) {
+ push(['[', value])
+ state = '['
+ } else if (char === ']' && !escape) push(['[', value])
+ else cat(char)
+ continue
+ } else if (state?.startsWith(';')) {
+ if (char === '=' && !escape) {
+ state = `;${value}`
+ value = ''
+ } else if (char === ';' && !escape) {
+ push([state, value])
+ state = ';'
+ } else if (char === ']' && !escape) push([state, value])
+ else cat(char)
+ continue
+ }
+ if (char === '/' || char === ':' || char === '~' || char === '@'
+ || char === '[' || char === '!' || char === ',') state = char
+ }
+ return tokens
+}
+
+const findTokens = (tokens, x) => findIndices(tokens, ([t]) => t === x)
+
+const parser = tokens => {
+ const parts = []
+ let state
+ for (const [type, val] of tokens) {
+ if (type === '/') parts.push({ index: val })
+ else {
+ const last = parts[parts.length - 1]
+ if (type === ':') last.offset = val
+ else if (type === '~') last.temporal = val
+ else if (type === '@') last.spatial = (last.spatial ?? []).concat(val)
+ else if (type === ';s') last.side = val
+ else if (type === '[') {
+ if (state === '/' && val) last.id = val
+ else {
+ last.text = (last.text ?? []).concat(val)
+ continue
+ }
+ }
+ }
+ state = type
+ }
+ return parts
+}
+
+// split at step indirections, then parse each part
+const parserIndir = tokens =>
+ splitAt(tokens, findTokens(tokens, '!')).map(parser)
+
+export const parse = cfi => {
+ const tokens = tokenizer(unwrap(cfi))
+ const commas = findTokens(tokens, ',')
+ if (!commas.length) return parserIndir(tokens)
+ const [parent, start, end] = splitAt(tokens, commas).map(parserIndir)
+ return { parent, start, end }
+}
+
+const partToString = ({ index, id, offset, temporal, spatial, text, side }) => {
+ const param = side ? `;s=${side}` : ''
+ return `/${index}`
+ + (id ? `[${escapeCFI(id)}${param}]` : '')
+ // "CFI expressions [..] SHOULD include an explicit character offset"
+ + (offset != null && index % 2 ? `:${offset}` : '')
+ + (temporal ? `~${temporal}` : '')
+ + (spatial ? `@${spatial.join(':')}` : '')
+ + (text || (!id && side) ? '['
+ + (text?.map(escapeCFI)?.join(',') ?? '')
+ + param + ']' : '')
+}
+
+const toInnerString = parsed => parsed.parent
+ ? [parsed.parent, parsed.start, parsed.end].map(toInnerString).join(',')
+ : parsed.map(parts => parts.map(partToString).join('')).join('!')
+
+const toString = parsed => wrap(toInnerString(parsed))
+
+const collapse = (x, toEnd) => typeof x === 'string'
+ ? toString(collapse(parse(x), toEnd))
+ : x.parent ? concatArrays(x.parent, x[toEnd ? 'end' : 'start']) : x
+
+// create range CFI from two CFIs
+const buildRange = (from, to) => {
+ if (typeof from === 'string') from = parse(from)
+ if (typeof to === 'string') to = parse(to)
+ from = collapse(from)
+ to = collapse(to, true)
+ // ranges across multiple documents are not allowed; handle local paths only
+ const localFrom = from[from.length - 1], localTo = to[to.length - 1]
+ const localParent = [], localStart = [], localEnd = []
+ let pushToParent = true
+ const len = Math.max(localFrom.length, localTo.length)
+ for (let i = 0; i < len; i++) {
+ const a = localFrom[i], b = localTo[i]
+ pushToParent &&= a?.index === b?.index && !a?.offset && !b?.offset
+ if (pushToParent) localParent.push(a)
+ else {
+ if (a) localStart.push(a)
+ if (b) localEnd.push(b)
+ }
+ }
+ // copy non-local paths from `from`
+ const parent = from.slice(0, -1).concat([localParent])
+ return toString({ parent, start: [localStart], end: [localEnd] })
+}
+
+export const compare = (a, b) => {
+ if (typeof a === 'string') a = parse(a)
+ if (typeof b === 'string') b = parse(b)
+ if (a.start || b.start) return compare(collapse(a), collapse(b))
+ || compare(collapse(a, true), collapse(b, true))
+
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
+ const p = a[i], q = b[i]
+ const maxIndex = Math.max(p.length, q.length) - 1
+ for (let i = 0; i <= maxIndex; i++) {
+ const x = p[i], y = q[i]
+ if (!x) return -1
+ if (!y) return 1
+ if (x.index > y.index) return 1
+ if (x.index < y.index) return -1
+ if (i === maxIndex) {
+ // TODO: compare temporal & spatial offsets
+ if (x.offset > y.offset) return 1
+ if (x.offset < y.offset) return -1
+ }
+ }
+ }
+ return 0
+}
+
+const isTextNode = ({ nodeType }) => nodeType === 3 || nodeType === 4
+const isElementNode = ({ nodeType }) => nodeType === 1
+
+// child nodes are organized such that the result is always
+// [element, text, element, text, ..., element],
+// regardless of the actual structure in the document;
+// so multiple text nodes need to be combined, and nonexistent ones counted;
+// see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec
+const indexChildNodes = node => {
+ const nodes = Array.from(node.childNodes)
+ // "content other than element and character data is ignored"
+ .filter(node => isTextNode(node) || isElementNode(node))
+ .reduce((arr, node) => {
+ let last = arr[arr.length - 1]
+ if (!last) arr.push(node)
+ // "there is one chunk between each pair of child elements"
+ else if (isTextNode(node)) {
+ if (Array.isArray(last)) last.push(node)
+ else if (isTextNode(last)) arr[arr.length - 1] = [last, node]
+ else arr.push(node)
+ } else {
+ if (isElementNode(last)) arr.push(null, node)
+ else arr.push(node)
+ }
+ return arr
+ }, [])
+ // "the first chunk is located before the first child element"
+ if (isElementNode(nodes[0])) nodes.unshift('first')
+ // "the last chunk is located after the last child element"
+ if (isElementNode(nodes[nodes.length - 1])) nodes.push('last')
+ // "'virtual' elements"
+ nodes.unshift('before') // "0 is a valid index"
+ nodes.push('after') // "n+2 is a valid index"
+ return nodes
+}
+
+const getNodeByIndex = (node, index) => node ? indexChildNodes(node)[index] : null
+
+const partsToNode = (node, parts) => {
+ const { id } = parts[parts.length - 1]
+ if (id) {
+ const el = node.ownerDocument.getElementById(id)
+ if (el) return { node: el, offset: 0 }
+ }
+ for (const { index } of parts) {
+ const newNode = getNodeByIndex(node, index)
+ // handle non-existent nodes
+ if (newNode === 'first') return { node: node.firstChild ?? node }
+ if (newNode === 'last') return { node: node.lastChild ?? node }
+ if (newNode === 'before') return { node, before: true }
+ if (newNode === 'after') return { node, after: true }
+ node = newNode
+ }
+ const { offset } = parts[parts.length - 1]
+ if (!Array.isArray(node)) return { node, offset }
+ // get underlying text node and offset from the chunk
+ let sum = 0
+ for (const n of node) {
+ const { length } = n.nodeValue
+ if (sum + length > offset) return { node: n, offset: offset - sum }
+ sum += length
+ if (n === node[node.length - 1]) return { node: n, offset: length - 1 }
+ }
+}
+
+const nodeToParts = (node, offset) => {
+ const { parentNode, id } = node
+ const indexed = indexChildNodes(parentNode)
+ const index = indexed.findIndex(x =>
+ Array.isArray(x) ? x.some(x => x === node) : x === node)
+ // adjust offset as if merging the text nodes in the chunk
+ const chunk = indexed[index]
+ if (Array.isArray(chunk)) {
+ let sum = 0
+ for (const x of chunk) {
+ if (x === node) {
+ sum += offset
+ break
+ } else sum += x.nodeValue.length
+ }
+ offset = sum
+ }
+ const part = { id, index, offset }
+ return parentNode !== node.ownerDocument.documentElement
+ ? nodeToParts(parentNode).concat(part) : [part]
+}
+
+export const fromRange = range => {
+ const { startContainer, startOffset, endContainer, endOffset } = range
+ const start = nodeToParts(startContainer, startOffset)
+ if (range.collapsed) return toString([start])
+ const end = nodeToParts(endContainer, endOffset)
+ return buildRange([start], [end])
+}
+
+export const toRange = (doc, parts) => {
+ const startParts = collapse(parts)
+ const endParts = collapse(parts, true)
+
+ const root = doc.documentElement
+ const start = partsToNode(root, startParts[0])
+ const end = partsToNode(root, endParts[0])
+
+ const range = doc.createRange()
+
+ if (start.before) range.setStartBefore(start.node)
+ else if (start.after) range.setStartAfter(start.node)
+ else range.setStart(start.node, start.offset)
+
+ if (end.before) range.setEndBefore(end.node)
+ else if (end.after) range.setEndAfter(end.node)
+ else range.setEnd(end.node, end.offset)
+ return range
+}
+
+// faster way of getting CFIs for sorted elements in a single parent
+export const fromElements = elements => {
+ const results = []
+ const { parentNode } = elements[0]
+ const parts = nodeToParts(parentNode)
+ for (const [index, node] of indexChildNodes(parentNode).entries()) {
+ const el = elements[results.length]
+ if (node === el)
+ results.push(toString([parts.concat({ id: el.id, index })]))
+ }
+ return results
+}
+
+export const toElement = (doc, parts) =>
+ partsToNode(doc.documentElement, collapse(parts)).node
+
+// turn indices into standard CFIs when you don't have an actual package document
+export const fake = {
+ fromIndex: index => `/6/${(index + 1) * 2}`,
+ toIndex: parts => parts?.at(-1).index / 2 - 1,
+}
diff --git a/fb2.js b/fb2.js
new file mode 100644
index 0000000..e91dd77
--- /dev/null
+++ b/fb2.js
@@ -0,0 +1,329 @@
+const trim = str => str?.trim()?.replace(/\s{2,}/g, ' ')
+const getElementText = el => trim(el?.textContent)
+
+const NS = {
+ XLINK: 'http://www.w3.org/1999/xlink',
+ EPUB: 'http://www.idpf.org/2007/ops',
+}
+
+const MIME = {
+ XML: 'application/xml',
+ XHTML: 'application/xhtml+xml',
+}
+
+const STYLE = {
+ 'strong': ['strong', 'self'],
+ 'emphasis': ['em', 'self'],
+ 'style': ['span', 'self'],
+ 'a': 'anchor',
+ 'strikethrough': ['s', 'self'],
+ 'sub': ['sub', 'self'],
+ 'sup': ['sup', 'self'],
+ 'code': ['code', 'self'],
+ 'image': 'image',
+}
+
+const TABLE = {
+ 'tr': ['tr', ['align']],
+ 'th': ['th', ['colspan', 'rowspan', 'align', 'valign']],
+ 'td': ['td', ['colspan', 'rowspan', 'align', 'valign']],
+}
+
+const POEM = {
+ 'epigraph': ['blockquote'],
+ 'subtitle': ['h2', STYLE],
+ 'text-author': ['p', STYLE],
+ 'date': ['p', STYLE],
+ 'stanza': 'stanza',
+}
+
+const SECTION = {
+ 'title': ['header', {
+ 'p': ['h1', STYLE],
+ 'empty-line': ['br'],
+ }],
+ 'epigraph': ['blockquote', 'self'],
+ 'image': 'image',
+ 'annotation': ['aside'],
+ 'section': ['section', 'self'],
+ 'p': ['p', STYLE],
+ 'poem': ['blockquote', POEM],
+ 'subtitle': ['h2', STYLE],
+ 'cite': ['blockquote', 'self'],
+ 'empty-line': ['br'],
+ 'table': ['table', TABLE],
+ 'text-author': ['p', STYLE],
+}
+POEM['epigraph'].push(SECTION)
+
+const BODY = {
+ 'image': 'image',
+ 'title': ['section', {
+ 'p': ['h1', STYLE],
+ 'empty-line': ['br'],
+ }],
+ 'epigraph': ['section', SECTION],
+ 'section': ['section', SECTION],
+}
+
+const getImageSrc = el => {
+ const href = el.getAttributeNS(NS.XLINK, 'href')
+ const [, id] = href.split('#')
+ const bin = el.getRootNode().getElementById(id)
+ return bin
+ ? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}`
+ : href
+}
+
+class FB2Converter {
+ constructor(fb2) {
+ this.fb2 = fb2
+ this.doc = document.implementation.createDocument(NS.XHTML, 'html')
+ }
+ image(node) {
+ const el = this.doc.createElement('img')
+ el.alt = node.getAttribute('alt')
+ el.title = node.getAttribute('title')
+ el.setAttribute('src', getImageSrc(node))
+ return el
+ }
+ anchor(node) {
+ const el = this.convert(node, { 'a': ['a', STYLE] })
+ el.setAttribute('href', node.getAttributeNS(NS.XLINK, 'href'))
+ if (node.getAttribute('type') === 'note')
+ el.setAttributeNS(NS.EPUB, 'epub:type', 'noteref')
+ return el
+ }
+ stanza(node) {
+ const el = this.convert(node, {
+ 'stanza': ['p', {
+ 'title': ['header', {
+ 'p': ['strong', STYLE],
+ 'empty-line': ['br'],
+ }],
+ 'subtitle': ['p', STYLE],
+ }],
+ })
+ for (const child of node.children) if (child.nodeName === 'v') {
+ el.append(this.doc.createTextNode(child.textContent))
+ el.append(this.doc.createElement('br'))
+ }
+ return el
+ }
+ convert(node, def) {
+ // not an element; return text content
+ if (node.nodeType !== 1) return this.doc.createTextNode(node.textContent)
+
+ const d = def?.[node.nodeName]
+ if (!d) return null
+ if (typeof d === 'string') return this[d](node)
+
+ const [name, opts] = d
+ const el = this.doc.createElement(name)
+
+ // copy the ID, and set class name from original element name
+ if (node.id) el.id = node.id
+ el.classList.add(node.nodeName)
+
+ // copy attributes
+ if (Array.isArray(opts)) for (const attr of opts)
+ el.setAttribute(attr, node.getAttribute(attr))
+
+ // process child elements recursively
+ const childDef = opts === 'self' ? def : Array.isArray(opts) ? null : opts
+ let child = node.firstChild
+ while (child) {
+ const childEl = this.convert(child, childDef)
+ if (childEl) el.append(childEl)
+ child = child.nextSibling
+ }
+ return el
+ }
+}
+
+const parseXML = async blob => {
+ const buffer = await blob.arrayBuffer()
+ const str = new TextDecoder('utf-8').decode(buffer)
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(str, MIME.XML)
+ // FIXME: `Document.xmlEncoding` is deprecated
+ if (doc.xmlEncoding && doc.xmlEncoding !== 'utf-8') {
+ const str = new TextDecoder(doc.xmlEncoding).decode(buffer)
+ return parser.parseFromString(str, MIME.XML)
+ }
+ return doc
+}
+
+const style = URL.createObjectURL(new Blob([`
+@namespace epub "http://www.idpf.org/2007/ops";
+body > img, section > img {
+ display: block;
+ margin: auto;
+}
+.title {
+ text-align: center;
+}
+body > section > .title, body.notesBodyType > .title {
+ margin: 3em 0;
+}
+body.notesBodyType > section .title {
+ text-align: left;
+ margin: 1em 0;
+}
+p {
+ text-indent: 1em;
+ margin: 0;
+}
+:not(p) + p, p:first-child {
+ text-indent: 0;
+}
+.poem p {
+ text-indent: 0;
+ margin: 1em 0;
+}
+.text-author, .date {
+ text-align: end;
+}
+.text-author:before {
+ content: "—";
+}
+table {
+ border-collapse: collapse;
+}
+td, th {
+ padding: .25em;
+}
+a[epub|type~="noteref"] {
+ font-size: .75em;
+ vertical-align: super;
+}
+body:not(.notesBodyType) > .title, body:not(.notesBodyType) > .epigraph {
+ margin: 3em 0;
+}
+`], { type: 'text/css' }))
+
+const template = html => `
+
+
+ ${html}
+`
+
+// name of custom ID attribute for TOC items
+const dataID = 'data-foliate-id'
+
+export const makeFB2 = async blob => {
+ const book = {}
+ const doc = await parseXML(blob)
+ const converter = new FB2Converter(doc)
+
+ const $ = x => doc.querySelector(x)
+ const $$ = x => [...doc.querySelectorAll(x)]
+ const getPerson = el => {
+ const nick = getElementText(el.querySelector('nickname'))
+ if (nick) return nick
+ const first = getElementText(el.querySelector('first-name'))
+ const middle = getElementText(el.querySelector('middle-name'))
+ const last = getElementText(el.querySelector('last-name'))
+ const name = [first, middle, last].filter(x => x).join(' ')
+ const sortAs = last
+ ? [last, [first, middle].filter(x => x).join(' ')].join(', ')
+ : null
+ return { name, sortAs }
+ }
+ const getDate = el => el?.getAttribute('value') ?? getElementText(el)
+ const annotation = $('title-info annotation')
+ book.metadata = {
+ title: getElementText($('title-info book-title')),
+ identifier: getElementText($('document-info id')),
+ language: getElementText($('title-info lang')),
+ author: $$('title-info author').map(getPerson),
+ translator: $$('title-info translator').map(getPerson),
+ producer: $$('document-info author').map(getPerson)
+ .concat($$('document-info program-used').map(getElementText)),
+ publisher: getElementText($('publish-info publisher')),
+ published: getDate($('title-info date')),
+ modified: getDate($('document-info date')),
+ description: annotation ? converter.convert(annotation,
+ { annotation: ['div', SECTION] }).innerHTML : null,
+ subject: $$('title-info genre').map(getElementText)
+ }
+ book.getCover = () => fetch(getImageSrc($('coverpage image')))
+ .then(res => res.blob())
+
+ // get convert each body
+ const bodyData = Array.from(doc.querySelectorAll('body'), body => {
+ const converted = converter.convert(body, { body: ['body', BODY] })
+ return [Array.from(converted.children, el => {
+ // get list of IDs in the section
+ const ids = [el, ...el.querySelectorAll('[id]')].map(el => el.id)
+ return { el, ids }
+ }), converted]
+ })
+
+ const sectionData = bodyData[0][0]
+ // make a separate section for each section in the first body
+ .map(({ el, ids }) => {
+ // set up titles for TOC
+ const titles = Array.from(
+ el.querySelectorAll(':scope > section > .title'),
+ (el, index) => {
+ el.setAttribute(dataID, index)
+ return { title: getElementText(el), index }
+ })
+ return { ids, titles, el }
+ })
+ // for additional bodies, only make one section for each body
+ .concat(bodyData.slice(1).map(([sections, body]) => {
+ const ids = sections.map(s => s.ids).flat()
+ body.classList.add('notesBodyType')
+ return { ids, el: body, linear: 'no' }
+ }))
+ .map(({ ids, titles, el, linear }) => {
+ const str = template(el.outerHTML)
+ const blob = new Blob([str], { type: MIME.XHTML })
+ const url = URL.createObjectURL(blob)
+ const title = trim(el.querySelector('.title, .subtitle, p')?.textContent
+ ?? (el.classList.contains('title') ? el.textContent : ''))
+ return {
+ ids, title, titles, load: () => url,
+ createDocument: () => new DOMParser().parseFromString(str, MIME.XHTML),
+ // doo't count image data as it'd skew the size too much
+ size: blob.size - Array.from(el.querySelectorAll('[src]'),
+ el => el.getAttribute('src')?.length ?? 0)
+ .reduce((a, b) => a + b, 0),
+ linear,
+ }
+ })
+
+ const idMap = new Map()
+ book.sections = sectionData.map((section, index) => {
+ const { ids, load, createDocument, size, linear } = section
+ for (const id of ids) if (id) idMap.set(id, index)
+ return { id: index, load, createDocument, size, linear }
+ })
+
+ book.toc = sectionData.map(({ title, titles }, index) => {
+ const id = index.toString()
+ return {
+ label: title,
+ href: id,
+ subitems: titles?.length ? titles.map(({ title, index }) => ({
+ label: title,
+ href: `${id}#${index}`,
+ })) : null,
+ }
+ }).filter(item => item)
+
+ book.resolveHref = href => {
+ const [a, b] = href.split('#')
+ return a
+ // the link is from the TOC
+ ? { index: Number(a), anchor: doc => doc.querySelector(`[${dataID}="${b}"]`) }
+ // link from within the page
+ : { index: idMap.get(b), anchor: doc => doc.getElementById(b) }
+ }
+ book.splitTOCHref = href => href?.split('#')?.map(x => Number(x)) ?? []
+ book.getTOCFragment = (doc, id) => doc.querySelector(`[${dataID}="${id}"]`)
+
+ return book
+}
diff --git a/fixed-layout.js b/fixed-layout.js
new file mode 100644
index 0000000..e2f74bc
--- /dev/null
+++ b/fixed-layout.js
@@ -0,0 +1,261 @@
+const parseViewport = str => str
+ ?.split(/[,;\s]/) // NOTE: technically, only the comma is valid
+ ?.filter(x => x)
+ ?.map(x => x.split('=').map(x => x.trim()))
+
+const getViewport = (doc, viewport) => {
+ // use `viewBox` for SVG
+ if (doc.documentElement.nodeName === 'svg') {
+ const [, , width, height] = doc.documentElement
+ .getAttribute('viewBox')?.split(/\s/) ?? []
+ return { width, height }
+ }
+
+ // get `viewport` `meta` element
+ const meta = parseViewport(doc.querySelector('meta[name="viewport"]')
+ ?.getAttribute('content'))
+ if (meta) return Object.fromEntries(meta)
+
+ // fallback to book's viewport
+ if (typeof viewport === 'string') return parseViewport(viewport)
+ if (viewport) return viewport
+
+ // if no viewport (possibly with image directly in spine), get image size
+ const img = doc.querySelector('img')
+ if (img) return { width: img.naturalWidth, height: img.naturalHeight }
+
+ // just show *something*, i guess...
+ console.warn(new Error('Missing viewport properties'))
+ return { width: 1000, height: 2000 }
+}
+
+class Container {
+ #element = document.createElement('div')
+ defaultViewport
+ #portrait = false
+ #left
+ #right
+ #side
+ constructor() {
+ Object.assign(this.#element.style, {
+ width: '100vw',
+ height: '100vh',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ })
+ new ResizeObserver(() => this.render()).observe(this.#element)
+ }
+ get element() {
+ return this.#element
+ }
+ get side() {
+ return this.#side
+ }
+ async #createFrame(src) {
+ const element = document.createElement('div')
+ const iframe = document.createElement('iframe')
+ element.append(iframe)
+ Object.assign(iframe.style, {
+ border: '0',
+ display: 'none',
+ overflow: 'hidden',
+ })
+ iframe.setAttribute('scrolling', 'no')
+ iframe.classList.add('filter')
+ this.#element.append(element)
+ if (!src) return { blank: true, element, iframe }
+ return new Promise(resolve => {
+ const onload = () => {
+ iframe.removeEventListener('load', onload)
+ this.onLoad?.(iframe)
+ const doc = iframe.contentDocument
+ const { width, height } = getViewport(doc, this.defaultViewport)
+ resolve({
+ element, iframe,
+ width: parseFloat(width),
+ height: parseFloat(height),
+ })
+ }
+ iframe.addEventListener('load', onload)
+ iframe.src = src
+ })
+ }
+ render(side = this.#side) {
+ if (!side) return
+ const left = this.#left
+ const right = this.#right
+ const target = side === 'left' ? left : right
+ const { width, height } = this.#element.getBoundingClientRect()
+ const portrait = height > width
+ this.#portrait = portrait
+ const blankWidth = left.width ?? right.width
+ const blankHeight = left.height ?? right.height
+
+ const scale = portrait
+ ? Math.min(
+ width / (target.width ?? blankWidth),
+ height / (target.height ?? blankHeight))
+ : Math.min(
+ width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)),
+ height / Math.max(
+ left.height ?? blankHeight,
+ right.height ?? blankHeight))
+
+ const transform = frame => {
+ const { element, iframe, width, height } = frame
+ Object.assign(iframe.style, {
+ width: `${width}px`,
+ height: `${height}px`,
+ transform: `scale(${scale})`,
+ transformOrigin: 'top left',
+ display: 'block',
+ })
+ Object.assign(element.style, {
+ width: `${(width ?? blankWidth) * scale}px`,
+ height: `${(height ?? blankHeight) * scale}px`,
+ display: 'block',
+ })
+ if (portrait && frame !== target) {
+ element.style.display = 'none'
+ }
+ }
+ transform(left, 'left')
+ transform(right, 'right')
+ }
+ async showSpread({ left, right, center, side }) {
+ this.#element.replaceChildren()
+ this.#left = null
+ this.#right = null
+ if (center) {
+ // TODO
+ } else {
+ this.#left = await this.#createFrame(left)
+ this.#right = await this.#createFrame(right)
+ this.#side = side
+ this.render()
+ }
+ }
+ goLeft() {
+ if (this.#left?.blank) return true
+ if (this.#portrait && this.#left?.element?.style?.display === 'none') {
+ this.#right.element.style.display = 'none'
+ this.#left.element.style.display = 'block'
+ this.#side = 'left'
+ return true
+ }
+ }
+ goRight() {
+ if (this.#right?.blank) return true
+ if (this.#portrait && this.#right?.element?.style?.display === 'none') {
+ this.#left.element.style.display = 'none'
+ this.#right.element.style.display = 'block'
+ this.#side = 'right'
+ return true
+ }
+ }
+}
+
+export class FixedLayout {
+ #spreads
+ #index = -1
+ #container = new Container()
+ constructor({ book, onLoad, onRelocated }) {
+ this.book = book
+ this.#container.defaultViewport = book.rendition?.viewport
+ this.onLoad = onLoad
+ this.onRelocated = onRelocated
+
+ const rtl = book.dir === 'rtl'
+ const ltr = !rtl
+ this.rtl = rtl
+ this.#spreads = book.sections.reduce((arr, section) => {
+ const last = arr[arr.length - 1]
+ const { linear, forceCenter, forceLeft, forceRight } = section
+ if (linear === 'no') return arr
+ const newSpread = () => {
+ const spread = {}
+ arr.push(spread)
+ return spread
+ }
+ if (forceCenter) newSpread().center = section
+ else if (forceLeft) {
+ const spread = last.center || last.left || ltr ? newSpread() : last
+ spread.left = section
+ }
+ else if (forceRight) {
+ const spread = last.center || last.right || rtl ? newSpread() : last
+ spread.right = section
+ }
+ else if (ltr) {
+ if (last.center || last.right) newSpread().left = section
+ else if (last.left) last.right = section
+ else last.left = section
+ }
+ else {
+ if (last.center || last.left) newSpread().right = section
+ else if (last.right) last.left = section
+ else last .right = section
+ }
+ return arr
+ }, [{}])
+ }
+ get element() {
+ return this.#container.element
+ }
+ get index() {
+ const spread = this.#spreads[this.#index]
+ const section = spread?.center ?? (this.#container.side === 'left'
+ ? spread.left ?? spread.right : spread.right ?? spread.left)
+ return this.book.sections.indexOf(section)
+ }
+ getSpreadOf(section) {
+ const spreads = this.#spreads
+ for (let index = 0; index < spreads.length; index++) {
+ const { left, right, center } = spreads[index]
+ if (left === section) return { index, side: 'left' }
+ if (right === section) return { index, side: 'right' }
+ if (center === section) return { index, side: 'center' }
+ }
+ }
+ async goToSpread(index, side) {
+ if (index < 0 || index > this.#spreads.length - 1) return
+ if (index === this.#index) {
+ this.#container.render(side)
+ return
+ }
+ this.#index = index
+ const spread = this.#spreads[index]
+ if (spread.center) {
+ const center = await spread.center?.load?.()
+ await this.#container.showSpread({ center, side })
+ } else {
+ const left = await spread.left?.load?.()
+ const right = await spread.right?.load?.()
+ await this.#container.showSpread({ left, right, side })
+ }
+ this.onRelocated?.(null, this.index, 0, 1)
+ }
+ async select(target) {
+ await this.goTo(target)
+ // TODO
+ }
+ async goTo(target) {
+ const { book } = this
+ const resolved = await target
+ const section = book.sections[resolved.index]
+ if (!section) return
+ const { index, side } = this.getSpreadOf(section)
+ await this.goToSpread(index, side)
+ }
+ async next() {
+ const s = this.rtl ? this.#container.goLeft() : this.#container.goRight()
+ if (s) this.onRelocated?.(null, this.index, 0, 1)
+ else return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left')
+ }
+ async prev() {
+ const s = this.rtl ? this.#container.goRight() : this.#container.goLeft()
+ if (s) this.onRelocated?.(null, this.index, 0, 1)
+ else return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right')
+ }
+}
diff --git a/mobi.js b/mobi.js
new file mode 100644
index 0000000..5126d61
--- /dev/null
+++ b/mobi.js
@@ -0,0 +1,1157 @@
+const unescapeHTML = str => {
+ if (!str) return ''
+ const textarea = document.createElement('textarea')
+ textarea.innerHTML = str
+ return textarea.value
+}
+
+const MIME = {
+ XML: 'application/xml',
+ XHTML: 'application/xhtml+xml',
+ HTML: 'text/html',
+ CSS: 'text/css',
+ SVG: 'image/svg+xml',
+}
+
+const PDB_HEADER = {
+ name: [0, 32, 'string'],
+ type: [60, 4, 'string'],
+ creator: [64, 4, 'string'],
+ numRecords: [76, 2, 'uint'],
+}
+
+const PALMDOC_HEADER = {
+ compression: [0, 2, 'uint'],
+ numTextRecords: [8, 2, 'uint'],
+ recordSize: [10, 2, 'uint'],
+ encryption: [12, 2, 'uint'],
+}
+
+const MOBI_HEADER = {
+ magic: [16, 4, 'string'],
+ length: [20, 4, 'uint'],
+ type: [24, 4, 'uint'],
+ encoding: [28, 4, 'uint'],
+ uid: [32, 4, 'uint'],
+ version: [36, 4, 'uint'],
+ titleOffset: [84, 4, 'uint'],
+ titleLength: [88, 4, 'uint'],
+ localeRegion: [94, 1, 'uint'],
+ localeLanguage: [95, 1, 'uint'],
+ resourceStart: [108, 4, 'uint'],
+ huffcdic: [112, 4, 'uint'],
+ numHuffcdic: [116, 4, 'uint'],
+ exthFlag: [128, 4, 'uint'],
+ trailingFlags: [240, 4, 'uint'],
+ indx: [244, 4, 'uint'],
+}
+
+const KF8_HEADER = {
+ resourceStart: [108, 4, 'uint'],
+ fdst: [192, 4, 'uint'],
+ numFdst: [196, 4, 'uint'],
+ frag: [248, 4, 'uint'],
+ skel: [252, 4, 'uint'],
+ guide: [260, 4, 'uint'],
+}
+
+const EXTH_HEADER = {
+ magic: [0, 4, 'string'],
+ length: [4, 4, 'uint'],
+ count: [8, 4, 'uint'],
+}
+
+const INDX_HEADER = {
+ magic: [0, 4, 'string'],
+ length: [4, 4, 'uint'],
+ type: [8, 4, 'uint'],
+ idxt: [20, 4, 'uint'],
+ numRecords: [24, 4, 'uint'],
+ encoding: [28, 4, 'uint'],
+ language: [32, 4, 'uint'],
+ total: [36, 4, 'uint'],
+ ordt: [40, 4, 'uint'],
+ ligt: [44, 4, 'uint'],
+ numLigt: [48, 4, 'uint'],
+ numCncx: [52, 4, 'uint'],
+}
+
+const TAGX_HEADER = {
+ magic: [0, 4, 'string'],
+ length: [4, 4, 'uint'],
+ numControlBytes: [8, 4, 'uint'],
+}
+
+const HUFF_HEADER = {
+ magic: [0, 4, 'string'],
+ offset1: [8, 4, 'uint'],
+ offset2: [12, 4, 'uint'],
+}
+
+const CDIC_HEADER = {
+ magic: [0, 4, 'string'],
+ length: [4, 4, 'uint'],
+ numEntries: [8, 4, 'uint'],
+ codeLength: [12, 4, 'uint'],
+}
+
+const FDST_HEADER = {
+ magic: [0, 4, 'string'],
+ numEntries: [8, 4, 'uint'],
+}
+
+const FONT_HEADER = {
+ flags: [8, 4, 'uint'],
+ dataStart: [12, 4, 'uint'],
+ keyLength: [16, 4, 'uint'],
+ keyStart: [20, 4, 'uint'],
+}
+
+const MOBI_ENCODING = {
+ 1252: 'windows-1252',
+ 65001: 'utf-8',
+}
+
+const EXTH_RECORD_TYPE = {
+ 100: ['creator', 'string', true],
+ 101: ['publisher'],
+ 103: ['description'],
+ 104: ['isbn'],
+ 105: ['subject', 'string', true],
+ 106: ['date'],
+ 108: ['contributor', 'string', true],
+ 109: ['rights'],
+ 110: ['subjectCode', 'string', true],
+ 112: ['source', 'string', true],
+ 113: ['asin'],
+ 121: ['boundary', 'uint'],
+ 122: ['fixedLayout'],
+ 125: ['numResources', 'uint'],
+ 126: ['originalResolution'],
+ 127: ['zeroGutter'],
+ 128: ['zeroMargin'],
+ 129: ['coverURI'],
+ 132: ['regionMagnification'],
+ 201: ['coverOffset', 'uint'],
+ 202: ['thumbnailOffset', 'uint'],
+ 503: ['title'],
+ 524: ['language', 'string', true],
+ 527: ['pageProgressionDirection'],
+}
+
+const MOBI_LANG = {
+ 1: ['ar', 'ar-SA', 'ar-IQ', 'ar-EG', 'ar-LY', 'ar-DZ', 'ar-MA', 'ar-TN', 'ar-OM',
+ 'ar-YE', 'ar-SY', 'ar-JO', 'ar-LB', 'ar-KW', 'ar-AE', 'ar-BH', 'ar-QA'],
+ 2: ['bg'], 3: ['ca'], 4: ['zh', 'zh-TW', 'zh-CN', 'zh-HK', 'zh-SG'], 5: ['cs'],
+ 6: ['da'], 7: ['de', 'de-DE', 'de-CH', 'de-AT', 'de-LU', 'de-LI'], 8: ['el'],
+ 9: ['en', 'en-US', 'en-GB', 'en-AU', 'en-CA', 'en-NZ', 'en-IE', 'en-ZA',
+ 'en-JM', null, 'en-BZ', 'en-TT', 'en-ZW', 'en-PH'],
+ 10: ['es', 'es-ES', 'es-MX', null, 'es-GT', 'es-CR', 'es-PA', 'es-DO',
+ 'es-VE', 'es-CO', 'es-PE', 'es-AR', 'es-EC', 'es-CL', 'es-UY', 'es-PY',
+ 'es-BO', 'es-SV', 'es-HN', 'es-NI', 'es-PR'],
+ 11: ['fi'], 12: ['fr', 'fr-FR', 'fr-BE', 'fr-CA', 'fr-CH', 'fr-LU', 'fr-MC'],
+ 13: ['he'], 14: ['hu'], 15: ['is'], 16: ['it', 'it-IT', 'it-CH'],
+ 17: ['ja'], 18: ['ko'], 19: ['nl', 'nl-NL', 'nl-BE'], 20: ['no', 'nb', 'nn'],
+ 21: ['pl'], 22: ['pt', 'pt-BR', 'pt-PT'], 23: ['rm'], 24: ['ro'], 25: ['ru'],
+ 26: ['hr', null, 'sr'], 27: ['sk'], 28: ['sq'], 29: ['sv', 'sv-SE', 'sv-FI'],
+ 30: ['th'], 31: ['tr'], 32: ['ur'], 33: ['id'], 34: ['uk'], 35: ['be'],
+ 36: ['sl'], 37: ['et'], 38: ['lv'], 39: ['lt'], 41: ['fa'], 42: ['vi'],
+ 43: ['hy'], 44: ['az'], 45: ['eu'], 46: ['hsb'], 47: ['mk'], 48: ['st'],
+ 49: ['ts'], 50: ['tn'], 52: ['xh'], 53: ['zu'], 54: ['af'], 55: ['ka'],
+ 56: ['fo'], 57: ['hi'], 58: ['mt'], 59: ['se'], 62: ['ms'], 63: ['kk'],
+ 65: ['sw'], 67: ['uz', null, 'uz-UZ'], 68: ['tt'], 69: ['bn'], 70: ['pa'],
+ 71: ['gu'], 72: ['or'], 73: ['ta'], 74: ['te'], 75: ['kn'], 76: ['ml'],
+ 77: ['as'], 78: ['mr'], 79: ['sa'], 82: ['cy', 'cy-GB'], 83: ['gl', 'gl-ES'],
+ 87: ['kok'], 97: ['ne'], 98: ['fy'],
+}
+
+const concatTypedArray = (a, b) => {
+ const result = new a.constructor(a.length + b.length)
+ result.set(a)
+ result.set(b, a.length)
+ return result
+}
+const concatTypedArray3 = (a, b, c) => {
+ const result = new a.constructor(a.length + b.length + c.length)
+ result.set(a)
+ result.set(b, a.length)
+ result.set(c, a.length + b.length)
+ return result
+}
+
+const decoder = new TextDecoder()
+const getString = buffer => decoder.decode(buffer)
+const getUint = buffer => {
+ if (!buffer) return
+ const l = buffer.byteLength
+ const func = l === 4 ? 'getUint32' : l === 2 ? 'getUint16' : 'getUint8'
+ return new DataView(buffer)[func](0)
+}
+const getStruct = (def, buffer) => Object.fromEntries(Array.from(Object.entries(def))
+ .map(([key, [start, len, type]]) => [key,
+ (type === 'string' ? getString : getUint)(buffer.slice(start, start + len))]))
+
+const getDecoder = x => new TextDecoder(MOBI_ENCODING[x])
+
+const getVarLen = (byteArray, i = 0) => {
+ let value = 0, length = 0
+ for (const byte of byteArray.subarray(i, i + 4)) {
+ value = (value << 7) | (byte & 0b111_1111) >>> 0
+ length++
+ if (byte & 0b1000_0000) break
+ }
+ return { value, length }
+}
+
+// variable-length quantity, but read from the end of data
+const getVarLenFromEnd = byteArray => {
+ let value = 0
+ for (const byte of byteArray.subarray(-4)) {
+ // `byte & 0b1000_0000` indicates the start of value
+ if (byte & 0b1000_0000) value = 0
+ value = (value << 7) | (byte & 0b111_1111)
+ }
+ return value
+}
+
+const countBitsSet = x => {
+ let count = 0
+ for (; x > 0; x = x >> 1) if ((x & 1) === 1) count++
+ return count
+}
+
+const countUnsetEnd = x => {
+ let count = 0
+ while ((x & 1) === 0) x = x >> 1, count++
+ return count
+}
+
+const decompressPalmDOC = array => {
+ let output = []
+ for (let i = 0; i < array.length; i++) {
+ const byte = array[i]
+ if (byte === 0) output.push(0) // uncompressed literal, just copy it
+ else if (byte <= 8) // copy next 1-8 bytes
+ for (const x of array.subarray(i + 1, (i += byte) + 1))
+ output.push(x)
+ else if (byte <= 0b0111_1111) output.push(byte) // uncompressed literal
+ else if (byte <= 0b1011_1111) {
+ // 1st and 2nd bits are 10, meaning this is a length-distance pair
+ // read next byte and combine it with current byte
+ const bytes = (byte << 8) | array[i++ + 1]
+ // the 3rd to 13th bits encode distance
+ const distance = (bytes & 0b0011_1111_1111_1111) >>> 3
+ // the last 3 bits, plus 3, is the length to copy
+ const length = (bytes & 0b111) + 3
+ for (let j = 0; j < length; j++)
+ output.push(output[output.length - distance])
+ }
+ // compressed from space plus char
+ else output.push(32, byte ^ 0b1000_0000)
+ }
+ return Uint8Array.from(output)
+}
+
+const read32Bits = (byteArray, from) => {
+ const startByte = from >> 3
+ const end = from + 32
+ const endByte = end >> 3
+ let bits = 0n
+ for (let i = startByte; i <= endByte; i++)
+ bits = bits << 8n | BigInt(byteArray[i] ?? 0)
+ return (bits >> (8n - BigInt(end & 7))) & 0xffffffffn
+}
+
+const huffcdic = async (mobi, loadRecord) => {
+ const huffRecord = await loadRecord(mobi.huffcdic)
+ const { magic, offset1, offset2 } = getStruct(HUFF_HEADER, huffRecord)
+ if (magic !== 'HUFF') throw new Error('Invalid HUFF record')
+
+ // table1 is indexed by byte value
+ const table1 = Array.from({ length: 256 }, (_, i) => offset1 + i * 4)
+ .map(offset => getUint(huffRecord.slice(offset, offset + 4)))
+ .map(x => [x & 0b1000_0000, x & 0b1_1111, x >>> 8])
+
+ // table2 is indexed by code length
+ const table2 = [null].concat(Array.from({ length: 32 }, (_, i) => offset2 + i * 8)
+ .map(offset => [
+ getUint(huffRecord.slice(offset, offset + 4)),
+ getUint(huffRecord.slice(offset + 4, offset + 8))]))
+
+ const dictionary = []
+ for (let i = 1; i < mobi.numHuffcdic; i++) {
+ const record = await loadRecord(mobi.huffcdic + i)
+ const cdic = getStruct(CDIC_HEADER, record)
+ if (cdic.magic !== 'CDIC') throw new Error('Invalid CDIC record')
+ // `numEntries` is the total number of dictionary data across CDIC records
+ // so `n` here is the number of entries in *this* record
+ const n = Math.min(1 << cdic.codeLength, cdic.numEntries - dictionary.length)
+ const buffer = record.slice(cdic.length)
+ for (let i = 0; i < n; i++) {
+ const offset = getUint(buffer.slice(i * 2, i * 2 + 2))
+ const x = getUint(buffer.slice(offset, offset + 2))
+ const length = x & 0x7fff
+ const decompressed = x & 0x8000
+ const value = new Uint8Array(
+ buffer.slice(offset + 2, offset + 2 + length))
+ dictionary.push([value, decompressed])
+ }
+ }
+
+ const decompress = byteArray => {
+ let output = new Uint8Array()
+ const bitLength = byteArray.byteLength * 8
+ for (let i = 0; i < bitLength;) {
+ const bits = Number(read32Bits(byteArray, i))
+ let [found, codeLength, value] = table1[bits >>> 24]
+ if (!found) {
+ while (bits >>> (32 - codeLength) < table2[codeLength][0])
+ codeLength += 1
+ value = table2[codeLength][1]
+ }
+ if ((i += codeLength) > bitLength) break
+
+ const code = value - (bits >>> (32 - codeLength))
+ let [result, decompressed] = dictionary[code]
+ if (!decompressed) {
+ // the result is itself compressed
+ result = decompress(result)
+ // cache the result for next time
+ dictionary[code] = [result, true]
+ }
+ output = concatTypedArray(output, result)
+ }
+ return output
+ }
+ return decompress
+}
+
+const getIndexData = async (indxIndex, loadRecord) => {
+ const indxRecord = await loadRecord(indxIndex)
+ const indx = getStruct(INDX_HEADER, indxRecord)
+ if (indx.magic !== 'INDX') throw new Error('Invalid INDX record')
+ const decoder = getDecoder(indx.encoding)
+
+ const tagxBuffer = indxRecord.slice(indx.length)
+ const tagx = getStruct(TAGX_HEADER, tagxBuffer)
+ if (tagx.magic !== 'TAGX') throw new Error('Invalid TAGX section')
+ const numTags = (tagx.length - 12) / 4
+ const tagTable = Array.from({ length: numTags }, (_, i) =>
+ new Uint8Array(tagxBuffer.slice(12 + i * 4, 12 + i * 4 + 4)))
+
+ const cncx = {}
+ let cncxRecordOffset = 0
+ for (let i = 0; i < indx.numCncx; i++) {
+ const record = await loadRecord(indxIndex + indx.numRecords + i + 1)
+ const array = new Uint8Array(record)
+ for (let pos = 0; pos < array.byteLength;) {
+ const index = pos
+ const { value, length } = getVarLen(array, pos)
+ pos += length
+ const result = record.slice(pos, pos + value)
+ pos += value
+ cncx[cncxRecordOffset + index] = decoder.decode(result)
+ }
+ cncxRecordOffset += 0x10000
+ }
+
+ const table = []
+ for (let i = 0; i < indx.numRecords; i++) {
+ const record = await loadRecord(indxIndex + 1 + i)
+ const array = new Uint8Array(record)
+ const indx = getStruct(INDX_HEADER, record)
+ if (indx.magic !== 'INDX') throw new Error('Invalid INDX record')
+ for (let j = 0; j < indx.numRecords; j++) {
+ const offsetOffset = indx.idxt + 4 + 2 * j
+ const offset = getUint(record.slice(offsetOffset, offsetOffset + 2))
+
+ const length = getUint(record.slice(offset, offset + 1))
+ const name = getString(record.slice(offset + 1, offset + 1 + length))
+
+ const tags = []
+ const startPos = offset + 1 + length
+ let controlByteIndex = 0
+ let pos = startPos + tagx.numControlBytes
+ for (const [tag, numValues, mask, end] of tagTable) {
+ if (end & 1) {
+ controlByteIndex++
+ continue
+ }
+ const offset = startPos + controlByteIndex
+ const value = getUint(record.slice(offset, offset + 1)) & mask
+ if (value === mask) {
+ if (countBitsSet(mask) > 1) {
+ const { value, length } = getVarLen(array, pos)
+ tags.push([tag, null, value, numValues])
+ pos += length
+ } else tags.push([tag, 1, null, numValues])
+ } else tags.push([tag, value >> countUnsetEnd(mask), null, numValues])
+ }
+
+ const tagMap = {}
+ for (const [tag, valueCount, valueBytes, numValues] of tags) {
+ const values = []
+ if (valueCount != null) {
+ for (let i = 0; i < valueCount * numValues; i++) {
+ const { value, length } = getVarLen(array, pos)
+ values.push(value)
+ pos += length
+ }
+ } else {
+ let count = 0
+ while (count < valueBytes) {
+ const { value, length } = getVarLen(array, pos)
+ values.push(value)
+ pos += length
+ count += length
+ }
+ }
+ tagMap[tag] = values
+ }
+ table.push({ name, tagMap })
+ }
+ }
+ return { table, cncx }
+}
+
+const getNCX = async (indxIndex, loadRecord) => {
+ const { table, cncx } = await getIndexData(indxIndex, loadRecord)
+ const items = table.map(({ tagMap }, index) => ({
+ index,
+ offset: tagMap[1]?.[0],
+ size: tagMap[2]?.[0],
+ label: cncx[tagMap[3]] ?? '',
+ headingLevel: tagMap[4]?.[0],
+ pos: tagMap[6],
+ parent: tagMap[21]?.[0],
+ firstChild: tagMap[22]?.[0],
+ lastChild: tagMap[23]?.[0],
+ }))
+ const getChildren = item => {
+ if (item.firstChild == null) return item
+ item.children = items.filter(x => x.parent === item.index).map(getChildren)
+ return item
+ }
+ return items.filter(item => item.headingLevel === 0).map(getChildren)
+}
+
+const getEXTH = (buf, encoding) => {
+ const { magic, count } = getStruct(EXTH_HEADER, buf)
+ if (magic !== 'EXTH') throw new Error('Invalid EXTH header')
+ const decoder = getDecoder(encoding)
+ const results = {}
+ let offset = 12
+ for (let i = 0; i < count; i++) {
+ const type = getUint(buf.slice(offset, offset + 4))
+ const length = getUint(buf.slice(offset + 4, offset + 8))
+ if (type in EXTH_RECORD_TYPE) {
+ const [name, typ, many] = EXTH_RECORD_TYPE[type]
+ const data = buf.slice(offset + 8, offset + length)
+ const value = typ === 'uint' ? getUint(data) : decoder.decode(data)
+ if (many) {
+ results[name] ??= []
+ results[name].push(value)
+ } else results[name] = value
+ }
+ offset += length
+ }
+ return results
+}
+
+const getFont = async (buf, unzlib) => {
+ const { flags, dataStart, keyLength, keyStart } = getStruct(FONT_HEADER, buf)
+ const array = new Uint8Array(buf.slice(dataStart))
+ // deobfuscate font
+ if (flags & 0b10) {
+ const bytes = keyLength === 16 ? 1024 : 1040
+ const key = new Uint8Array(buf.slice(keyStart, keyStart + keyLength))
+ const length = Math.min(bytes, array.length)
+ for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length]
+ }
+ // decompress font
+ if (flags & 1) try {
+ return await unzlib(array)
+ } catch (e) {
+ console.warn(e)
+ console.warn('Failed to decompress font')
+ }
+ return array
+}
+
+export const isMOBI = async file => {
+ const magic = getString(await file.slice(60, 68).arrayBuffer())
+ return magic === 'BOOKMOBI'// || magic === 'TEXtREAd'
+}
+
+class PDB {
+ #file
+ #offsets
+ pdb
+ async open(file) {
+ this.#file = file
+ const pdb = getStruct(PDB_HEADER, await file.slice(0, 78).arrayBuffer())
+ this.pdb = pdb
+ const buffer = await file.slice(78, 78 + pdb.numRecords * 8).arrayBuffer()
+ // get start and end offsets for each record
+ this.#offsets = Array.from({ length: pdb.numRecords },
+ (_, i) => getUint(buffer.slice(i * 8, i * 8 + 4)))
+ .map((x, i, a) => [x, a[i + 1]])
+ }
+ loadRecord(index) {
+ const offsets = this.#offsets[index]
+ if (!offsets) throw new RangeError('Record index out of bounds')
+ return this.#file.slice(...offsets).arrayBuffer()
+ }
+ async loadMagic(index) {
+ const start = this.#offsets[index][0]
+ return getString(await this.#file.slice(start, start + 4).arrayBuffer())
+ }
+}
+
+export class MOBI extends PDB {
+ #start = 0
+ #resourceStart
+ #decoder
+ #encoder
+ #decompress
+ #removeTrailingEntries
+ constructor({ unzlib }) {
+ super()
+ this.unzlib = unzlib
+ }
+ async open(file) {
+ await super.open(file)
+ // TODO: if (this.pdb.type === 'TEXt')
+ this.headers = this.#getHeaders(await super.loadRecord(0))
+ this.#resourceStart = this.headers.mobi.resourceStart
+ let isKF8 = this.headers.mobi.version >= 8
+ if (!isKF8) {
+ const boundary = this.headers.exth?.boundary
+ if (boundary < 0xffffffff) try {
+ // it's a "combo" MOBI/KF8 file; try to open the KF8 part
+ this.headers = this.#getHeaders(await super.loadRecord(boundary))
+ this.#start = boundary
+ isKF8 = true
+ } catch (e) {
+ console.warn(e)
+ console.warn('Failed to open KF8; falling back to MOBI')
+ }
+ }
+ await this.#setup()
+ return isKF8 ? new KF8(this).init() : new MOBI6(this).init()
+ }
+ #getHeaders(buf) {
+ const palmdoc = getStruct(PALMDOC_HEADER, buf)
+ const mobi = getStruct(MOBI_HEADER, buf)
+ if (mobi.magic !== 'MOBI') throw new Error('Missing MOBI header')
+
+ const { titleOffset, titleLength, localeLanguage, localeRegion } = mobi
+ mobi.title = buf.slice(titleOffset, titleOffset + titleLength)
+ const lang = MOBI_LANG[localeLanguage]
+ mobi.language = lang?.[localeRegion >> 2] ?? lang?.[0]
+
+ const exth = mobi.exthFlag & 0b100_0000
+ ? getEXTH(buf.slice(mobi.length + 16), mobi.encoding) : null
+ const kf8 = mobi.version >= 8 ? getStruct(KF8_HEADER, buf) : null
+ return { palmdoc, mobi, exth, kf8 }
+ }
+ async #setup() {
+ const { palmdoc, mobi } = this.headers
+ this.#decoder = getDecoder(mobi.encoding)
+ // `TextEncoder` only supports UTF-8
+ // we are only encoding ASCII anyway, so I think it's fine
+ this.#encoder = new TextEncoder()
+
+ // set up decompressor
+ const { compression } = palmdoc
+ this.#decompress = compression === 1 ? f => f
+ : compression === 2 ? decompressPalmDOC
+ : compression === 17480 ? await huffcdic(mobi, this.loadRecord.bind(this))
+ : null
+ if (!this.#decompress) throw new Error('Unknown compression type')
+
+ // set up function for removing trailing bytes
+ const { trailingFlags } = mobi
+ const multibyte = trailingFlags & 1
+ const numTrailingEntries = countBitsSet(trailingFlags >>> 1)
+ this.#removeTrailingEntries = array => {
+ for (let i = 0; i < numTrailingEntries; i++) {
+ const length = getVarLenFromEnd(array)
+ array = array.subarray(0, -length)
+ }
+ if (multibyte) {
+ const length = (array[array.length - 1] & 0b11) + 1
+ array = array.subarray(0, -length)
+ }
+ return array
+ }
+ }
+ decode(...args) {
+ return this.#decoder.decode(...args)
+ }
+ encode(...args) {
+ return this.#encoder.encode(...args)
+ }
+ loadRecord(index) {
+ return super.loadRecord(this.#start + index)
+ }
+ loadMagic(index) {
+ return super.loadMagic(this.#start + index)
+ }
+ loadText(index) {
+ return this.loadRecord(index + 1)
+ .then(buf => new Uint8Array(buf))
+ .then(this.#removeTrailingEntries)
+ .then(this.#decompress)
+ }
+ async loadResource(index) {
+ const buf = await super.loadRecord(this.#resourceStart + index)
+ const magic = getString(buf.slice(0, 4))
+ if (magic === 'FONT') return getFont(buf, this.unzlib)
+ if (magic === 'VIDE' || magic === 'AUDI') return buf.slice(12)
+ return buf
+ }
+ getNCX() {
+ const index = this.headers.mobi.indx
+ if (index < 0xffffffff) return getNCX(index, this.loadRecord.bind(this))
+ }
+ getMetadata() {
+ const { mobi, exth } = this.headers
+ return {
+ identifier: mobi.uid.toString(),
+ title: unescapeHTML(exth?.title || this.decode(mobi.title)),
+ author: exth?.creator?.map(unescapeHTML),
+ publisher: unescapeHTML(exth?.publisher),
+ language: exth?.language ?? mobi.language,
+ published: exth?.date,
+ description: unescapeHTML(exth?.description),
+ subject: exth?.subject?.map(unescapeHTML),
+ rights: unescapeHTML(exth?.rights),
+ }
+ }
+ async getCover() {
+ const { exth } = this.headers
+ const offset = exth?.coverOffset < 0xffffffff ? exth?.coverOffset
+ : exth?.thumbnailOffset < 0xffffffff ? exth?.thumbnailOffset : null
+ if (offset != null) {
+ const buf = await this.loadResource(offset)
+ return new Blob([buf])
+ }
+ }
+}
+
+const mbpPagebreakRegex = /<\s*(?:mbp:)?pagebreak[^>]*>/gi
+const fileposRegex = /<[^<>]+filepos=['"]{0,1}(\d+)[^<>]*>/gi
+
+class MOBI6 {
+ parser = new DOMParser()
+ serializer = new XMLSerializer()
+ #resourceCache = new Map()
+ #textCache = new Map()
+ #cache = new Map()
+ #sections
+ #fileposList = []
+ #type = MIME.HTML
+ constructor(mobi) {
+ this.mobi = mobi
+ }
+ async init() {
+ // load all text records in an array
+ let array = new Uint8Array()
+ for (let i = 0; i < this.mobi.headers.palmdoc.numTextRecords; i++)
+ array = concatTypedArray(array, await this.mobi.loadText(i))
+
+ // convert to string so we can use regex
+ // note that `filepos` are byte offsets
+ // so it needs to preserve each byte as a separate character
+ // (see https://stackoverflow.com/q/50198017)
+ const str = Array.from(new Uint8Array(array),
+ c => String.fromCharCode(c)).join('')
+
+ // split content into sections at each ``
+ this.#sections = [0]
+ .concat(Array.from(str.matchAll(mbpPagebreakRegex), m => m.index))
+ .map((x, i, a) => str.slice(x, a[i + 1]))
+ // recover the original raw bytes
+ .map(str => Uint8Array.from(str, x => x.charCodeAt(0)))
+ .map(raw => ({ book: this, raw }))
+ // get start and end filepos for each section
+ .reduce((arr, x) => {
+ const last = arr[arr.length - 1]
+ x.start = last?.end ?? 0
+ x.end = x.start + x.raw.byteLength
+ return arr.concat(x)
+ }, [])
+
+ this.sections = this.#sections.map((section, index) => ({
+ id: index,
+ load: () => this.loadSection(section),
+ createDocument: () => this.createDocument(section),
+ size: section.end - section.start,
+ }))
+
+ const fileposInNCX = []
+ try {
+ const ncx = await this.mobi.getNCX()
+ const map = ({ label, offset, children }) => {
+ const filepos = offset.toString().padStart(10, '0')
+ const href = `filepos:${filepos}`
+ fileposInNCX.push(filepos)
+ label = unescapeHTML(label)
+ return { label, href, subitems: children?.map(map) }
+ }
+ this.toc = ncx?.map(map)
+ this.landmarks = await this.getGuide()
+
+ // try to build TOC if there's no NCX
+ if (!this.toc) {
+ const tocHref = this.landmarks
+ .find(({ type }) => type?.includes('toc'))?.href
+ if (tocHref) {
+ const { index } = this.resolveHref(tocHref)
+ const doc = await this.sections[index].createDocument()
+ this.toc = Array.from(doc.querySelectorAll('a[filepos]'),
+ a => ({
+ label: a.innerText?.trim(),
+ href: `filepos:${a.getAttribute('filepos')}`,
+ }))
+ }
+ }
+ } catch(e) {
+ console.warn(e)
+ }
+
+ // get list of all `filepos` references in the book,
+ // which will be used to insert anchor elements
+ // because only then can they be referenced in the DOM
+ this.#fileposList = [...new Set(fileposInNCX
+ .concat(Array.from(str.matchAll(fileposRegex), m => m[1]))
+ .map(filepos => ({ filepos, number: Number(filepos) }))
+ .sort((a, b) => a.number - b.number))]
+
+ this.metadata = this.mobi.getMetadata()
+ this.getCover = this.mobi.getCover.bind(this.mobi)
+ return this
+ }
+ async getGuide() {
+ const doc = await this.createDocument(this.#sections[0])
+ return Array.from(doc.getElementsByTagName('reference'), ref => ({
+ label: ref.getAttribute('title'),
+ type: ref.getAttribute('type')?.split(/\s/),
+ href: `filepos:${ref.getAttribute('filepos')}`,
+ }))
+ }
+ async loadResource(index) {
+ if (this.#resourceCache.has(index)) return this.#resourceCache.get(index)
+ const raw = await this.mobi.loadResource(index)
+ const url = URL.createObjectURL(new Blob([raw]))
+ this.#resourceCache.set(index, url)
+ return url
+ }
+ async loadRecindex(recindex) {
+ return this.loadResource(Number(recindex) - 1)
+ }
+ async replaceResources(doc) {
+ for (const img of doc.querySelectorAll('img[recindex]')) {
+ const recindex = img.getAttribute('recindex')
+ try {
+ img.src = await this.loadRecindex(recindex)
+ } catch (e) {
+ console.warn(`Failed to load image ${recindex}`)
+ }
+ }
+ for (const media of doc.querySelectorAll('[mediarecindex]')) {
+ const mediarecindex = media.getAttribute('mediarecindex')
+ const recindex = media.getAttribute('recindex')
+ try {
+ media.src = await this.loadRecindex(mediarecindex)
+ if (recindex) media.poster = await this.loadRecindex(recindex)
+ } catch (e) {
+ console.warn(`Failed to load media ${mediarecindex}`)
+ }
+ }
+ for (const a of doc.querySelectorAll('[filepos]')) {
+ const filepos = a.getAttribute('filepos')
+ a.href = `filepos:${filepos}`
+ }
+ }
+ async loadText(section) {
+ if (this.#textCache.has(section)) return this.#textCache.get(section)
+ const { raw } = section
+
+ // insert anchor elements for each `filepos`
+ const fileposList = this.#fileposList
+ .filter(({ number }) => number >= section.start && number < section.end)
+ .map(obj => ({ ...obj, offset: obj.number - section.start }))
+ let arr = raw
+ if (fileposList.length) {
+ arr = raw.subarray(0, fileposList[0].offset)
+ fileposList.forEach(({ filepos, offset }, i) => {
+ const next = fileposList[i + 1]
+ const a = this.mobi.encode(``)
+ arr = concatTypedArray3(arr, a, raw.subarray(offset, next?.offset))
+ })
+ }
+ const str = this.mobi.decode(arr).replaceAll(mbpPagebreakRegex, '')
+ this.#textCache.set(section, str)
+ return str
+ }
+ async createDocument(section) {
+ const str = await this.loadText(section)
+ return this.parser.parseFromString(str, this.#type)
+ }
+ async loadSection(section) {
+ if (this.#cache.has(section)) return this.#cache.get(section)
+ const doc = await this.createDocument(section)
+
+ // inject default stylesheet
+ const style = doc.createElement('style')
+ doc.head.append(style)
+ // blockquotes in MOBI seem to have only a small left margin by default
+ // many books seem to rely on this, as it's the only way to set margin
+ // (since there's no CSS)
+ style.append(doc.createTextNode(`blockquote {
+ margin-block-start: 0;
+ margin-block-end: 0;
+ margin-inline-start: 1em;
+ margin-inline-end: 0;
+ }`))
+
+ await this.replaceResources(doc)
+ const result = this.serializer.serializeToString(doc)
+ const url = URL.createObjectURL(new Blob([result], { type: this.#type }))
+ this.#cache.set(section, url)
+ return url
+ }
+ resolveHref(href) {
+ const filepos = href.match(/filepos:(.*)/)[1]
+ const number = Number(filepos)
+ const index = this.#sections.findIndex(section => section.end > number)
+ const anchor = doc => doc.getElementById(`filepos${filepos}`)
+ return { index, anchor }
+ }
+ splitTOCHref(href) {
+ const filepos = href.match(/filepos:(.*)/)[1]
+ const number = Number(filepos)
+ const index = this.#sections.findIndex(section => section.end > number)
+ return [index, `filepos${filepos}`]
+ }
+ getTOCFragment(doc, id) {
+ return doc.getElementById(id)
+ }
+ isExternal(uri) {
+ return /^(?!blob|filepos)\w+:/i.test(uri)
+ }
+}
+
+// handlers for `kindle:` uris
+const kindleResourceRegex = /kindle:(flow|embed):(\w+)(?:\?mime=(\w+\/[-+.\w]+))?/
+const kindlePosRegex = /kindle:pos:fid:(\w+):off:(\w+)/
+const parseResourceURI = str => {
+ const [resourceType, id, type] = str.match(kindleResourceRegex).slice(1)
+ return { resourceType, id: parseInt(id, 32), type }
+}
+const parsePosURI = str => {
+ const [fid, off] = str.match(kindlePosRegex).slice(1)
+ return { fid: parseInt(fid, 32), off: parseInt(off, 32) }
+}
+const makePosURI = (fid = 0, off = 0) =>
+ `kindle:pos:fid:${fid.toString(32).toUpperCase().padStart(4, '0')
+ }:off:${off.toString(32).toUpperCase().padStart(10, '0')}`
+
+// `kindle:pos:` links are originally links that contain fragments identifiers
+// so there should exist an element with `id` or `name`
+// otherwise try to find one with an `aid` attribute
+const getFragmentSelector = str => {
+ const match = str.match(/\s(id|name|aid)\s*=\s*['"]([^'"]*)['"]/i)
+ if (!match) return
+ const [, attr, value] = match
+ return `[${attr}="${CSS.escape(value)}"]`
+}
+
+// replace asynchronously and sequentially
+const replaceSeries = async (str, regex, f) => {
+ const matches = []
+ str.replace(regex, (...args) => (matches.push(args), null))
+ const results = []
+ for (const args of matches) results.push(await f(...args))
+ return str.replace(regex, () => results.shift())
+}
+
+class KF8 {
+ parser = new DOMParser()
+ #cache = new Map()
+ #fragmentOffsets = new Map()
+ #fragmentSelectors = new Map()
+ #tables = {}
+ #sections
+ #fullRawLength
+ #rawHead = new Uint8Array()
+ #rawTail = new Uint8Array()
+ #lastLoadedHead = -1
+ #lastLoadedTail = -1
+ #checkType = true
+ #type = MIME.XHTML
+ constructor(mobi) {
+ this.mobi = mobi
+ }
+ async init() {
+ const loadRecord = this.mobi.loadRecord.bind(this.mobi)
+ const { kf8 } = this.mobi.headers
+
+ try {
+ const fdstBuffer = await loadRecord(kf8.fdst)
+ const fdst = getStruct(FDST_HEADER, fdstBuffer)
+ if (fdst.magic !== 'FDST') throw new Error('Missing FDST record')
+ const fdstTable = Array.from({ length: fdst.numEntries },
+ (_, i) => 12 + i * 8)
+ .map(offset => [
+ getUint(fdstBuffer.slice(offset, offset + 4)),
+ getUint(fdstBuffer.slice(offset + 4, offset + 8))])
+ this.#tables.fdstTable = fdstTable
+ this.#fullRawLength = fdstTable[fdstTable.length - 1][1]
+ } catch {}
+
+ const skelTable = (await getIndexData(kf8.skel, loadRecord)).table
+ .map(({ name, tagMap }, index) => ({
+ index, name,
+ numFrag: tagMap[1][0],
+ offset: tagMap[6][0],
+ length: tagMap[6][1],
+ }))
+ const fragData = await getIndexData(kf8.frag, loadRecord)
+ const fragTable = fragData.table.map(({ name, tagMap }) => ({
+ insertOffset: parseInt(name),
+ selector: fragData.cncx[tagMap[2][0]],
+ index: tagMap[4][0],
+ offset: tagMap[6][0],
+ length: tagMap[6][1],
+ }))
+ this.#tables.skelTable = skelTable
+ this.#tables.fragTable = fragTable
+
+ this.#sections = skelTable.reduce((arr, skel) => {
+ const last = arr[arr.length - 1]
+ const fragStart = last?.fragEnd ?? 0, fragEnd = fragStart + skel.numFrag
+ const frags = fragTable.slice(fragStart, fragEnd)
+ const length = skel.length + frags.map(f => f.length).reduce((a, b) => a + b)
+ const totalLength = (last?.totalLength ?? 0) + length
+ return arr.concat({ skel, frags, fragEnd, length, totalLength })
+ }, [])
+
+ /*
+ const resources = await this.getResourcesByMagic(['RESC', 'PAGE'])
+ if (resources.RESC) {
+ const buf = await this.mobi.loadRecord(resources.RESC)
+ const str = this.mobi.decode(buf.slice(16)).replace(/\0/g, '')
+ // the RESC record lacks the root `` element
+ // but seem to be otherwise valid XML
+ const index = str.search(/\?>/)
+ const xmlStr = `${str.slice(index)}`
+ const opf = this.parser.parseFromString(xmlStr, MIME.XML)
+ }*/
+
+ // insert cover page for CFI compatibility with KindleUnpack,
+ // which will pretty much always insert a cover page;
+ // it will not be accessible in any way, so just insert a dummy section
+ this.#sections.unshift({ frags: [] })
+
+ this.sections = this.#sections.map((section, index) =>
+ section.frags.length ? ({
+ id: index,
+ load: () => this.loadSection(section),
+ createDocument: () => this.createDocument(section),
+ size: section.length,
+ }) : ({ linear: 'no' }))
+
+ try {
+ const ncx = await this.mobi.getNCX()
+ const map = ({ label, pos, children }) => {
+ const [fid, off] = pos
+ const href = makePosURI(fid, off)
+ const arr = this.#fragmentOffsets.get(fid)
+ if (arr) arr.push(off)
+ else this.#fragmentOffsets.set(fid, [off])
+ return { label: unescapeHTML(label), href, subitems: children?.map(map) }
+ }
+ this.toc = ncx?.map(map)
+ this.landmarks = await this.getGuide()
+ } catch(e) {
+ console.warn(e)
+ }
+
+ const { exth } = this.mobi.headers
+ this.dir = exth.pageProgressionDirection
+ this.rendition = {
+ layout: exth.fixedLayout === 'true' ? 'pre-paginated' : 'reflowable',
+ viewport: Object.fromEntries(exth.originalResolution
+ ?.split('x')?.slice(0, 2)
+ ?.map((x, i) => [i ? 'height' : 'width', x]) ?? []),
+ }
+
+ this.metadata = this.mobi.getMetadata()
+ this.getCover = this.mobi.getCover.bind(this.mobi)
+ return this
+ }
+ // is this really the only way of getting to RESC, PAGE, etc.?
+ async getResourcesByMagic(keys) {
+ const results = {}
+ const start = this.mobi.headers.kf8.resourceStart
+ const end = this.mobi.pdb.numRecords
+ for (let i = start; i < end; i++) {
+ try {
+ const magic = await this.mobi.loadMagic(i)
+ const match = keys.find(key => key === magic)
+ if (match) results[match] = i
+ } catch {}
+ }
+ return results
+ }
+ async getGuide() {
+ const index = this.mobi.headers.kf8.guide
+ if (index < 0xffffffff) {
+ const loadRecord = this.mobi.loadRecord.bind(this.mobi)
+ const { table, cncx } = await getIndexData(index, loadRecord)
+ return table.map(({ name, tagMap }) => ({
+ label: cncx[tagMap[1][0]] ?? '',
+ type: name?.split(/\s/),
+ href: makePosURI(tagMap[6]?.[0] ?? tagMap[3]?.[0]),
+ }))
+ }
+ }
+ async loadResourceBlob(str) {
+ const { resourceType, id, type } = parseResourceURI(str)
+ const raw = resourceType === 'flow' ? await this.loadFlow(id)
+ : await this.mobi.loadResource(id - 1)
+ const result = [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(type)
+ ? await this.replaceResources(this.mobi.decode(raw)) : raw
+ return new Blob([result], { type })
+ }
+ async loadResource(str) {
+ if (this.#cache.has(str)) return this.#cache.get(str)
+ const blob = await this.loadResourceBlob(str)
+ const url = URL.createObjectURL(blob)
+ this.#cache.set(str, url)
+ return url
+ }
+ replaceResources(str) {
+ const regex = new RegExp(kindleResourceRegex, 'g')
+ return replaceSeries(str, regex, this.loadResource.bind(this))
+ }
+ // NOTE: there doesn't seem to be a way to access text randomly?
+ // how to know the decompressed size of the records without decompressing?
+ // 4096 is just the maximum size
+ async loadRaw(start, end) {
+ // here we load either from the front or back until we have reached the
+ // required offsets; at worst you'd have to load half the book at once
+ const distanceHead = end - this.#rawHead.length
+ const distanceEnd = this.#fullRawLength == null ? Infinity
+ : (this.#fullRawLength - this.#rawTail.length) - start
+ // load from the start
+ if (distanceHead < 0 || distanceHead < distanceEnd) {
+ while (this.#rawHead.length < end) {
+ const index = ++this.#lastLoadedHead
+ const data = await this.mobi.loadText(index)
+ this.#rawHead = concatTypedArray(this.#rawHead, data)
+ }
+ return this.#rawHead.slice(start, end)
+ }
+ // load from the end
+ while (this.#fullRawLength - this.#rawTail.length > start) {
+ const index = this.mobi.headers.palmdoc.numTextRecords - 1
+ - (++this.#lastLoadedTail)
+ const data = await this.mobi.loadText(index)
+ this.#rawTail = concatTypedArray(data, this.#rawTail)
+ }
+ const rawTailStart = this.#fullRawLength - this.#rawTail.length
+ return this.#rawTail.slice(start - rawTailStart, end - rawTailStart)
+ }
+ loadFlow(index) {
+ if (index < 0xffffffff)
+ return this.loadRaw(...this.#tables.fdstTable[index])
+ }
+ async loadText(section) {
+ const { skel, frags, length } = section
+ const raw = await this.loadRaw(skel.offset, skel.offset + length)
+ let skeleton = raw.slice(0, skel.length)
+ for (const frag of frags) {
+ const insertOffset = frag.insertOffset - skel.offset
+ const offset = skel.length + frag.offset
+ const fragRaw = raw.slice(offset, offset + frag.length)
+ skeleton = concatTypedArray3(
+ skeleton.slice(0, insertOffset), fragRaw,
+ skeleton.slice(insertOffset))
+
+ const offsets = this.#fragmentOffsets.get(frag.index)
+ if (offsets) for (const offset of offsets) {
+ const str = this.mobi.decode(fragRaw).slice(offset)
+ const selector = getFragmentSelector(str)
+ this.#setFragmentSelector(frag.index, offset, selector)
+ }
+ }
+ return this.mobi.decode(skeleton)
+ }
+ async createDocument(section) {
+ const str = await this.loadText(section)
+ return this.parser.parseFromString(str, this.#type)
+ }
+ async loadSection(section) {
+ if (this.#cache.has(section)) return this.#cache.get(section)
+ const str = await this.loadText(section)
+
+ // by default, type is XHTML; change to HTML if it's not valid XHTML
+ if (this.#checkType && this.parser
+ .parseFromString(str, this.#type)
+ .querySelector('parsererror')) this.#type = MIME.HTML
+ // let's just check it once for now
+ if (this.#checkType) this.#checkType = false
+
+ const replaced = await this.replaceResources(str)
+ const url = URL.createObjectURL(new Blob([replaced], { type: this.#type }))
+ this.#cache.set(section, url)
+ return url
+ }
+ getIndexByFID(fid) {
+ return this.#sections.findIndex(section =>
+ section.frags.some(frag => frag.index === fid))
+ }
+ #setFragmentSelector(id, offset, selector) {
+ const map = this.#fragmentSelectors.get(id)
+ if (map) map.set(offset, selector)
+ else {
+ const map = new Map()
+ this.#fragmentSelectors.set(id, map)
+ map.set(offset, selector)
+ }
+ }
+ async resolveHref(href) {
+ const { fid, off } = parsePosURI(href)
+ const index = this.getIndexByFID(fid)
+ if (index < 0) return
+
+ const saved = this.#fragmentSelectors.get(fid)?.get(off)
+ if (saved) return { index, anchor: doc => doc.querySelector(saved) }
+
+ const { skel, frags } = this.#sections[index]
+ const frag = frags.find(frag => frag.index === fid)
+ const offset = skel.offset + skel.length + frag.offset
+ const fragRaw = await this.loadRaw(offset, offset + frag.length)
+ const str = this.mobi.decode(fragRaw).slice(off)
+ const selector = getFragmentSelector(str)
+ this.#setFragmentSelector(fid, off, selector)
+ const anchor = doc => doc.querySelector(selector)
+ return { index, anchor }
+ }
+ splitTOCHref(href) {
+ const pos = parsePosURI(href)
+ const index = this.getIndexByFID(pos.fid)
+ return [index, pos]
+ }
+ getTOCFragment(doc, { fid, off }) {
+ const selector = this.#fragmentSelectors.get(fid)?.get(off)
+ return doc.querySelector(selector)
+ }
+ isExternal(uri) {
+ return /^(?!blob|kindle)\w+:/i.test(uri)
+ }
+}
diff --git a/overlayer.js b/overlayer.js
new file mode 100644
index 0000000..a790e4b
--- /dev/null
+++ b/overlayer.js
@@ -0,0 +1,103 @@
+const createSVGElement = tag =>
+ document.createElementNS('http://www.w3.org/2000/svg', tag)
+
+export class Overlayer {
+ #svg = createSVGElement('svg')
+ #map = new Map()
+ constructor() {
+ Object.assign(this.#svg.style, {
+ position: 'absolute', top: '0', left: '0',
+ width: '100%', height: '100%',
+ pointerEvents: 'none',
+ })
+ const darkMode = matchMedia('(prefers-color-scheme: dark)')
+ const setBlendMode = () => this.#svg.style.mixBlendMode =
+ darkMode.matches ? 'normal' : 'multiply'
+ darkMode.addEventListener('change', setBlendMode)
+ setBlendMode()
+ }
+ get element() {
+ return this.#svg
+ }
+ add(key, range, draw, options) {
+ if (this.#map.has(key)) this.remove(key)
+ if (typeof range === 'function') range = range(this.#svg.getRootNode())
+ const rects = range.getClientRects()
+ const element = draw(rects, options)
+ this.#svg.append(element)
+ this.#map.set(key, { range, draw, options, element, rects })
+ }
+ remove(key) {
+ if (!this.#map.has(key)) return
+ this.#svg.removeChild(this.#map.get(key).element)
+ this.#map.delete(key)
+ }
+ redraw() {
+ for (const obj of this.#map.values()) {
+ const { range, draw, options, element } = obj
+ this.#svg.removeChild(element)
+ const rects = range.getClientRects()
+ const el = draw(rects, options)
+ this.#svg.append(el)
+ obj.element = el
+ obj.rects = rects
+ }
+ }
+ hitTest({ x, y }) {
+ const arr = Array.from(this.#map.entries())
+ // loop in reverse to hit more recently added items first
+ for (let i = arr.length - 1; i >= 0; i--) {
+ const [key, obj] = arr[i]
+ for (const { left, top, right, bottom } of obj.rects)
+ if (top <= y && left <= x && bottom > y && right > x)
+ return [key, obj.range]
+ }
+ return []
+ }
+ static underline(rects, options = {}) {
+ // TODO: in vertical-rl, the bōsen (sideline) should be on the right
+ const { color = 'red', width: strokeWidth = 2 } = options
+ const g = createSVGElement('g')
+ g.setAttribute('fill', color)
+ for (const { left, bottom, width } of rects) {
+ const el = createSVGElement('rect')
+ el.setAttribute('x', left)
+ el.setAttribute('y', bottom - strokeWidth)
+ el.setAttribute('height', strokeWidth)
+ el.setAttribute('width', width)
+ g.append(el)
+ }
+ return g
+ }
+ static highlight(rects, options = {}) {
+ const { color = 'red' } = options
+ const g = createSVGElement('g')
+ g.setAttribute('fill', color)
+ g.setAttribute('fill-opacity', .3)
+ for (const { left, top, height, width } of rects) {
+ const el = createSVGElement('rect')
+ el.setAttribute('x', left)
+ el.setAttribute('y', top)
+ el.setAttribute('height', height)
+ el.setAttribute('width', width)
+ g.append(el)
+ }
+ return g
+ }
+ // make an exact copy of an image in the overlay
+ // one can then apply filters to the entire element, without affecting them;
+ // it's a bit silly and probably better to just invert images twice
+ // (though the color will be off in that case if you do heu-rotate)
+ static copyImage([rect], options = {}) {
+ const { src } = options
+ const image = createSVGElement('image')
+ const { left, top, height, width } = rect
+ image.setAttribute('href', src)
+ image.setAttribute('x', left)
+ image.setAttribute('y', top)
+ image.setAttribute('height', height)
+ image.setAttribute('width', width)
+ return image
+ }
+}
+
diff --git a/paginator.js b/paginator.js
new file mode 100644
index 0000000..e1cb235
--- /dev/null
+++ b/paginator.js
@@ -0,0 +1,645 @@
+const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
+
+const debounce = (f, wait, immediate) => {
+ let timeout
+ return (...args) => {
+ const later = () => {
+ timeout = null
+ if (!immediate) f(...args)
+ }
+ const callNow = immediate && !timeout
+ if (timeout) clearTimeout(timeout)
+ timeout = setTimeout(later, wait)
+ if (callNow) f(...args)
+ }
+}
+
+// collapsed range doesn't return client rects sometimes (or always?)
+// try make get a non-collapsed range or element
+const uncollapse = range => {
+ if (!range?.collapsed) return range
+ const { endOffset, endContainer } = range
+ if (endContainer.nodeType === 1) return endContainer
+ if (endOffset + 1 < endContainer.length) range.setEnd(endContainer, endOffset + 1)
+ else if (endOffset > 1) range.setStart(endContainer, endOffset - 1)
+ else return endContainer.parentNode
+ return range
+}
+
+const makeRange = (doc, node, start, end = start) => {
+ const range = doc.createRange()
+ range.setStart(node, start)
+ range.setEnd(node, end)
+ return range
+}
+
+// use binary search to find an offset value in a text node
+const bisectNode = (doc, node, cb, start = 0, end = node.nodeValue.length) => {
+ if (end - start === 1) {
+ const result = cb(makeRange(doc, node, start), makeRange(doc, node, end))
+ return result < 0 ? start : end
+ }
+ const mid = Math.floor(start + (end - start) / 2)
+ const result = cb(makeRange(doc, node, start, mid), makeRange(doc, node, mid, end))
+ return result < 0 ? bisectNode(doc, node, cb, start, mid)
+ : result > 0 ? bisectNode(doc, node, cb, mid, end) : mid
+}
+
+const { SHOW_ELEMENT, SHOW_TEXT, SHOW_CDATA_SECTION,
+ FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter
+
+const filter = SHOW_ELEMENT | SHOW_TEXT | SHOW_CDATA_SECTION
+
+const getVisibleRange = (doc, start, end, mapRect) => {
+ // first get all visible nodes
+ const acceptNode = node => {
+ const name = node.localName?.toLowerCase()
+ // ignore all scripts, styles, and their children
+ if (name === 'script' || name === 'style') return FILTER_REJECT
+ if (node.nodeType === 1) {
+ const { left, right } = mapRect(node.getBoundingClientRect())
+ // no need to check child nodes if it's completely out of view
+ if (right < start || left > end) return FILTER_REJECT
+ // elements must be completely in view to be considered visible
+ // because you can't specify offsets for elements
+ if (left >= start && right <= end) return FILTER_ACCEPT
+ // TODO: it should probably allow elements that do not contain text
+ // because they can exceed the whole viewport in both directions
+ // especially in scrolled mode
+ } else {
+ // ignore empty text nodes
+ if (!node.nodeValue?.trim()) return FILTER_SKIP
+ // create range to get rect
+ const range = doc.createRange()
+ range.selectNodeContents(node)
+ const { left, right } = mapRect(range.getBoundingClientRect())
+ // it's visible if any part of it is in view
+ if (right >= start && left <= end) return FILTER_ACCEPT
+ }
+ return FILTER_SKIP
+ }
+ const walker = doc.createTreeWalker(doc.body, filter, { acceptNode })
+ const nodes = []
+ for (let node = walker.nextNode(); node; node = walker.nextNode())
+ nodes.push(node)
+
+ // we're only interested in the first and last visible nodes
+ const from = nodes[0] ?? doc.body
+ const to = nodes[nodes.length - 1] ?? from
+
+ // find the offset at which visibility changes
+ const startOffset = from.nodeType === 1 ? 0
+ : bisectNode(doc, from, (a, b) => {
+ const p = mapRect(a.getBoundingClientRect())
+ const q = mapRect(b.getBoundingClientRect())
+ if (p.right < start && q.left > start) return 0
+ return q.left > start ? -1 : 1
+ })
+ const endOffset = to.nodeType === 1 ? 0
+ : bisectNode(doc, to, (a, b) => {
+ const p = mapRect(a.getBoundingClientRect())
+ const q = mapRect(b.getBoundingClientRect())
+ if (p.right < end && q.left > end) return 0
+ return q.left > end ? -1 : 1
+ })
+
+ const range = doc.createRange()
+ range.setStart(from, startOffset)
+ range.setEnd(to, endOffset)
+ return range
+}
+
+const getDirection = doc => {
+ const { defaultView } = doc
+ const { writingMode, direction } = defaultView.getComputedStyle(doc.body)
+ const vertical = writingMode === 'vertical-rl'
+ || writingMode === 'vertical-lr'
+ const rtl = doc.body.dir === 'rtl'
+ || direction === 'rtl'
+ || doc.documentElement.dir === 'rtl'
+ return { vertical, rtl }
+}
+
+class View {
+ #element = document.createElement('div')
+ #iframe = document.createElement('iframe')
+ #contentRange = document.createRange()
+ #overlayers = {}
+ #vertical = false
+ #rtl = false
+ #column = true
+ #size
+ #layout = {}
+ constructor({ container }) {
+ this.container = container
+ this.#iframe.classList.add('filter')
+ this.#element.append(this.#iframe)
+ Object.assign(this.#element.style, {
+ position: 'relative',
+ overflow: 'hidden',
+ flex: '0 0 auto',
+ width: '100%', height: '100%',
+ })
+ Object.assign(this.#iframe.style, {
+ overflow: 'hidden',
+ border: '0',
+ display: 'none',
+ width: '100%', height: '100%',
+ })
+ // `allow-scripts` is needed for events because of WebKit bug
+ // https://bugs.webkit.org/show_bug.cgi?id=218086
+ this.#iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
+ this.#iframe.setAttribute('scrolling', 'no')
+ }
+ get element() {
+ return this.#element
+ }
+ get document() {
+ return this.#iframe.contentDocument
+ }
+ async load(src, afterLoad, beforeRender) {
+ if (typeof src !== 'string') throw new Error(`${src} is not string`)
+ return new Promise(resolve => {
+ this.#iframe.addEventListener('load', () => {
+ const doc = this.document
+ afterLoad?.(doc)
+
+ const { vertical, rtl } = getDirection(doc)
+ this.#vertical = vertical
+ this.#rtl = rtl
+
+ this.#contentRange.selectNodeContents(doc.body)
+ this.render(beforeRender?.({ vertical, rtl }))
+ new ResizeObserver(() => this.expand()).observe(doc.body)
+
+ resolve()
+ }, { once: true })
+ this.#iframe.src = src
+ })
+ }
+ render(layout) {
+ this.#column = layout.flow !== 'scrolled'
+ this.#layout = layout
+ if (this.#column) this.columnize(layout)
+ else this.scrolled(layout)
+ }
+ scrolled({ gap, columnWidth }) {
+ const vertical = this.#vertical
+ this.#iframe.style.display = 'block'
+ const doc = this.document
+ Object.assign(doc.documentElement.style, {
+ boxSizing: 'border-box',
+ padding: vertical ? `${gap}px 0` : `0 ${gap}px`,
+ columnWidth: 'auto',
+ height: 'auto',
+ width: 'auto',
+ })
+ Object.assign(doc.body.style, {
+ [vertical ? 'maxHeight' : 'maxWidth']: `${columnWidth}px`,
+ margin: 'auto',
+ })
+ this.setImageSize()
+ this.expand()
+ }
+ columnize({ width, height, margin, gap, columnWidth }) {
+ const vertical = this.#vertical
+ this.#size = vertical ? height : width
+ this.#iframe.style.display = 'block'
+
+ const doc = this.document
+ const gapPadding = `${gap / 2}px`
+ const marginPadding = `${margin}px`
+ Object.assign(doc.documentElement.style, {
+ boxSizing: 'border-box',
+ columnWidth: `${columnWidth}px`,
+ columnGap: `${gap}px`,
+ columnFill: 'auto',
+ ...(vertical
+ ? { width: `${width}px` }
+ : { height: `${height}px` }),
+ padding: (vertical
+ ? [gapPadding, marginPadding]
+ : [marginPadding, gapPadding]).join(' '),
+ overflow: 'hidden',
+ // force wrap long words
+ overflowWrap: 'anywhere',
+ // reset some potentially problematic props
+ position: 'static', border: '0', margin: '0',
+ maxHeight: 'none', maxWidth: 'none',
+ minHeight: 'none', minWidth: 'none',
+ })
+ Object.assign(doc.body.style, {
+ maxHeight: 'none',
+ maxWidth: 'none',
+ margin: '0',
+ })
+ this.setImageSize()
+ this.expand()
+ }
+ setImageSize() {
+ const { width, height, margin } = this.#layout
+ const vertical = this.#vertical
+ const doc = this.document
+ for (const el of doc.body.querySelectorAll('img, svg, video')) {
+ // preserve max size if they are already set
+ const { maxHeight, maxWidth } = doc.defaultView.getComputedStyle(el)
+ Object.assign(el.style, {
+ maxHeight: vertical
+ ? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%')
+ : `${height - margin * 2}px`,
+ maxWidth: vertical
+ ? `${width - margin * 2}px`
+ : (maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%'),
+ objectFit: 'contain',
+ pageBreakInside: 'avoid',
+ breakInside: 'avoid',
+ boxSizing: 'border-box',
+ })
+ }
+ }
+ expand() {
+ if (this.#column) {
+ const side = this.#vertical ? 'height' : 'width'
+ const otherSide = this.#vertical ? 'width' : 'height'
+ const contentSize = this.#contentRange.getBoundingClientRect()[side]
+ const pageCount = Math.ceil(contentSize / this.#size)
+ const expandedSize = pageCount * this.#size
+ this.#element.style.padding = '0'
+ this.#iframe.style[side] = `${expandedSize}px`
+ this.#element.style[side] = `${expandedSize}px`
+ this.#iframe.style[otherSide] = '100%'
+ this.#element.style[otherSide] = '100%'
+ for (const overlayer of Object.values(this.#overlayers)) {
+ overlayer.element.style.margin = '0'
+ overlayer.element.style[side] = `${expandedSize}px`
+ overlayer.redraw()
+ }
+ } else {
+ const side = this.#vertical ? 'width' : 'height'
+ const otherSide = this.#vertical ? 'height' : 'width'
+ const doc = this.document
+ const contentSize = doc?.documentElement?.getBoundingClientRect()?.[side]
+ const expandedSize = contentSize
+ const { margin } = this.#layout
+ const padding = this.#vertical ? `0 ${margin}px` : `${margin}px 0`
+ this.#element.style.padding = padding
+ this.#iframe.style[side] = `${expandedSize}px`
+ this.#element.style[side] = `${expandedSize}px`
+ this.#iframe.style[otherSide] = '100%'
+ this.#element.style[otherSide] = '100%'
+ for (const overlayer of Object.values(this.#overlayers)) {
+ overlayer.element.style.margin = padding
+ overlayer.element.style[side] = `${expandedSize}px`
+ overlayer.redraw()
+ }
+ }
+ }
+ set overlayers(overlayers) {
+ this.#overlayers = overlayers
+ for (const overlayer of Object.values(overlayers))
+ this.#element.append(overlayer.element)
+ }
+ get overlayers() {
+ return this.#overlayers
+ }
+}
+
+// NOTE: everything here assumes the so-called "negative scroll type" for RTL
+export class Paginator {
+ #element = document.createElement('div')
+ #view
+ #vertical = false
+ #rtl = false
+ #index = -1
+ #anchor = 0 // anchor view to a fraction (0-1), Range, or Element
+ #locked = false // while true, prevent any further navigation
+ #styleMap = new WeakMap()
+ layout = {
+ margin: 48,
+ gap: 40,
+ maxColumnWidth: 700,
+ }
+ constructor({ book, onLoad, onRelocated, createOverlayers }) {
+ this.sections = book.sections
+ this.onLoad = onLoad
+ this.onRelocated = onRelocated
+ this.createOverlayers = createOverlayers
+ Object.assign(this.#element.style, {
+ display: 'flex',
+ flexWrap: 'nowrap',
+ overflow: 'hidden',
+ position: 'absolute',
+ })
+ new ResizeObserver(() => this.render()).observe(this.#element)
+ this.#element.addEventListener('scroll', debounce(() => {
+ if (this.scrolled) this.#afterScroll('scroll')
+ }, 250))
+ }
+ get element() {
+ return this.#element
+ }
+ #createView() {
+ if (this.#view) this.#element.removeChild(this.#view.element)
+ this.#view = new View({ container: this.#element })
+ this.#element.append(this.#view.element)
+ return this.#view
+ }
+ #beforeRender({ vertical, rtl }) {
+ this.#vertical = vertical
+ this.#rtl = rtl
+ const { flow, margin, gap, maxColumnWidth } = this.layout
+ if (flow === 'scrolled') {
+ // FIXME: vertical-rl only, not -lr
+ this.#element.setAttribute('dir', vertical ? 'rtl' : 'ltr')
+ Object.assign(this.#element.style, {
+ width: '100%',
+ height: '100%',
+ margin: '0',
+ overflow: 'scroll',
+ })
+ const columnWidth = this.layout.maxColumnWidth
+ return { flow, margin, gap, columnWidth }
+ }
+ const { width, height } = this.#element.getBoundingClientRect()
+ const size = vertical ? height : width
+ const divisor = Math.ceil(size / maxColumnWidth)
+ const columnWidth = (size / divisor) - gap
+ this.#element.setAttribute('dir', rtl ? 'rtl' : 'ltr')
+ Object.assign(this.#element.style, {
+ width: vertical ? '100%' : `calc(100% - ${gap}px)`,
+ height: vertical ? `calc(100% - ${margin}px)` : '100%',
+ marginLeft: vertical ? '0' : `${gap / 2}px`,
+ marginTop: vertical ? `${margin / 2}px` : '0',
+ overflow: 'hidden',
+ })
+ return { height, width, margin, gap, columnWidth }
+ }
+ render() {
+ if (!this.#view) return
+ this.#view.render(this.#beforeRender({
+ vertical: this.#vertical,
+ rtl: this.#rtl,
+ }))
+ this.#scrollToAnchor()
+ }
+ get scrolled() {
+ return this.layout.flow === 'scrolled'
+ }
+ get scrollProp() {
+ const { scrolled } = this
+ return this.#vertical ? (scrolled ? 'scrollLeft' : 'scrollTop')
+ : scrolled ? 'scrollTop' : 'scrollLeft'
+ }
+ get sideProp() {
+ const { scrolled } = this
+ return this.#vertical ? (scrolled ? 'width' : 'height')
+ : scrolled ? 'height' : 'width'
+ }
+ get size() {
+ return this.#element.getBoundingClientRect()[this.sideProp]
+ }
+ get viewSize() {
+ return this.#view.element.getBoundingClientRect()[this.sideProp]
+ }
+ get start() {
+ return Math.abs(this.#element[this.scrollProp])
+ }
+ get end() {
+ return this.start + this.size
+ }
+ get page() {
+ return Math.floor(((this.start + this.end) / 2) / this.size)
+ }
+ get pages() {
+ return this.viewSize / this.size
+ }
+ // allows one to process rects as if they were LTR and horizontal
+ #getRectMapper() {
+ if (this.scrolled) {
+ const size = this.viewSize
+ const margin = this.layout.margin
+ return this.#vertical
+ ? ({ left, right }) =>
+ ({ left: size - right - margin, right: size - left - margin })
+ : ({ top, bottom }) => ({ left: top + margin, right: bottom + margin })
+ }
+ const pxSize = this.pages * this.size
+ return this.#rtl
+ ? ({ left, right }) =>
+ ({ left: pxSize - right, right: pxSize - left })
+ : this.#vertical
+ ? ({ top, bottom }) => ({ left: top, right: bottom })
+ : f => f
+ }
+ async #scrollToRect(rect, reason) {
+ if (this.scrolled) {
+ const offset = this.#getRectMapper()(rect).left
+ return this.#scrollTo(offset, reason)
+ }
+ const offset = this.#getRectMapper()(rect).left
+ + this.layout.margin / 2
+ return this.#scrollToPage(Math.floor(offset / this.size), reason)
+ }
+ async #scrollTo(offset, reason) {
+ const element = this.#element
+ const { scrollProp } = this
+ if (element[scrollProp] === offset) {
+ this.#afterScroll(reason)
+ return
+ }
+ // FIXME: vertical-rl only, not -lr
+ if (this.scrolled && this.#vertical) offset = -offset
+ element[scrollProp] = offset
+ this.#afterScroll(reason)
+ /*return new Promise((resolve, reject) => {
+ try {
+ const onScroll = () => {
+ if (element[scrollProp] - offset > 2) return
+ element.removeEventListener('scroll', onScroll)
+ resolve()
+ this.#afterScroll(reason)
+ }
+ element.addEventListener('scroll', onScroll)
+ if (this.scrolled) {
+ const coord = scrollProp === 'scrollLeft' ? 'left' : 'top'
+ element.scrollTo({ [coord]: offset, behavior: 'smooth' })
+ }
+ element[scrollProp] = offset
+ } catch (e) {
+ reject(e)
+ }
+ })*/
+ }
+ async #scrollToPage(page, reason) {
+ const offset = this.size * (this.#rtl ? -page : page)
+ return this.#scrollTo(offset, reason)
+ }
+ async #scrollToAnchor(select) {
+ const rect = uncollapse(this.#anchor).getBoundingClientRect?.()
+ // if anchor is an element or a range
+ if (rect) {
+ await this.#scrollToRect(rect, 'anchor')
+ if (select) this.#selectAnchor()
+ return
+ }
+ // if anchor is a fraction
+ if (this.scrolled) {
+ await this.#scrollTo(this.#anchor * this.viewSize, 'anchor')
+ return
+ }
+ const { pages } = this
+ if (!pages) return
+ const newPage = Math.round(this.#anchor * (pages - 1))
+ await this.#scrollToPage(newPage, 'anchor')
+ }
+ #selectAnchor() {
+ const { defaultView } = this.#view.document
+ if (this.#anchor instanceof defaultView.Range) {
+ const sel = defaultView.getSelection()
+ sel.removeAllRanges()
+ sel.addRange(this.#anchor)
+ }
+ }
+ #getVisibleRange() {
+ return getVisibleRange(this.#view.document,
+ this.start, this.end, this.#getRectMapper(), this.scrolled)
+ }
+ #afterScroll(reason) {
+ const range = this.#getVisibleRange()
+ // don't set new anchor if relocation was to scroll to anchor
+ if (reason !== 'anchor') this.#anchor = range
+ const index = this.#index
+ if (this.scrolled)
+ this.onRelocated?.(range, index, this.end / this.viewSize)
+ else if (this.pages > 0)
+ this.onRelocated?.(range, index, (this.page + 1) / this.pages)
+ }
+ async #display(promise) {
+ const { index, src, anchor, onLoad, select } = await promise
+ this.#index = index
+ if (src) {
+ const view = this.#createView()
+ const afterLoad = doc => {
+ if (doc.head) {
+ const $style = doc.createElement('style')
+ doc.head.append($style)
+ this.#styleMap.set(doc, $style)
+ }
+ onLoad?.(doc, index)
+ }
+ const beforeRender = this.#beforeRender.bind(this)
+ await view.load(src, afterLoad, beforeRender)
+ const overlayers = this.createOverlayers?.(view.document, index)
+ if (overlayers) view.overlayers = overlayers
+ this.#view = view
+ }
+ this.#anchor = (typeof anchor === 'function'
+ ? anchor(this.#view.document) : anchor) ?? 0
+ await this.#scrollToAnchor(select)
+ }
+ #canScrollToPage(page) {
+ return page > -1 && page < this.pages
+ }
+ scrollPrev() {
+ if (!this.#view) return null
+ if (this.scrolled) {
+ if (this.start > 0)
+ return this.#scrollTo(Math.max(0, this.start - this.size))
+ else return null
+ }
+ const page = this.page - 1
+ if (this.#canScrollToPage(page)) return this.#scrollToPage(page)
+ return null
+ }
+ scrollNext() {
+ if (!this.#view) return null
+ if (this.scrolled) {
+ if (this.viewSize - this.end > 2)
+ return this.#scrollTo(Math.min(this.viewSize, this.end))
+ else return null
+ }
+ const page = this.page + 1
+ if (this.#canScrollToPage(page)) return this.#scrollToPage(page)
+ return null
+ }
+ #canGoToIndex(index) {
+ return index >= 0 && index <= this.sections.length - 1
+ }
+ async #goTo(tryScroll, target, lock) {
+ if (this.#locked) return
+ if (lock) this.#locked = true
+ const scroll = tryScroll?.()
+ if (scroll) await scroll
+ else {
+ const { index, anchor, select } = await target
+ if (!this.#canGoToIndex(index)) {
+ this.#locked = false
+ return null
+ }
+ if (index === this.#index) await this.#display({ index, anchor, select })
+ else {
+ const oldIndex = this.#index
+ const onLoad = (...args) => {
+ this.sections[oldIndex]?.unload?.()
+ this.onLoad?.(...args)
+ }
+ await this.#display(Promise.resolve(this.sections[index].load())
+ .then(src => ({ index, src, anchor, onLoad, select }))
+ .catch(e => {
+ console.warn(e)
+ console.warn(new Error(`Failed to load section ${index}`))
+ return {}
+ }))
+ }
+ }
+ if (lock) {
+ await wait(100) // throttle by 100ms
+ this.#locked = false
+ }
+ }
+ async goTo(target) {
+ return this.#goTo(null, target)
+ }
+ #adjacentIndex(dir) {
+ for (let index = this.#index + dir; this.#canGoToIndex(index); index += dir)
+ if (this.sections[index]?.linear !== 'no') return index
+ }
+ prev() {
+ const index = this.#adjacentIndex(-1)
+ return this.#goTo(() => this.scrollPrev(), { index, anchor: () => 1 }, true)
+ }
+ next() {
+ const index = this.#adjacentIndex(1)
+ return this.#goTo(() => this.scrollNext(), { index }, true)
+ }
+ prevSection() {
+ return this.goTo({ index: this.#adjacentIndex(-1) })
+ }
+ nextSection() {
+ return this.goTo({ index: this.#adjacentIndex(1) })
+ }
+ firstSection() {
+ const index = this.sections.findIndex(section => section.linear !== 'no')
+ return this.goTo({ index })
+ }
+ lastSection() {
+ const index = this.sections.findLastIndex(section => section.linear !== 'no')
+ return this.goTo({ index })
+ }
+ getOverlayers() {
+ if (!this.#view) return []
+ return [{
+ index: this.#index,
+ overlayers: this.#view.overlayers,
+ document: this.#view.document,
+ }]
+ }
+ setStyle(style) {
+ const $style = this.#styleMap.get(this.#view?.document)
+ if ($style) $style.textContent = style
+ }
+ async #setAnchor(anchor, select) {
+ this.#anchor = anchor
+ await this.#scrollToAnchor(select)
+ }
+}
diff --git a/progress.js b/progress.js
new file mode 100644
index 0000000..c1e07c7
--- /dev/null
+++ b/progress.js
@@ -0,0 +1,107 @@
+// 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 {
+ constructor({ toc, ids, splitHref, getFragment }) {
+ assignIDs(toc)
+ const items = flatten(toc)
+ const grouped = new Map()
+ for (const [i, item] of items.entries()) {
+ const [id, fragment] = 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) {
+ 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 SectionProgress {
+ constructor(sections, sizePerLoc, sizePerTimeUnit) {
+ this.sizes = sections.map(s => s.linear === 'no' ? 0 : s.size)
+ this.sizePerLoc = sizePerLoc
+ this.sizePerTimeUnit = sizePerTimeUnit
+ this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0)
+ }
+ // get progress given index of and fractions within a section
+ getProgress(index, fractionInSection) {
+ 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 remainingTotal = sizeTotal - size
+ const remainingSection = (1 - fractionInSection) * sizeInSection
+ return {
+ fraction: size / sizeTotal,
+ section: {
+ current: index,
+ total: sizes.length,
+ },
+ location: {
+ current: Math.floor(size / 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) {
+ const { sizes, sizeTotal } = this
+ const target = fraction * sizeTotal
+ let index = -1
+ let fractionInSection = 0
+ let sum = 0
+ for (const [i, size] of sizes.entries()) {
+ const newSum = sum + size
+ if (newSum > target) {
+ index = i
+ fractionInSection = (target - sum) / size
+ break
+ }
+ sum = newSum
+ }
+ return [index, fractionInSection]
+ }
+}
diff --git a/search.js b/search.js
new file mode 100644
index 0000000..b2e91ee
--- /dev/null
+++ b/search.js
@@ -0,0 +1,114 @@
+// length for context in excerpts
+const CONTEXT_LENGTH = 50
+
+const normalizeWhitespace = str => str.replace(/\s+/g, ' ')
+
+const makeExcerpt = (strs, { startIndex, startOffset, endIndex, endOffset }) => {
+ const start = strs[startIndex]
+ const end = strs[endIndex]
+ const match = start === end
+ ? start.slice(startOffset, endOffset)
+ : start.slice(startOffset)
+ + strs.slice(start + 1, end).join('')
+ + end.slice(0, endOffset)
+ const trimmedStart = normalizeWhitespace(start.slice(0, startOffset)).trimStart()
+ const trimmedEnd = normalizeWhitespace(end.slice(endOffset)).trimEnd()
+ const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…'
+ const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…'
+ const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}`
+ const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}`
+ return { pre, match, post }
+}
+
+// TODO: maybe use this for exact matches as it would be faster
+/*
+export const simpleSearch = function* (strs, query, locales = 'en') {
+ const haystack = strs.join('')
+ const lowerHaystack = haystack.toLocaleLowerCase(locales)
+ const needle = query.toLocaleLowerCase(locales)
+ const needleLength = needle.length
+ let index = -1
+ do {
+ index = lowerHaystack.indexOf(needle, index + 1)
+ if (index > -1) {
+ const end = index + needleLength
+ // TODO
+ }
+ } while (index > -1)
+}
+*/
+
+const segmenterSearch = function* (strs, query, options = {}) {
+ const { locales = 'en', granularity = 'word', sensitivity = 'base' } = options
+ let segmenter, collator
+ try {
+ segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity })
+ collator = new Intl.Collator(locales, { sensitivity })
+ } catch (e) {
+ console.warn(e)
+ segmenter = new Intl.Segmenter('en', { usage: 'search', granularity })
+ collator = new Intl.Collator('en', { sensitivity })
+ }
+ const queryLength = Array.from(segmenter.segment(query)).length
+
+ const substrArr = []
+ let strIndex = 0
+ let segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
+ main: while (strIndex < strs.length) {
+ while (substrArr.length < queryLength) {
+ const { done, value } = segments.next()
+ if (done) {
+ // the current string is exhausted
+ // move on to the next string
+ strIndex++
+ if (strIndex < strs.length) {
+ segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
+ continue
+ } else break main
+ }
+ const { index, segment } = value
+ // ignore formatting characters
+ if (!/[^\p{Format}]/u.test(segment)) continue
+ // normalize whitespace
+ if (/\s/u.test(segment)) {
+ if (!/\s/u.test(substrArr[substrArr.length - 1]?.segment))
+ substrArr.push({ strIndex, index, segment: ' ' })
+ continue
+ }
+ value.strIndex = strIndex
+ substrArr.push(value)
+ }
+ const substr = substrArr.map(x => x.segment).join('')
+ if (collator.compare(query, substr) === 0) {
+ const endIndex = strIndex
+ const lastSeg = substrArr[substrArr.length - 1]
+ const endOffset = lastSeg.index + lastSeg.segment.length
+ const startIndex = substrArr[0].strIndex
+ const startOffset = substrArr[0].index
+ const range = { startIndex, startOffset, endIndex, endOffset }
+ yield { range, excerpt: makeExcerpt(strs, range) }
+ }
+ substrArr.shift()
+ }
+}
+
+export const searchMatcher = (textWalker, opts) => {
+ const { defalutLocale, matchCase, matchDiacritics, matchWholeWords } = opts
+ return function* (doc, query) {
+ const iter = textWalker(doc, function* (strs, makeRange) {
+ for (const result of segmenterSearch(strs, query, {
+ locales: doc.body.lang || doc.documentElement.lang || defalutLocale || 'en',
+ granularity: matchWholeWords ? 'word' : 'grapheme',
+ sensitivity: matchDiacritics && matchCase ? 'variant'
+ : matchDiacritics && !matchCase ? 'accent'
+ : !matchDiacritics && matchCase ? 'case'
+ : 'base',
+ })) {
+ const { startIndex, startOffset, endIndex, endOffset } = result.range
+ result.range = makeRange(startIndex, startOffset, endIndex, endOffset)
+ yield result
+ }
+ })
+ for (const result of iter) yield result
+ }
+}