From 82142f89609b820beefed2b26834a73a12d5f76f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 16 Apr 2026 20:04:05 -0400 Subject: [PATCH] style: apply code formatting to core files - Format comic-book.js, epub.js, and view.js with consistent style - No functional changes, formatting only --- comic-book.js | 102 ++- epub.js | 2128 ++++++++++++++++++++++++++----------------------- view.js | 1158 ++++++++++++++------------- 3 files changed, 1821 insertions(+), 1567 deletions(-) diff --git a/comic-book.js b/comic-book.js index cf41d11..d6c9fbf 100644 --- a/comic-book.js +++ b/comic-book.js @@ -1,45 +1,63 @@ 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 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', '.jxl', '.avif'] - const files = entries - .map(entry => entry.filename) - .filter(name => exts.some(ext => name.endsWith(ext))) - .sort(new Intl.Collator([], { numeric: true }).compare) - if (!files.length) throw new Error('No supported image files in archive') + const exts = [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + ".webp", + ".svg", + ".jxl", + ".avif", + ]; + const files = entries + .map((entry) => entry.filename) + .filter((name) => exts.some((ext) => name.endsWith(ext))) + .sort(new Intl.Collator([], { numeric: true }).compare); + if (!files.length) throw new Error("No supported image files in archive"); - 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 - book.destroy = () => { - for (const arr of urls.values()) - for (const url of arr) URL.revokeObjectURL(url) - } - return book -} + 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; + book.destroy = () => { + for (const arr of urls.values()) + for (const url of arr) URL.revokeObjectURL(url); + }; + return book; +}; diff --git a/epub.js b/epub.js index e8bd703..feb3cea 100644 --- a/epub.js +++ b/epub.js @@ -1,1083 +1,1247 @@ -import * as CFI from './epubcfi.js' +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', - SMIL: 'http://www.w3.org/ns/SMIL', -} + 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", + SMIL: "http://www.w3.org/ns/SMIL", +}; 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)/, -} + 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)/, +}; // https://www.w3.org/TR/epub-33/#sec-reserved-prefixes const PREFIX = { - a11y: 'http://www.idpf.org/epub/vocab/package/a11y/#', - dcterms: 'http://purl.org/dc/terms/', - marc: 'http://id.loc.gov/vocabulary/', - media: 'http://www.idpf.org/epub/vocab/overlays/#', - onix: 'http://www.editeur.org/ONIX/book/codelists/current.html#', - rendition: 'http://www.idpf.org/vocab/rendition/#', - schema: 'http://schema.org/', - xsd: 'http://www.w3.org/2001/XMLSchema#', - msv: 'http://www.idpf.org/epub/vocab/structure/magazine/#', - prism: 'http://www.prismstandard.org/specifications/3.0/PRISM_CV_Spec_3.0.htm#', -} + a11y: "http://www.idpf.org/epub/vocab/package/a11y/#", + dcterms: "http://purl.org/dc/terms/", + marc: "http://id.loc.gov/vocabulary/", + media: "http://www.idpf.org/epub/vocab/overlays/#", + onix: "http://www.editeur.org/ONIX/book/codelists/current.html#", + rendition: "http://www.idpf.org/vocab/rendition/#", + schema: "http://schema.org/", + xsd: "http://www.w3.org/2001/XMLSchema#", + msv: "http://www.idpf.org/epub/vocab/structure/magazine/#", + prism: + "http://www.prismstandard.org/specifications/3.0/PRISM_CV_Spec_3.0.htm#", +}; const RELATORS = { - art: 'artist', - aut: 'author', - clr: 'colorist', - edt: 'editor', - ill: 'illustrator', - nrt: 'narrator', - trl: 'translator', - pbl: 'publisher', -} + art: "artist", + aut: "author", + clr: "colorist", + edt: "editor", + ill: "illustrator", + nrt: "narrator", + trl: "translator", + pbl: "publisher", +}; const ONIX5 = { - '02': 'isbn', - '06': 'doi', - '15': 'isbn', - '26': 'doi', - '34': 'issn', -} + "02": "isbn", + "06": "doi", + 15: "isbn", + 26: "doi", + 34: "issn", +}; // convert to camel case -const camel = x => x.toLowerCase().replace(/[-:](.)/g, (_, g) => g.toUpperCase()) +const camel = (x) => + x.toLowerCase().replace(/[-:](.)/g, (_, g) => g.toUpperCase()); // strip and collapse ASCII whitespace // https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace -const normalizeWhitespace = str => str ? str - .replace(/[\t\n\f\r ]+/g, ' ') - .replace(/^[\t\n\f\r ]+/, '') - .replace(/[\t\n\f\r ]+$/, '') : '' +const normalizeWhitespace = (str) => + str + ? str + .replace(/[\t\n\f\r ]+/g, " ") + .replace(/^[\t\n\f\r ]+/, "") + .replace(/[\t\n\f\r ]+$/, "") + : ""; -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 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 getAttributes = + (...xs) => + (el) => + el + ? Object.fromEntries(xs.map((x) => [camel(x), el.getAttribute(x)])) + : null; -const getElementText = el => normalizeWhitespace(el?.textContent) +const getElementText = (el) => normalizeWhitespace(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(name)], - } -} + // 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(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 = 'https://invalid.invalid/' - const obj = new URL(url, root + relativeTo) - obj.search = '' - return decodeURI(obj.href.replace(root, '')) - } catch(e) { - console.warn(e) - return url - } -} + 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 = "https://invalid.invalid/"; + const obj = new URL(url, root + relativeTo); + obj.search = ""; + return decodeURI(obj.href.replace(root, "")); + } catch (e) { + console.warn(e); + return url; + } +}; -const isExternal = uri => /^(?!blob)\w+:/i.test(uri) +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('/') -} + 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) +const pathDirname = (str) => str.slice(0, str.lastIndexOf("/") + 1); // replace asynchronously and sequentially // same technique 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 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 regexEscape = (str) => str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); -const tidy = obj => { - for (const [key, val] of Object.entries(obj)) - if (val == null) delete obj[key] - else if (Array.isArray(val)) { - obj[key] = val.filter(x => x).map(x => - typeof x === 'object' && !Array.isArray(x) ? tidy(x) : x) - if (!obj[key].length) delete obj[key] - else if (obj[key].length === 1) obj[key] = obj[key][0] - } - else if (typeof val === 'object') { - obj[key] = tidy(val) - if (!Object.keys(val).length) delete obj[key] - } - const keys = Object.keys(obj) - if (keys.length === 1 && keys[0] === 'name') return obj[keys[0]] - return obj -} +const tidy = (obj) => { + for (const [key, val] of Object.entries(obj)) + if (val == null) delete obj[key]; + else if (Array.isArray(val)) { + obj[key] = val + .filter((x) => x) + .map((x) => (typeof x === "object" && !Array.isArray(x) ? tidy(x) : x)); + if (!obj[key].length) delete obj[key]; + else if (obj[key].length === 1) obj[key] = obj[key][0]; + } else if (typeof val === "object") { + obj[key] = tidy(val); + if (!Object.keys(val).length) delete obj[key]; + } + const keys = Object.keys(obj); + if (keys.length === 1 && keys[0] === "name") return obj[keys[0]]; + return obj; +}; // https://www.w3.org/TR/epub/#sec-prefix-attr -const getPrefixes = doc => { - const map = new Map(Object.entries(PREFIX)) - const value = doc.documentElement.getAttributeNS(NS.EPUB, 'prefix') - || doc.documentElement.getAttribute('prefix') - if (value) for (const [, prefix, url] of value - .matchAll(/(.+): +(.+)[ \t\r\n]*/g)) map.set(prefix, url) - return map -} +const getPrefixes = (doc) => { + const map = new Map(Object.entries(PREFIX)); + const value = + doc.documentElement.getAttributeNS(NS.EPUB, "prefix") || + doc.documentElement.getAttribute("prefix"); + if (value) + for (const [, prefix, url] of value.matchAll(/(.+): +(.+)[ \t\r\n]*/g)) + map.set(prefix, url); + return map; +}; // https://www.w3.org/TR/epub-rs/#sec-property-values // but ignoring the case where the prefix is omitted const getPropertyURL = (value, prefixes) => { - if (!value) return null - const [a, b] = value.split(':') - const prefix = b ? a : null - const reference = b ? b : a - const baseURL = prefixes.get(prefix) - return baseURL ? baseURL + reference : null -} + if (!value) return null; + const [a, b] = value.split(":"); + const prefix = b ? a : null; + const reference = b ? b : a; + const baseURL = prefixes.get(prefix); + return baseURL ? baseURL + reference : null; +}; -const getMetadata = opf => { - const { $ } = childGetter(opf, NS.OPF) - const $metadata = $(opf.documentElement, 'metadata') +const getMetadata = (opf) => { + const { $ } = childGetter(opf, NS.OPF); + const $metadata = $(opf.documentElement, "metadata"); - // first pass: convert to JS objects - const els = Object.groupBy($metadata.children, el => - el.namespaceURI === NS.DC ? 'dc' - : el.namespaceURI === NS.OPF && el.localName === 'meta' ? - (el.hasAttribute('name') ? 'legacyMeta' : 'meta') : '') - const baseLang = $metadata.getAttribute('xml:lang') - ?? opf.documentElement.getAttribute('xml:lang') ?? 'und' - const prefixes = getPrefixes(opf) - const parse = el => { - const property = el.getAttribute('property') - const scheme = el.getAttribute('scheme') - return { - property: getPropertyURL(property, prefixes) ?? property, - scheme: getPropertyURL(scheme, prefixes) ?? scheme, - lang: el.getAttribute('xml:lang'), - value: getElementText(el), - props: getProperties(el), - // `opf:` attributes from EPUB 2 & EPUB 3.1 (removed in EPUB 3.2) - attrs: Object.fromEntries(Array.from(el.attributes) - .filter(attr => attr.namespaceURI === NS.OPF) - .map(attr => [attr.localName, attr.value])), - } - } - const refines = Map.groupBy(els.meta ?? [], el => el.getAttribute('refines')) - const getProperties = el => { - const els = refines.get(el ? '#' + el.getAttribute('id') : null) - if (!els) return null - return Object.groupBy(els.map(parse), x => x.property) - } - const dc = Object.fromEntries(Object.entries(Object.groupBy(els.dc, el => el.localName)) - .map(([name, els]) => [name, els.map(parse)])) - const properties = getProperties() ?? {} - const legacyMeta = Object.fromEntries(els.legacyMeta?.map(el => - [el.getAttribute('name'), el.getAttribute('content')]) ?? []) - - // second pass: map to webpub - const one = x => x?.[0]?.value - const prop = (x, p) => one(x?.props?.[p]) - const makeLanguageMap = x => { - if (!x) return null - const alts = x.props?.['alternate-script'] ?? [] - const altRep = x.attrs['alt-rep'] - if (!alts.length && (!x.lang || x.lang === baseLang) && !altRep) return x.value - const map = { [x.lang ?? baseLang]: x.value } - if (altRep) map[x.attrs['alt-rep-lang']] = altRep - for (const y of alts) map[y.lang] ??= y.value - return map - } - const makeContributor = x => x ? ({ - name: makeLanguageMap(x), - sortAs: makeLanguageMap(x.props?.['file-as']?.[0]) ?? x.attrs['file-as'], - role: x.props?.role?.filter(x => x.scheme === PREFIX.marc + 'relators') - ?.map(x => x.value) ?? [x.attrs.role], - code: prop(x, 'term') ?? x.attrs.term, - scheme: prop(x, 'authority') ?? x.attrs.authority, - }) : null - const makeCollection = x => ({ - name: makeLanguageMap(x), - // NOTE: webpub requires number but EPUB allows values like "2.2.1" - position: one(x.props?.['group-position']), - }) - const makeAltIdentifier = x => { - const { value } = x - if (/^urn:/i.test(value)) return value - if (/^doi:/i.test(value)) return `urn:${value}` - const type = x.props?.['identifier-type'] - if (!type) { - const scheme = x.attrs.scheme - if (!scheme) return value - // https://idpf.github.io/epub-registries/identifiers/ - // but no "jdcn", which isn't a registered URN namespace - if (/^(doi|isbn|uuid)$/i.test(scheme)) return `urn:${scheme}:${value}` - // NOTE: webpub requires scheme to be a URI; EPUB allows anything - return { scheme, value } - } - if (type.scheme === PREFIX.onix + 'codelist5') { - const nid = ONIX5[type.value] - if (nid) return `urn:${nid}:${value}` - } - return value - } - const belongsTo = Object.groupBy(properties['belongs-to-collection'] ?? [], - x => prop(x, 'collection-type') === 'series' ? 'series' : 'collection') - const mainTitle = dc.title?.find(x => prop(x, 'title-type') === 'main') ?? dc.title?.[0] - const metadata = { - identifier: getIdentifier(opf), - title: makeLanguageMap(mainTitle), - sortAs: makeLanguageMap(mainTitle?.props?.['file-as']?.[0]) - ?? mainTitle?.attrs?.['file-as'] - ?? legacyMeta?.['calibre:title_sort'], - subtitle: dc.title?.find(x => prop(x, 'title-type') === 'subtitle')?.value, - language: dc.language?.map(x => x.value), - description: one(dc.description), - publisher: dc.publisher?.map(makeContributor), - published: dc.date?.find(x => x.attrs.event === 'publication')?.value - ?? one(dc.date), - modified: one(properties[PREFIX.dcterms + 'modified']) - ?? dc.date?.find(x => x.attrs.event === 'modification')?.value, - subject: dc.subject?.map(makeContributor), - belongsTo: { - collection: belongsTo.collection?.map(makeCollection), - series: belongsTo.series?.map(makeCollection) - ?? legacyMeta?.['calibre:series'] ? { - name: legacyMeta?.['calibre:series'], - position: parseFloat(legacyMeta?.['calibre:series_index']), - } : null, - }, - altIdentifier: dc.identifier?.map(makeAltIdentifier), - source: dc.source?.map(makeAltIdentifier), // NOTE: not in webpub schema - rights: one(dc.rights), // NOTE: not in webpub schema - pageBreakSource: one(properties['pageBreakSource']), // NOTE: not in webpub schema - } - const remapContributor = defaultKey => x => { - const keys = new Set(x.role?.map(role => RELATORS[role] ?? defaultKey)) - return [keys.size ? keys : [defaultKey], x] - } - for (const [keys, val] of [].concat( - dc.creator?.map(makeContributor)?.map(remapContributor('author')) ?? [], - dc.contributor?.map(makeContributor)?.map(remapContributor('contributor')) ?? [])) - for (const key of keys) - if (metadata[key]) metadata[key].push(val) - else metadata[key] = [val] - tidy(metadata) - if (metadata.altIdentifier === metadata.identifier) - delete metadata.altIdentifier - - const rendition = {} - const media = {} - for (const [key, val] of Object.entries(properties)) { - if (key.startsWith(PREFIX.rendition)) - rendition[camel(key.replace(PREFIX.rendition, ''))] = one(val) - else if (key.startsWith(PREFIX.media)) - media[camel(key.replace(PREFIX.media, ''))] = one(val) - } - if (media.duration) media.duration = parseClock(media.duration) - return { metadata, rendition, media } -} - -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 - } + // first pass: convert to JS objects + const els = Object.groupBy($metadata.children, (el) => + el.namespaceURI === NS.DC + ? "dc" + : el.namespaceURI === NS.OPF && el.localName === "meta" + ? el.hasAttribute("name") + ? "legacyMeta" + : "meta" + : "", + ); + const baseLang = + $metadata.getAttribute("xml:lang") ?? + opf.documentElement.getAttribute("xml:lang") ?? + "und"; + const prefixes = getPrefixes(opf); + const parse = (el) => { + const property = el.getAttribute("property"); + const scheme = el.getAttribute("scheme"); return { - toc: getSingle('navMap', 'navPoint'), - pageList: getSingle('pageList', 'pageTarget'), - others: $$(doc.documentElement, 'navList').map(el => ({ - label: getElementText($(el, 'navLabel')), - list: parseList(el, 'navTarget'), - })), - } -} + property: getPropertyURL(property, prefixes) ?? property, + scheme: getPropertyURL(scheme, prefixes) ?? scheme, + lang: el.getAttribute("xml:lang"), + value: getElementText(el), + props: getProperties(el), + // `opf:` attributes from EPUB 2 & EPUB 3.1 (removed in EPUB 3.2) + attrs: Object.fromEntries( + Array.from(el.attributes) + .filter((attr) => attr.namespaceURI === NS.OPF) + .map((attr) => [attr.localName, attr.value]), + ), + }; + }; + const refines = Map.groupBy(els.meta ?? [], (el) => + el.getAttribute("refines"), + ); + const getProperties = (el) => { + const els = refines.get(el ? "#" + el.getAttribute("id") : null); + if (!els) return null; + return Object.groupBy(els.map(parse), (x) => x.property); + }; + const dc = Object.fromEntries( + Object.entries(Object.groupBy(els.dc, (el) => el.localName)).map( + ([name, els]) => [name, els.map(parse)], + ), + ); + const properties = getProperties() ?? {}; + const legacyMeta = Object.fromEntries( + els.legacyMeta?.map((el) => [ + el.getAttribute("name"), + el.getAttribute("content"), + ]) ?? [], + ); -const parseClock = str => { - if (!str) return - const parts = str.split(':').map(x => parseFloat(x)) - if (parts.length === 3) { - const [h, m, s] = parts - return h * 60 * 60 + m * 60 + s + // second pass: map to webpub + const one = (x) => x?.[0]?.value; + const prop = (x, p) => one(x?.props?.[p]); + const makeLanguageMap = (x) => { + if (!x) return null; + const alts = x.props?.["alternate-script"] ?? []; + const altRep = x.attrs["alt-rep"]; + if (!alts.length && (!x.lang || x.lang === baseLang) && !altRep) + return x.value; + const map = { [x.lang ?? baseLang]: x.value }; + if (altRep) map[x.attrs["alt-rep-lang"]] = altRep; + for (const y of alts) map[y.lang] ??= y.value; + return map; + }; + const makeContributor = (x) => + x + ? { + name: makeLanguageMap(x), + sortAs: + makeLanguageMap(x.props?.["file-as"]?.[0]) ?? x.attrs["file-as"], + role: x.props?.role + ?.filter((x) => x.scheme === PREFIX.marc + "relators") + ?.map((x) => x.value) ?? [x.attrs.role], + code: prop(x, "term") ?? x.attrs.term, + scheme: prop(x, "authority") ?? x.attrs.authority, + } + : null; + const makeCollection = (x) => ({ + name: makeLanguageMap(x), + // NOTE: webpub requires number but EPUB allows values like "2.2.1" + position: one(x.props?.["group-position"]), + }); + const makeAltIdentifier = (x) => { + const { value } = x; + if (/^urn:/i.test(value)) return value; + if (/^doi:/i.test(value)) return `urn:${value}`; + const type = x.props?.["identifier-type"]; + if (!type) { + const scheme = x.attrs.scheme; + if (!scheme) return value; + // https://idpf.github.io/epub-registries/identifiers/ + // but no "jdcn", which isn't a registered URN namespace + if (/^(doi|isbn|uuid)$/i.test(scheme)) return `urn:${scheme}:${value}`; + // NOTE: webpub requires scheme to be a URI; EPUB allows anything + return { scheme, value }; } - if (parts.length === 2) { - const [m, s] = parts - return m * 60 + s + if (type.scheme === PREFIX.onix + "codelist5") { + const nid = ONIX5[type.value]; + if (nid) return `urn:${nid}:${value}`; } - const [x, unit] = str.split(/(?=[^\d.])/) - const n = parseFloat(x) - const f = unit === 'h' ? 60 * 60 - : unit === 'min' ? 60 - : unit === 'ms' ? .001 - : 1 - return n * f -} + return value; + }; + const belongsTo = Object.groupBy( + properties["belongs-to-collection"] ?? [], + (x) => (prop(x, "collection-type") === "series" ? "series" : "collection"), + ); + const mainTitle = + dc.title?.find((x) => prop(x, "title-type") === "main") ?? dc.title?.[0]; + const metadata = { + identifier: getIdentifier(opf), + title: makeLanguageMap(mainTitle), + sortAs: + makeLanguageMap(mainTitle?.props?.["file-as"]?.[0]) ?? + mainTitle?.attrs?.["file-as"] ?? + legacyMeta?.["calibre:title_sort"], + subtitle: dc.title?.find((x) => prop(x, "title-type") === "subtitle") + ?.value, + language: dc.language?.map((x) => x.value), + description: one(dc.description), + publisher: dc.publisher?.map(makeContributor), + published: + dc.date?.find((x) => x.attrs.event === "publication")?.value ?? + one(dc.date), + modified: + one(properties[PREFIX.dcterms + "modified"]) ?? + dc.date?.find((x) => x.attrs.event === "modification")?.value, + subject: dc.subject?.map(makeContributor), + belongsTo: { + collection: belongsTo.collection?.map(makeCollection), + series: + (belongsTo.series?.map(makeCollection) ?? + legacyMeta?.["calibre:series"]) + ? { + name: legacyMeta?.["calibre:series"], + position: parseFloat(legacyMeta?.["calibre:series_index"]), + } + : null, + }, + altIdentifier: dc.identifier?.map(makeAltIdentifier), + source: dc.source?.map(makeAltIdentifier), // NOTE: not in webpub schema + rights: one(dc.rights), // NOTE: not in webpub schema + pageBreakSource: one(properties["pageBreakSource"]), // NOTE: not in webpub schema + }; + const remapContributor = (defaultKey) => (x) => { + const keys = new Set(x.role?.map((role) => RELATORS[role] ?? defaultKey)); + return [keys.size ? keys : [defaultKey], x]; + }; + for (const [keys, val] of [].concat( + dc.creator?.map(makeContributor)?.map(remapContributor("author")) ?? [], + dc.contributor + ?.map(makeContributor) + ?.map(remapContributor("contributor")) ?? [], + )) + for (const key of keys) + if (metadata[key]) metadata[key].push(val); + else metadata[key] = [val]; + tidy(metadata); + if (metadata.altIdentifier === metadata.identifier) + delete metadata.altIdentifier; + + const rendition = {}; + const media = {}; + for (const [key, val] of Object.entries(properties)) { + if (key.startsWith(PREFIX.rendition)) + rendition[camel(key.replace(PREFIX.rendition, ""))] = one(val); + else if (key.startsWith(PREFIX.media)) + media[camel(key.replace(PREFIX.media, ""))] = one(val); + } + if (media.duration) media.duration = parseClock(media.duration); + return { metadata, rendition, media }; +}; + +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 parseClock = (str) => { + if (!str) return; + const parts = str.split(":").map((x) => parseFloat(x)); + if (parts.length === 3) { + const [h, m, s] = parts; + return h * 60 * 60 + m * 60 + s; + } + if (parts.length === 2) { + const [m, s] = parts; + return m * 60 + s; + } + const [x, unit] = str.split(/(?=[^\d.])/); + const n = parseFloat(x); + const f = + unit === "h" ? 60 * 60 : unit === "min" ? 60 : unit === "ms" ? 0.001 : 1; + return n * f; +}; class MediaOverlay extends EventTarget { - #entries - #lastMediaOverlayItem - #sectionIndex - #audioIndex - #itemIndex - #audio - #volume = 1 - #rate = 1 - #state - constructor(book, loadXML) { - super() - this.book = book - this.loadXML = loadXML - } - async #loadSMIL(item) { - if (this.#lastMediaOverlayItem === item) return - const doc = await this.loadXML(item.href) - const resolve = href => href ? resolveURL(href, item.href) : null - const { $, $$$ } = childGetter(doc, NS.SMIL) - this.#audioIndex = -1 - this.#itemIndex = -1 - this.#entries = $$$(doc, 'par').reduce((arr, $par) => { - const text = resolve($($par, 'text')?.getAttribute('src')) - const $audio = $($par, 'audio') - if (!text || !$audio) return arr - const src = resolve($audio.getAttribute('src')) - const begin = parseClock($audio.getAttribute('clipBegin')) - const end = parseClock($audio.getAttribute('clipEnd')) - const last = arr.at(-1) - if (last?.src === src) last.items.push({ text, begin, end }) - else arr.push({ src, items: [{ text, begin, end }] }) - return arr - }, []) - this.#lastMediaOverlayItem = item - } - get #activeAudio() { - return this.#entries[this.#audioIndex] - } - get #activeItem() { - return this.#activeAudio?.items?.[this.#itemIndex] - } - #error(e) { - console.error(e) - this.dispatchEvent(new CustomEvent('error', { detail: e })) - } - #highlight() { - this.dispatchEvent(new CustomEvent('highlight', { detail: this.#activeItem })) - } - #unhighlight() { - this.dispatchEvent(new CustomEvent('unhighlight', { detail: this.#activeItem })) - } - async #play(audioIndex, itemIndex) { - this.#stop() - this.#audioIndex = audioIndex - this.#itemIndex = itemIndex - const src = this.#activeAudio?.src - if (!src || !this.#activeItem) return this.start(this.#sectionIndex + 1) + #entries; + #lastMediaOverlayItem; + #sectionIndex; + #audioIndex; + #itemIndex; + #audio; + #volume = 1; + #rate = 1; + #state; + constructor(book, loadXML) { + super(); + this.book = book; + this.loadXML = loadXML; + } + async #loadSMIL(item) { + if (this.#lastMediaOverlayItem === item) return; + const doc = await this.loadXML(item.href); + const resolve = (href) => (href ? resolveURL(href, item.href) : null); + const { $, $$$ } = childGetter(doc, NS.SMIL); + this.#audioIndex = -1; + this.#itemIndex = -1; + this.#entries = $$$(doc, "par").reduce((arr, $par) => { + const text = resolve($($par, "text")?.getAttribute("src")); + const $audio = $($par, "audio"); + if (!text || !$audio) return arr; + const src = resolve($audio.getAttribute("src")); + const begin = parseClock($audio.getAttribute("clipBegin")); + const end = parseClock($audio.getAttribute("clipEnd")); + const last = arr.at(-1); + if (last?.src === src) last.items.push({ text, begin, end }); + else arr.push({ src, items: [{ text, begin, end }] }); + return arr; + }, []); + this.#lastMediaOverlayItem = item; + } + get #activeAudio() { + return this.#entries[this.#audioIndex]; + } + get #activeItem() { + return this.#activeAudio?.items?.[this.#itemIndex]; + } + #error(e) { + console.error(e); + this.dispatchEvent(new CustomEvent("error", { detail: e })); + } + #highlight() { + this.dispatchEvent( + new CustomEvent("highlight", { detail: this.#activeItem }), + ); + } + #unhighlight() { + this.dispatchEvent( + new CustomEvent("unhighlight", { detail: this.#activeItem }), + ); + } + async #play(audioIndex, itemIndex) { + this.#stop(); + this.#audioIndex = audioIndex; + this.#itemIndex = itemIndex; + const src = this.#activeAudio?.src; + if (!src || !this.#activeItem) return this.start(this.#sectionIndex + 1); - const url = URL.createObjectURL(await this.book.loadBlob(src)) - const audio = new Audio(url) - this.#audio = audio - audio.volume = this.#volume - audio.playbackRate = this.#rate - audio.addEventListener('timeupdate', () => { - if (audio.paused) return - const t = audio.currentTime - const { items } = this.#activeAudio - if (t > this.#activeItem?.end) { - this.#unhighlight() - if (this.#itemIndex === items.length - 1) { - this.#play(this.#audioIndex + 1, 0).catch(e => this.#error(e)) - return - } - } - const oldIndex = this.#itemIndex - while (items[this.#itemIndex + 1]?.begin <= t) this.#itemIndex++ - if (this.#itemIndex !== oldIndex) this.#highlight() - }) - audio.addEventListener('error', () => - this.#error(new Error(`Failed to load ${src}`))) - audio.addEventListener('playing', () => this.#highlight()) - audio.addEventListener('ended', () => { - this.#unhighlight() - URL.revokeObjectURL(url) - this.#audio = null - this.#play(audioIndex + 1, 0).catch(e => this.#error(e)) - }) - if (this.#state === 'paused') { - this.#highlight() - audio.currentTime = this.#activeItem.begin ?? 0 + const url = URL.createObjectURL(await this.book.loadBlob(src)); + const audio = new Audio(url); + this.#audio = audio; + audio.volume = this.#volume; + audio.playbackRate = this.#rate; + audio.addEventListener("timeupdate", () => { + if (audio.paused) return; + const t = audio.currentTime; + const { items } = this.#activeAudio; + if (t > this.#activeItem?.end) { + this.#unhighlight(); + if (this.#itemIndex === items.length - 1) { + this.#play(this.#audioIndex + 1, 0).catch((e) => this.#error(e)); + return; } - else audio.addEventListener('canplaythrough', () => { - // for some reason need to seek in `canplaythrough` - // or it won't play when skipping in WebKit - audio.currentTime = this.#activeItem.begin ?? 0 - this.#state = 'playing' - audio.play().catch(e => this.#error(e)) - }, { once: true }) - } - async start(sectionIndex, filter = () => true) { - this.#audio?.pause() - const section = this.book.sections[sectionIndex] - const href = section?.id - if (!href) return + } + const oldIndex = this.#itemIndex; + while (items[this.#itemIndex + 1]?.begin <= t) this.#itemIndex++; + if (this.#itemIndex !== oldIndex) this.#highlight(); + }); + audio.addEventListener("error", () => + this.#error(new Error(`Failed to load ${src}`)), + ); + audio.addEventListener("playing", () => this.#highlight()); + audio.addEventListener("ended", () => { + this.#unhighlight(); + URL.revokeObjectURL(url); + this.#audio = null; + this.#play(audioIndex + 1, 0).catch((e) => this.#error(e)); + }); + if (this.#state === "paused") { + this.#highlight(); + audio.currentTime = this.#activeItem.begin ?? 0; + } else + audio.addEventListener( + "canplaythrough", + () => { + // for some reason need to seek in `canplaythrough` + // or it won't play when skipping in WebKit + audio.currentTime = this.#activeItem.begin ?? 0; + this.#state = "playing"; + audio.play().catch((e) => this.#error(e)); + }, + { once: true }, + ); + } + async start(sectionIndex, filter = () => true) { + this.#audio?.pause(); + const section = this.book.sections[sectionIndex]; + const href = section?.id; + if (!href) return; - const { mediaOverlay } = section - if (!mediaOverlay) return this.start(sectionIndex + 1) - this.#sectionIndex = sectionIndex - await this.#loadSMIL(mediaOverlay) + const { mediaOverlay } = section; + if (!mediaOverlay) return this.start(sectionIndex + 1); + this.#sectionIndex = sectionIndex; + await this.#loadSMIL(mediaOverlay); - for (let i = 0; i < this.#entries.length; i++) { - const { items } = this.#entries[i] - for (let j = 0; j < items.length; j++) { - if (items[j].text.split('#')[0] === href && filter(items[j], j, items)) - return this.#play(i, j).catch(e => this.#error(e)) - } - } + for (let i = 0; i < this.#entries.length; i++) { + const { items } = this.#entries[i]; + for (let j = 0; j < items.length; j++) { + if (items[j].text.split("#")[0] === href && filter(items[j], j, items)) + return this.#play(i, j).catch((e) => this.#error(e)); + } } - pause() { - this.#state = 'paused' - this.#audio?.pause() - } - resume() { - this.#state = 'playing' - this.#audio?.play().catch(e => this.#error(e)) - } - #stop() { - if (this.#audio) { - this.#audio.pause() - URL.revokeObjectURL(this.#audio.src) - this.#audio = null - this.#unhighlight() - } - } - stop() { - this.#state = 'stopped' - this.#stop() - } - prev() { - if (this.#itemIndex > 0) this.#play(this.#audioIndex, this.#itemIndex - 1) - else if (this.#audioIndex > 0) this.#play(this.#audioIndex - 1, - this.#entries[this.#audioIndex - 1].items.length - 1) - else if (this.#sectionIndex > 0) - this.start(this.#sectionIndex - 1, (_, i, items) => i === items.length - 1) - } - next() { - this.#play(this.#audioIndex, this.#itemIndex + 1) - } - setVolume(volume) { - this.#volume = volume - if (this.#audio) this.#audio.volume = volume - } - setRate(rate) { - this.#rate = rate - if (this.#audio) this.#audio.playbackRate = rate + } + pause() { + this.#state = "paused"; + this.#audio?.pause(); + } + resume() { + this.#state = "playing"; + this.#audio?.play().catch((e) => this.#error(e)); + } + #stop() { + if (this.#audio) { + this.#audio.pause(); + URL.revokeObjectURL(this.#audio.src); + this.#audio = null; + this.#unhighlight(); } + } + stop() { + this.#state = "stopped"; + this.#stop(); + } + prev() { + if (this.#itemIndex > 0) this.#play(this.#audioIndex, this.#itemIndex - 1); + else if (this.#audioIndex > 0) + this.#play( + this.#audioIndex - 1, + this.#entries[this.#audioIndex - 1].items.length - 1, + ); + else if (this.#sectionIndex > 0) + this.start( + this.#sectionIndex - 1, + (_, i, items) => i === items.length - 1, + ); + } + next() { + this.#play(this.#audioIndex, this.#itemIndex + 1); + } + setVolume(volume) { + this.#volume = volume; + if (this.#audio) this.#audio.volume = volume; + } + setRate(rate) { + this.#rate = rate; + if (this.#audio) this.#audio.playbackRate = rate; + } } -const isUUID = /([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})/ +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 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]) +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 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 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://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), + ); }, - '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), - }, -}) + 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) + #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) - } + } + 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) + 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') + 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', 'media-overlay')) - .map(item => { - item.href = resolveHref(item.href) - item.properties = item.properties?.split(/\s/) - return item - }) - this.manifestById = new Map(this.manifest.map(item => [item.id, 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.manifest = $$($manifest, "item") + .map( + getAttributes( + "href", + "id", + "media-type", + "properties", + "media-overlay", + ), + ) + .map((item) => { + item.href = resolveHref(item.href); + item.properties = item.properties?.split(/\s/); + return item; + }); + this.manifestById = new Map(this.manifest.map((item) => [item.id, 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 + 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), - })) + 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.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.manifestById.get(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 } + this.cfis = CFI.fromElements($$itemref); + } + getItemByID(id) { + return this.manifestById.get(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() - eventTarget = new EventTarget() - 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)) + #cache = new Map(); + #children = new Map(); + #refCount = new Map(); + eventTarget = new EventTarget(); + 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)) + } + async createURL(href, data, type, parent) { + if (!data) return ""; + const detail = { data, type }; + Object.defineProperty(detail, "name", { value: href }); // readonly + const event = new CustomEvent("data", { detail }); + this.eventTarget.dispatchEvent(event); + const newData = await event.detail.data; + const newType = await event.detail.type; + const url = URL.createObjectURL(new Blob([newData], { type: newType })); + 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]); } - async createURL(href, data, type, parent) { - if (!data) return '' - const detail = { data, type } - Object.defineProperty(detail, 'name', { value: href }) // readonly - const event = new CustomEvent('data', { detail }) - this.eventTarget.dispatchEvent(event) - const newData = await event.detail.data - const newType = await event.detail.type - const url = URL.createObjectURL(new Blob([newData], { type: newType })) - 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); + const detail = { type: mediaType, isScript, allow: true }; + const event = new CustomEvent("load", { detail }); + this.eventTarget.dispatchEvent(event); + const allow = await event.detail.allow; + if (!allow) 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); + // NOTE: this can be replaced with `Promise.try()` + const tryLoadBlob = Promise.resolve().then(() => this.loadBlob(href)); + return this.createURL(href, tryLoadBlob, 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); + let str = ""; + try { + str = await this.loadText(href); + } catch (e) { + return this.createURL(href, Promise.reject(e), mediaType, parent); + } + 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") || !doc.documentElement?.namespaceURI) + ) { + console.warn( + doc.querySelector("parsererror")?.innerText ?? "Invalid XHTML", + ); + 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; } - return url + } + // replace hrefs (excluding anchors) + 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("object[data]")) + await replace(el, "data"); + for (const el of doc.querySelectorAll("[*|href]:not([href])")) + el.setAttributeNS( + NS.XLINK, + "href", + await this.loadHref( + el.getAttributeNS(NS.XLINK, "href"), + href, + parents, + ), + ); + for (const el of doc.querySelectorAll("[srcset]")) + el.setAttribute( + "srcset", + await replaceSeries( + el.getAttribute("srcset"), + /(\s*)(.+?)\s*((?:\s[\d.]+[wx])+\s*(?:,|$)|,\s+|$)/g, + (_, p1, p2, p3) => + this.loadHref(p2, href, parents).then((p2) => `${p1}${p2}${p3}`), + ), + ); + // 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); } - 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) - const detail = { type: mediaType, isScript, allow: true} - const event = new CustomEvent('load', { detail }) - this.eventTarget.dispatchEvent(event) - const allow = await event.detail.allow - if (!allow) 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) - // NOTE: this can be replaced with `Promise.try()` - const tryLoadBlob = Promise.resolve().then(() => this.loadBlob(href)) - return this.createURL(href, tryLoadBlob, 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) - let str = '' - try { - str = await this.loadText(href) - } catch (e) { - return this.createURL(href, Promise.reject(e), mediaType, parent) - } - 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') - || !doc.documentElement?.namespaceURI)) { - console.warn(doc.querySelector('parsererror')?.innerText ?? 'Invalid XHTML') - 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) - 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('object[data]')) await replace(el, 'data') - for (const el of doc.querySelectorAll('[*|href]:not([href])')) - el.setAttributeNS(NS.XLINK, 'href', await this.loadHref( - el.getAttributeNS(NS.XLINK, 'href'), href, parents)) - for (const el of doc.querySelectorAll('[srcset]')) - el.setAttribute('srcset', await replaceSeries(el.getAttribute('srcset'), - /(\s*)(.+?)\s*((?:\s[\d.]+[wx])+\s*(?:,|$)|,\s+|$)/g, - (_, p1, p2, p3) => this.loadHref(p2, href, parents) - .then(p2 => `${p1}${p2}${p3}`))) - // 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?!) - return replaceSeries(replacedUrls, - /@import\s*["']([^"'\n]*?)["']/gi, - (_, url) => this.loadHref(url, href, parents) - .then(url => `@import "${url}"`)) - } - // 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) - } - destroy() { - for (const url of this.#cache.values()) URL.revokeObjectURL(url) - } + 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?!) + return replaceSeries( + replacedUrls, + /@import\s*["']([^"'\n]*?)["']/gi, + (_, url) => + this.loadHref(url, href, parents).then((url) => `@import "${url}"`), + ); + } + // 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); + } + destroy() { + for (const url of this.#cache.values()) URL.revokeObjectURL(url); + } } -const getHTMLFragment = (doc, id) => doc.getElementById(id) - ?? doc.querySelector(`[name="${CSS.escape(id)}"]`) +const getHTMLFragment = (doc, id) => + doc.getElementById(id) ?? doc.querySelector(`[name="${CSS.escape(id)}"]`); -const getPageSpread = properties => { - for (const p of properties) { - if (p === 'page-spread-left' || p === 'rendition:page-spread-left') - return 'left' - if (p === 'page-spread-right' || p === 'rendition:page-spread-right') - return 'right' - if (p === 'rendition:page-spread-center') return 'center' - } -} +const getPageSpread = (properties) => { + for (const p of properties) { + if (p === "page-spread-left" || p === "rendition:page-spread-left") + return "left"; + if (p === "page-spread-right" || p === "rendition:page-spread-right") + return "right"; + if (p === "rendition:page-spread-center") return "center"; + } +}; -const getDisplayOptions = doc => { - if (!doc) return null - return { - fixedLayout: getElementText(doc.querySelector('option[name="fixed-layout"]')), - openToSpread: getElementText(doc.querySelector('option[name="open-to-spread"]')), - } -} +const getDisplayOptions = (doc) => { + if (!doc) return null; + return { + fixedLayout: getElementText( + doc.querySelector('option[name="fixed-layout"]'), + ), + openToSpread: getElementText( + doc.querySelector('option[name="open-to-spread"]'), + ), + }; +}; export class EPUB { - parser = new DOMParser() - #loader - #encryption - constructor({ loadText, loadBlob, getSize, sha1 }) { - this.loadText = loadText - this.loadBlob = loadBlob - this.getSize = getSize - this.#encryption = new Encryption(deobfuscators(sha1)) - } - async #loadXML(uri) { - const str = await this.loadText(uri) - if (!str) return null - const doc = this.parser.parseFromString(str, MIME.XML) - if (doc.querySelector('parsererror')) - throw new Error(`XML parsing error: ${uri} -${doc.querySelector('parsererror').innerText}`) - return doc - } - async init() { - const $container = await this.#loadXML('META-INF/container.xml') - if (!$container) throw new Error('Failed to load container file') + parser = new DOMParser(); + #loader; + #encryption; + constructor({ loadText, loadBlob, getSize, sha1 }) { + this.loadText = loadText; + this.loadBlob = loadBlob; + this.getSize = getSize; + this.#encryption = new Encryption(deobfuscators(sha1)); + } + async #loadXML(uri) { + const str = await this.loadText(uri); + if (!str) return null; + const doc = this.parser.parseFromString(str, MIME.XML); + if (doc.querySelector("parsererror")) + throw new Error(`XML parsing error: ${uri} +${doc.querySelector("parsererror").innerText}`); + return doc; + } + 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') + 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') + 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) + 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), - }) - this.#loader = new Loader({ - loadText: this.loadText, - loadBlob: uri => Promise.resolve(this.loadBlob(uri)) - .then(this.#encryption.getDecoder(uri)), - resources: this.resources, - }) - this.transformTarget = this.#loader.eventTarget - 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: item.href, - load: () => this.#loader.loadItem(item), - unload: () => this.#loader.unloadItem(item), - createDocument: () => this.loadDocument(item), - size: this.getSize(item.href), - cfi: this.resources.cfis[index], - linear, - pageSpread: getPageSpread(properties), - resolveHref: href => resolveURL(href, item.href), - mediaOverlay: item.mediaOverlay - ? this.resources.getItemByID(item.mediaOverlay) : null, - } - }).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) + this.resources = new Resources({ + opf, + resolveHref: (url) => resolveURL(url, opfPath), + }); + this.#loader = new Loader({ + loadText: this.loadText, + loadBlob: (uri) => + Promise.resolve(this.loadBlob(uri)).then( + this.#encryption.getDecoder(uri), + ), + resources: this.resources, + }); + this.transformTarget = this.#loader.eventTarget; + 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; } - 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 + return { + id: item.href, + load: () => this.#loader.loadItem(item), + unload: () => this.#loader.unloadItem(item), + createDocument: () => this.loadDocument(item), + size: this.getSize(item.href), + cfi: this.resources.cfis[index], + linear, + pageSpread: getPageSpread(properties), + resolveHref: (href) => resolveURL(href, item.href), + mediaOverlay: item.mediaOverlay + ? this.resources.getItemByID(item.mediaOverlay) + : null, + }; + }) + .filter((s) => s); - const { metadata, rendition, media } = getMetadata(opf) - this.metadata = metadata - this.rendition = rendition - this.media = media - this.dir = this.resources.pageProgressionDirection - const displayOptions = getDisplayOptions( - await this.#loadXML('META-INF/com.apple.ibooks.display-options.xml') - ?? await this.#loadXML('META-INF/com.kobobooks.display-options.xml')) - if (displayOptions) { - if (displayOptions.fixedLayout === 'true') - this.rendition.layout ??= 'pre-paginated' - if (displayOptions.openToSpread === 'false') this.sections - .find(section => section.linear !== 'no').pageSpread ??= - this.dir === 'rtl' ? 'left' : 'right' - } - return this + 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, media } = getMetadata(opf); + this.metadata = metadata; + this.rendition = rendition; + this.media = media; + this.dir = this.resources.pageProgressionDirection; + const displayOptions = getDisplayOptions( + (await this.#loadXML("META-INF/com.apple.ibooks.display-options.xml")) ?? + (await this.#loadXML("META-INF/com.kobobooks.display-options.xml")), + ); + if (displayOptions) { + if (displayOptions.fixedLayout === "true") + this.rendition.layout ??= "pre-paginated"; + if (displayOptions.openToSpread === "false") + this.sections.find((section) => section.linear !== "no").pageSpread ??= + this.dir === "rtl" ? "left" : "right"; } - async loadDocument(item) { - const str = await this.loadText(item.href) - return this.parser.parseFromString(str, item.mediaType) - } - getMediaOverlay() { - return new MediaOverlay(this, this.#loadXML.bind(this)) - } - 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) - } - async getCover() { - const cover = this.resources?.cover - return cover?.href - ? new Blob([await this.loadBlob(cover.href)], { type: cover.mediaType }) - : null - } - async getCalibreBookmarks() { - const txt = await this.loadText('META-INF/calibre_bookmarks.txt') - const magic = 'encoding=json+base64:' - if (txt?.startsWith(magic)) { - const json = atob(txt.slice(magic.length)) - return JSON.parse(json) - } - } - destroy() { - this.#loader?.destroy() + return this; + } + async loadDocument(item) { + const str = await this.loadText(item.href); + return this.parser.parseFromString(str, item.mediaType); + } + getMediaOverlay() { + return new MediaOverlay(this, this.#loadXML.bind(this)); + } + 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); + } + async getCover() { + const cover = this.resources?.cover; + return cover?.href + ? new Blob([await this.loadBlob(cover.href)], { type: cover.mediaType }) + : null; + } + async getCalibreBookmarks() { + const txt = await this.loadText("META-INF/calibre_bookmarks.txt"); + const magic = "encoding=json+base64:"; + if (txt?.startsWith(magic)) { + const json = atob(txt.slice(magic.length)); + return JSON.parse(json); } + } + destroy() { + this.#loader?.destroy(); + } } diff --git a/view.js b/view.js index 7397ea2..cca14b3 100644 --- a/view.js +++ b/view.js @@ -1,597 +1,669 @@ -import * as CFI from './epubcfi.js' -import { TOCProgress, SectionProgress } from './progress.js' -import { Overlayer } from './overlayer.js' -import { textWalker } from './text-walker.js' +import * as CFI from "./epubcfi.js"; +import { TOCProgress, SectionProgress } from "./progress.js"; +import { Overlayer } from "./overlayer.js"; +import { textWalker } from "./text-walker.js"; -const SEARCH_PREFIX = 'foliate-search:' +const SEARCH_PREFIX = "foliate-search:"; -const isZip = async file => { - const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) - return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 -} +const isZip = async (file) => { + const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()); + return ( + arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 + ); +}; -const isPDF = async file => { - const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()) - return arr[0] === 0x25 - && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 - && arr[4] === 0x2d -} +const isPDF = async (file) => { + const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()); + return ( + arr[0] === 0x25 && + arr[1] === 0x50 && + arr[2] === 0x44 && + arr[3] === 0x46 && + arr[4] === 0x2d + ); +}; const isCBZ = ({ name, type }) => - type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz') + type === "application/vnd.comicbook+zip" || name.endsWith(".cbz"); const isFB2 = ({ name, type }) => - type === 'application/x-fictionbook+xml' || name.endsWith('.fb2') + type === "application/x-fictionbook+xml" || name.endsWith(".fb2"); const isFBZ = ({ name, type }) => - type === 'application/x-zip-compressed-fb2' - || name.endsWith('.fb2.zip') || name.endsWith('.fbz') + type === "application/x-zip-compressed-fb2" || + name.endsWith(".fb2.zip") || + name.endsWith(".fbz"); -const makeZipLoader = async file => { - const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = - await import('./vendor/zip.js') - configure({ useWebWorkers: false }) - const reader = new ZipReader(new BlobReader(file)) - const entries = await reader.getEntries() - const map = new Map(entries.map(entry => [entry.filename, entry])) - const load = f => (name, ...args) => - map.has(name) ? f(map.get(name), ...args) : null - const loadText = load(entry => entry.getData(new TextWriter())) - const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))) - const getSize = name => map.get(name)?.uncompressedSize ?? 0 - return { entries, loadText, loadBlob, getSize } -} +const makeZipLoader = async (file) => { + const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = + await import("./vendor/zip.js"); + configure({ useWebWorkers: false }); + const reader = new ZipReader(new BlobReader(file)); + const entries = await reader.getEntries(); + const map = new Map(entries.map((entry) => [entry.filename, entry])); + const load = + (f) => + (name, ...args) => + map.has(name) ? f(map.get(name), ...args) : null; + const loadText = load((entry) => entry.getData(new TextWriter())); + const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))); + const getSize = (name) => map.get(name)?.uncompressedSize ?? 0; + return { entries, loadText, loadBlob, getSize }; +}; -const getFileEntries = async entry => entry.isFile ? entry - : (await Promise.all(Array.from( - await new Promise((resolve, reject) => entry.createReader() - .readEntries(entries => resolve(entries), error => reject(error))), - getFileEntries))).flat() +const getFileEntries = async (entry) => + entry.isFile + ? entry + : ( + await Promise.all( + Array.from( + await new Promise((resolve, reject) => + entry.createReader().readEntries( + (entries) => resolve(entries), + (error) => reject(error), + ), + ), + getFileEntries, + ), + ) + ).flat(); -const makeDirectoryLoader = async entry => { - const entries = await getFileEntries(entry) - const files = await Promise.all( - entries.map(entry => new Promise((resolve, reject) => - entry.file(file => resolve([file, entry.fullPath]), - error => reject(error))))) - const map = new Map(files.map(([file, path]) => - [path.replace(entry.fullPath + '/', ''), file])) - const decoder = new TextDecoder() - const decode = x => x ? decoder.decode(x) : null - const getBuffer = name => map.get(name)?.arrayBuffer() ?? null - const loadText = async name => decode(await getBuffer(name)) - const loadBlob = name => map.get(name) - const getSize = name => map.get(name)?.size ?? 0 - return { loadText, loadBlob, getSize } -} +const makeDirectoryLoader = async (entry) => { + const entries = await getFileEntries(entry); + const files = await Promise.all( + entries.map( + (entry) => + new Promise((resolve, reject) => + entry.file( + (file) => resolve([file, entry.fullPath]), + (error) => reject(error), + ), + ), + ), + ); + const map = new Map( + files.map(([file, path]) => [path.replace(entry.fullPath + "/", ""), file]), + ); + const decoder = new TextDecoder(); + const decode = (x) => (x ? decoder.decode(x) : null); + const getBuffer = (name) => map.get(name)?.arrayBuffer() ?? null; + const loadText = async (name) => decode(await getBuffer(name)); + const loadBlob = (name) => map.get(name); + const getSize = (name) => map.get(name)?.size ?? 0; + return { loadText, loadBlob, getSize }; +}; export class ResponseError extends Error {} export class NotFoundError extends Error {} export class UnsupportedTypeError extends Error {} -const fetchFile = async url => { - const res = await fetch(url) - if (!res.ok) throw new ResponseError( - `${res.status} ${res.statusText}`, { cause: res }) - return new File([await res.blob()], new URL(res.url).pathname) -} +const fetchFile = async (url) => { + const res = await fetch(url); + if (!res.ok) + throw new ResponseError(`${res.status} ${res.statusText}`, { cause: res }); + return new File([await res.blob()], new URL(res.url).pathname); +}; -export const makeBook = async file => { - if (typeof file === 'string') file = await fetchFile(file) - let book - if (file.isDirectory) { - const loader = await makeDirectoryLoader(file) - const { EPUB } = await import('./epub.js') - book = await new EPUB(loader).init() +export const makeBook = async (file) => { + if (typeof file === "string") file = await fetchFile(file); + let book; + if (file.isDirectory) { + const loader = await makeDirectoryLoader(file); + const { EPUB } = await import("./epub.js"); + book = await new EPUB(loader).init(); + } else if (!file.size) throw new NotFoundError("File not found"); + else if (await isZip(file)) { + const loader = await makeZipLoader(file); + if (isCBZ(file)) { + // Check if CBZ is EPUB-structured (has content.opf) + const hasOPF = loader.entries.some((e) => e.filename === "content.opf"); + + if (hasOPF) { + console.log("[view] CBZ has OPF, treating as EPUB"); + const { EPUB } = await import("./epub.js"); + book = await new EPUB(loader).init(); + } else { + console.log("[view] CBZ is image-only, using comic-book loader"); + const { makeComicBook } = await import("./comic-book.js"); + book = makeComicBook(loader, file); + } + } else if (isFBZ(file)) { + const { makeFB2 } = await import("./fb2.js"); + const { entries } = loader; + const entry = entries.find((entry) => entry.filename.endsWith(".fb2")); + const blob = await loader.loadBlob((entry ?? entries[0]).filename); + book = await makeFB2(blob); + } else { + const { EPUB } = await import("./epub.js"); + book = await new EPUB(loader).init(); } - else if (!file.size) throw new NotFoundError('File not found') - else if (await isZip(file)) { - const loader = await makeZipLoader(file) - if (isCBZ(file)) { - const { makeComicBook } = await import('./comic-book.js') - book = makeComicBook(loader, file) - } - else if (isFBZ(file)) { - const { makeFB2 } = await import('./fb2.js') - const { entries } = loader - const entry = entries.find(entry => entry.filename.endsWith('.fb2')) - const blob = await loader.loadBlob((entry ?? entries[0]).filename) - book = await makeFB2(blob) - } - else { - const { EPUB } = await import('./epub.js') - book = await new EPUB(loader).init() - } + } else if (await isPDF(file)) { + const { makePDF } = await import("./pdf.js"); + book = await makePDF(file); + } else { + const { isMOBI, MOBI } = await import("./mobi.js"); + if (await isMOBI(file)) { + const fflate = await import("./vendor/fflate.js"); + book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file); + } else if (isFB2(file)) { + const { makeFB2 } = await import("./fb2.js"); + book = await makeFB2(file); } - else if (await isPDF(file)) { - const { makePDF } = await import('./pdf.js') - book = await makePDF(file) - } - else { - const { isMOBI, MOBI } = await import('./mobi.js') - if (await isMOBI(file)) { - const fflate = await import('./vendor/fflate.js') - book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file) - } - else if (isFB2(file)) { - const { makeFB2 } = await import('./fb2.js') - book = await makeFB2(file) - } - } - if (!book) throw new UnsupportedTypeError('File type not supported') - return book -} + } + if (!book) throw new UnsupportedTypeError("File type not supported"); + return book; +}; class CursorAutohider { - #timeout - #el - #check - #state - constructor(el, check, state = {}) { - this.#el = el - this.#check = check - this.#state = state - if (this.#state.hidden) this.hide() - this.#el.addEventListener('mousemove', ({ screenX, screenY }) => { - // check if it actually moved - if (screenX === this.#state.x && screenY === this.#state.y) return - this.#state.x = screenX, this.#state.y = screenY - this.show() - if (this.#timeout) clearTimeout(this.#timeout) - if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000) - }, false) - } - cloneFor(el) { - return new CursorAutohider(el, this.#check, this.#state) - } - hide() { - this.#el.style.cursor = 'none' - this.#state.hidden = true - } - show() { - this.#el.style.removeProperty('cursor') - this.#state.hidden = false - } + #timeout; + #el; + #check; + #state; + constructor(el, check, state = {}) { + this.#el = el; + this.#check = check; + this.#state = state; + if (this.#state.hidden) this.hide(); + this.#el.addEventListener( + "mousemove", + ({ screenX, screenY }) => { + // check if it actually moved + if (screenX === this.#state.x && screenY === this.#state.y) return; + ((this.#state.x = screenX), (this.#state.y = screenY)); + this.show(); + if (this.#timeout) clearTimeout(this.#timeout); + if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000); + }, + false, + ); + } + cloneFor(el) { + return new CursorAutohider(el, this.#check, this.#state); + } + hide() { + this.#el.style.cursor = "none"; + this.#state.hidden = true; + } + show() { + this.#el.style.removeProperty("cursor"); + this.#state.hidden = false; + } } class History extends EventTarget { - #arr = [] - #index = -1 - pushState(x) { - const last = this.#arr[this.#index] - if (last === x || last?.fraction && last.fraction === x.fraction) return - this.#arr[++this.#index] = x - this.#arr.length = this.#index + 1 - this.dispatchEvent(new Event('index-change')) - } - replaceState(x) { - const index = this.#index - this.#arr[index] = x - } - back() { - const index = this.#index - if (index <= 0) return - const detail = { state: this.#arr[index - 1] } - this.#index = index - 1 - this.dispatchEvent(new CustomEvent('popstate', { detail })) - this.dispatchEvent(new Event('index-change')) - } - forward() { - const index = this.#index - if (index >= this.#arr.length - 1) return - const detail = { state: this.#arr[index + 1] } - this.#index = index + 1 - this.dispatchEvent(new CustomEvent('popstate', { detail })) - this.dispatchEvent(new Event('index-change')) - } - get canGoBack() { - return this.#index > 0 - } - get canGoForward() { - return this.#index < this.#arr.length - 1 - } - clear() { - this.#arr = [] - this.#index = -1 - } + #arr = []; + #index = -1; + pushState(x) { + const last = this.#arr[this.#index]; + if (last === x || (last?.fraction && last.fraction === x.fraction)) return; + this.#arr[++this.#index] = x; + this.#arr.length = this.#index + 1; + this.dispatchEvent(new Event("index-change")); + } + replaceState(x) { + const index = this.#index; + this.#arr[index] = x; + } + back() { + const index = this.#index; + if (index <= 0) return; + const detail = { state: this.#arr[index - 1] }; + this.#index = index - 1; + this.dispatchEvent(new CustomEvent("popstate", { detail })); + this.dispatchEvent(new Event("index-change")); + } + forward() { + const index = this.#index; + if (index >= this.#arr.length - 1) return; + const detail = { state: this.#arr[index + 1] }; + this.#index = index + 1; + this.dispatchEvent(new CustomEvent("popstate", { detail })); + this.dispatchEvent(new Event("index-change")); + } + get canGoBack() { + return this.#index > 0; + } + get canGoForward() { + return this.#index < this.#arr.length - 1; + } + clear() { + this.#arr = []; + this.#index = -1; + } } -const languageInfo = lang => { - if (!lang) return {} - try { - const canonical = Intl.getCanonicalLocales(lang)[0] - const locale = new Intl.Locale(canonical) - const isCJK = ['zh', 'ja', 'kr'].includes(locale.language) - const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction - return { canonical, locale, isCJK, direction } - } catch (e) { - console.warn(e) - return {} - } -} +const languageInfo = (lang) => { + if (!lang) return {}; + try { + const canonical = Intl.getCanonicalLocales(lang)[0]; + const locale = new Intl.Locale(canonical); + const isCJK = ["zh", "ja", "kr"].includes(locale.language); + const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction; + return { canonical, locale, isCJK, direction }; + } catch (e) { + console.warn(e); + return {}; + } +}; export class View extends HTMLElement { - #root = this.attachShadow({ mode: 'closed' }) - #sectionProgress - #tocProgress - #pageProgress - #searchResults = new Map() - #searchDraw - #searchDrawOptions - #cursorAutohider = new CursorAutohider(this, () => - this.hasAttribute('autohide-cursor')) - isFixedLayout = false - lastLocation - history = new History() - constructor() { - super() - this.history.addEventListener('popstate', ({ detail }) => { - const resolved = this.resolveNavigation(detail.state) - this.renderer.goTo(resolved) - }) - } - async open(book) { - if (typeof book === 'string' - || typeof book.arrayBuffer === 'function' - || book.isDirectory) book = await makeBook(book) - this.book = book - this.language = languageInfo(book.metadata?.language) + #root = this.attachShadow({ mode: "closed" }); + #sectionProgress; + #tocProgress; + #pageProgress; + #searchResults = new Map(); + #searchDraw; + #searchDrawOptions; + #cursorAutohider = new CursorAutohider(this, () => + this.hasAttribute("autohide-cursor"), + ); + isFixedLayout = false; + lastLocation; + history = new History(); + constructor() { + super(); + this.history.addEventListener("popstate", ({ detail }) => { + const resolved = this.resolveNavigation(detail.state); + this.renderer.goTo(resolved); + }); + } + async open(book) { + if ( + typeof book === "string" || + typeof book.arrayBuffer === "function" || + book.isDirectory + ) + book = await makeBook(book); + this.book = book; + this.language = languageInfo(book.metadata?.language); - if (book.splitTOCHref && book.getTOCFragment) { - const ids = book.sections.map(s => s.id) - this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600) - const splitHref = book.splitTOCHref.bind(book) - const getFragment = book.getTOCFragment.bind(book) - this.#tocProgress = new TOCProgress() - await this.#tocProgress.init({ - toc: book.toc ?? [], ids, splitHref, getFragment }) - this.#pageProgress = new TOCProgress() - await this.#pageProgress.init({ - toc: book.pageList ?? [], ids, splitHref, getFragment }) - } + if (book.splitTOCHref && book.getTOCFragment) { + const ids = book.sections.map((s) => s.id); + this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600); + const splitHref = book.splitTOCHref.bind(book); + const getFragment = book.getTOCFragment.bind(book); + this.#tocProgress = new TOCProgress(); + await this.#tocProgress.init({ + toc: book.toc ?? [], + ids, + splitHref, + getFragment, + }); + this.#pageProgress = new TOCProgress(); + await this.#pageProgress.init({ + toc: book.pageList ?? [], + ids, + splitHref, + getFragment, + }); + } - this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated' - if (this.isFixedLayout) { - await import('./fixed-layout.js') - this.renderer = document.createElement('foliate-fxl') - } else { - await import('./paginator.js') - this.renderer = document.createElement('foliate-paginator') - } - this.renderer.setAttribute('exportparts', 'head,foot,filter') - this.renderer.addEventListener('load', e => this.#onLoad(e.detail)) - this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail)) - this.renderer.addEventListener('create-overlayer', e => - e.detail.attach(this.#createOverlayer(e.detail))) - this.renderer.open(book) - this.#root.append(this.renderer) + this.isFixedLayout = this.book.rendition?.layout === "pre-paginated"; + if (this.isFixedLayout) { + await import("./fixed-layout.js"); + this.renderer = document.createElement("foliate-fxl"); + } else { + await import("./paginator.js"); + this.renderer = document.createElement("foliate-paginator"); + } + this.renderer.setAttribute("exportparts", "head,foot,filter"); + this.renderer.addEventListener("load", (e) => this.#onLoad(e.detail)); + this.renderer.addEventListener("relocate", (e) => + this.#onRelocate(e.detail), + ); + this.renderer.addEventListener("create-overlayer", (e) => + e.detail.attach(this.#createOverlayer(e.detail)), + ); + this.renderer.open(book); + this.#root.append(this.renderer); - if (book.sections.some(section => section.mediaOverlay)) { - const activeClass = book.media.activeClass - const playbackActiveClass = book.media.playbackActiveClass - this.mediaOverlay = book.getMediaOverlay() - let lastActive - this.mediaOverlay.addEventListener('highlight', e => { - const resolved = this.resolveNavigation(e.detail.text) - this.renderer.goTo(resolved) - .then(() => { - const { doc } = this.renderer.getContents() - .find(x => x.index = resolved.index) - const el = resolved.anchor(doc) - el.classList.add(activeClass) - if (playbackActiveClass) el.ownerDocument - .documentElement.classList.add(playbackActiveClass) - lastActive = new WeakRef(el) - }) - }) - this.mediaOverlay.addEventListener('unhighlight', () => { - const el = lastActive?.deref() - if (el) { - el.classList.remove(activeClass) - if (playbackActiveClass) el.ownerDocument - .documentElement.classList.remove(playbackActiveClass) - } - }) + if (book.sections.some((section) => section.mediaOverlay)) { + const activeClass = book.media.activeClass; + const playbackActiveClass = book.media.playbackActiveClass; + this.mediaOverlay = book.getMediaOverlay(); + let lastActive; + this.mediaOverlay.addEventListener("highlight", (e) => { + const resolved = this.resolveNavigation(e.detail.text); + this.renderer.goTo(resolved).then(() => { + const { doc } = this.renderer + .getContents() + .find((x) => (x.index = resolved.index)); + const el = resolved.anchor(doc); + el.classList.add(activeClass); + if (playbackActiveClass) + el.ownerDocument.documentElement.classList.add(playbackActiveClass); + lastActive = new WeakRef(el); + }); + }); + this.mediaOverlay.addEventListener("unhighlight", () => { + const el = lastActive?.deref(); + if (el) { + el.classList.remove(activeClass); + if (playbackActiveClass) + el.ownerDocument.documentElement.classList.remove( + playbackActiveClass, + ); } + }); } - close() { - this.renderer?.destroy() - this.renderer?.remove() - this.#sectionProgress = null - this.#tocProgress = null - this.#pageProgress = null - this.#searchResults = new Map() - this.lastLocation = null - this.history.clear() - this.tts = null - this.mediaOverlay = null + } + close() { + this.renderer?.destroy(); + this.renderer?.remove(); + this.#sectionProgress = null; + this.#tocProgress = null; + this.#pageProgress = null; + this.#searchResults = new Map(); + this.lastLocation = null; + this.history.clear(); + this.tts = null; + this.mediaOverlay = null; + } + goToTextStart() { + return this.goTo( + this.book.landmarks?.find( + (m) => m.type.includes("bodymatter") || m.type.includes("text"), + )?.href ?? this.book.sections.findIndex((s) => s.linear !== "no"), + ); + } + async init({ lastLocation, showTextStart }) { + const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null; + if (resolved) { + await this.renderer.goTo(resolved); + this.history.pushState(lastLocation); + } else if (showTextStart) await this.goToTextStart(); + else { + this.history.pushState(0); + await this.next(); } - goToTextStart() { - return this.goTo(this.book.landmarks - ?.find(m => m.type.includes('bodymatter') || m.type.includes('text')) - ?.href ?? this.book.sections.findIndex(s => s.linear !== 'no')) - } - async init({ lastLocation, showTextStart }) { - const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null - if (resolved) { - await this.renderer.goTo(resolved) - this.history.pushState(lastLocation) - } - else if (showTextStart) await this.goToTextStart() - else { - this.history.pushState(0) - await this.next() - } - } - #emit(name, detail, cancelable) { - return this.dispatchEvent(new CustomEvent(name, { detail, cancelable })) - } - #onRelocate({ reason, range, index, fraction, size }) { - const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {} - const tocItem = this.#tocProgress?.getProgress(index, range) - const pageItem = this.#pageProgress?.getProgress(index, range) - const cfi = this.getCFI(index, range) - this.lastLocation = { ...progress, tocItem, pageItem, cfi, range } - if (reason === 'snap' || reason === 'page' || reason === 'scroll') - this.history.replaceState(cfi) - this.#emit('relocate', this.lastLocation) - } - #onLoad({ doc, index }) { - // set language and dir if not already set - doc.documentElement.lang ||= this.language.canonical ?? '' - if (!this.language.isCJK) - doc.documentElement.dir ||= this.language.direction ?? '' + } + #emit(name, detail, cancelable) { + return this.dispatchEvent(new CustomEvent(name, { detail, cancelable })); + } + #onRelocate({ reason, range, index, fraction, size }) { + const progress = + this.#sectionProgress?.getProgress(index, fraction, size) ?? {}; + const tocItem = this.#tocProgress?.getProgress(index, range); + const pageItem = this.#pageProgress?.getProgress(index, range); + const cfi = this.getCFI(index, range); + this.lastLocation = { ...progress, tocItem, pageItem, cfi, range }; + if (reason === "snap" || reason === "page" || reason === "scroll") + this.history.replaceState(cfi); + this.#emit("relocate", this.lastLocation); + } + #onLoad({ doc, index }) { + // set language and dir if not already set + doc.documentElement.lang ||= this.language.canonical ?? ""; + if (!this.language.isCJK) + doc.documentElement.dir ||= this.language.direction ?? ""; - this.#handleLinks(doc, index) - this.#cursorAutohider.cloneFor(doc.documentElement) + this.#handleLinks(doc, index); + this.#cursorAutohider.cloneFor(doc.documentElement); - this.#emit('load', { doc, index }) - } - #handleLinks(doc, index) { - const { book } = this - const section = book.sections[index] - doc.addEventListener('click', e => { - const a = e.target.closest('a[href]') - if (!a) return - e.preventDefault() - const href_ = a.getAttribute('href') - const href = section?.resolveHref?.(href_) ?? href_ - if (book?.isExternal?.(href)) - Promise.resolve(this.#emit('external-link', { a, href }, true)) - .then(x => x ? globalThis.open(href, '_blank') : null) - .catch(e => console.error(e)) - else Promise.resolve(this.#emit('link', { a, href }, true)) - .then(x => x ? this.goTo(href) : null) - .catch(e => console.error(e)) - }) - } - async addAnnotation(annotation, remove) { - const { value } = annotation - if (value.startsWith(SEARCH_PREFIX)) { - const cfi = value.replace(SEARCH_PREFIX, '') - const { index, anchor } = await this.resolveNavigation(cfi) - const obj = this.#getOverlayer(index) - if (obj) { - const { overlayer, doc } = obj - if (remove) { - overlayer.remove(value) - return - } - const range = doc ? anchor(doc) : anchor - overlayer.add(value, range, this.#searchDraw, this.#searchDrawOptions) - } - return + this.#emit("load", { doc, index }); + } + #handleLinks(doc, index) { + const { book } = this; + const section = book.sections[index]; + doc.addEventListener("click", (e) => { + const a = e.target.closest("a[href]"); + if (!a) return; + e.preventDefault(); + const href_ = a.getAttribute("href"); + const href = section?.resolveHref?.(href_) ?? href_; + if (book?.isExternal?.(href)) + Promise.resolve(this.#emit("external-link", { a, href }, true)) + .then((x) => (x ? globalThis.open(href, "_blank") : null)) + .catch((e) => console.error(e)); + else + Promise.resolve(this.#emit("link", { a, href }, true)) + .then((x) => (x ? this.goTo(href) : null)) + .catch((e) => console.error(e)); + }); + } + async addAnnotation(annotation, remove) { + const { value } = annotation; + if (value.startsWith(SEARCH_PREFIX)) { + const cfi = value.replace(SEARCH_PREFIX, ""); + const { index, anchor } = await this.resolveNavigation(cfi); + const obj = this.#getOverlayer(index); + if (obj) { + const { overlayer, doc } = obj; + if (remove) { + overlayer.remove(value); + return; } - const { index, anchor } = await this.resolveNavigation(value) - const obj = this.#getOverlayer(index) - if (obj) { - const { overlayer, doc } = obj - overlayer.remove(value) - if (!remove) { - const range = doc ? anchor(doc) : anchor - const draw = (func, opts) => overlayer.add(value, range, func, opts) - this.#emit('draw-annotation', { draw, annotation, doc, range }) - } + const range = doc ? anchor(doc) : anchor; + overlayer.add(value, range, this.#searchDraw, this.#searchDrawOptions); + } + return; + } + const { index, anchor } = await this.resolveNavigation(value); + const obj = this.#getOverlayer(index); + if (obj) { + const { overlayer, doc } = obj; + overlayer.remove(value); + if (!remove) { + const range = doc ? anchor(doc) : anchor; + const draw = (func, opts) => overlayer.add(value, range, func, opts); + this.#emit("draw-annotation", { draw, annotation, doc, range }); + } + } + const label = this.#tocProgress.getProgress(index)?.label ?? ""; + return { index, label }; + } + deleteAnnotation(annotation) { + return this.addAnnotation(annotation, true); + } + #getOverlayer(index) { + return this.renderer + .getContents() + .find((x) => x.index === index && x.overlayer); + } + #createOverlayer({ doc, index }) { + const overlayer = new Overlayer(); + doc.addEventListener( + "click", + (e) => { + const [value, range] = overlayer.hitTest(e); + if (value && !value.startsWith(SEARCH_PREFIX)) { + this.#emit("show-annotation", { value, index, range }); } - const label = this.#tocProgress.getProgress(index)?.label ?? '' - return { index, label } - } - deleteAnnotation(annotation) { - return this.addAnnotation(annotation, true) - } - #getOverlayer(index) { - return this.renderer.getContents() - .find(x => x.index === index && x.overlayer) - } - #createOverlayer({ doc, index }) { - const overlayer = new Overlayer() - doc.addEventListener('click', e => { - const [value, range] = overlayer.hitTest(e) - if (value && !value.startsWith(SEARCH_PREFIX)) { - this.#emit('show-annotation', { value, index, range }) - } - }, false) + }, + false, + ); - const list = this.#searchResults.get(index) - if (list) for (const item of list) this.addAnnotation(item) + const list = this.#searchResults.get(index); + if (list) for (const item of list) this.addAnnotation(item); - this.#emit('create-overlay', { index }) - return overlayer + this.#emit("create-overlay", { index }); + return overlayer; + } + async showAnnotation(annotation) { + const { value } = annotation; + const resolved = await this.goTo(value); + if (resolved) { + const { index, anchor } = resolved; + const { doc } = this.#getOverlayer(index); + const range = anchor(doc); + this.#emit("show-annotation", { value, index, range }); } - async showAnnotation(annotation) { - const { value } = annotation - const resolved = await this.goTo(value) - if (resolved) { - const { index, anchor } = resolved - const { doc } = this.#getOverlayer(index) - const range = anchor(doc) - this.#emit('show-annotation', { value, index, range }) - } + } + getCFI(index, range) { + const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index); + if (!range) return baseCFI; + return CFI.joinIndir(baseCFI, CFI.fromRange(range)); + } + resolveCFI(cfi) { + if (this.book.resolveCFI) return this.book.resolveCFI(cfi); + else { + const parts = CFI.parse(cfi); + const index = CFI.fake.toIndex((parts.parent ?? parts).shift()); + const anchor = (doc) => CFI.toRange(doc, parts); + return { index, anchor }; } - getCFI(index, range) { - const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index) - if (!range) return baseCFI - return CFI.joinIndir(baseCFI, CFI.fromRange(range)) + } + resolveNavigation(target) { + try { + if (typeof target === "number") return { index: target }; + if (typeof target.fraction === "number") { + const [index, anchor] = this.#sectionProgress.getSection( + target.fraction, + ); + return { index, anchor }; + } + if (CFI.isCFI.test(target)) return this.resolveCFI(target); + return this.book.resolveHref(target); + } catch (e) { + console.error(e); + console.error(`Could not resolve target ${target}`); } - resolveCFI(cfi) { - if (this.book.resolveCFI) - return this.book.resolveCFI(cfi) - else { - const parts = CFI.parse(cfi) - const index = CFI.fake.toIndex((parts.parent ?? parts).shift()) - const anchor = doc => CFI.toRange(doc, parts) - return { index, anchor } - } + } + async goTo(target) { + const resolved = this.resolveNavigation(target); + try { + await this.renderer.goTo(resolved); + this.history.pushState(target); + return resolved; + } catch (e) { + console.error(e); + console.error(`Could not go to ${target}`); } - resolveNavigation(target) { - try { - if (typeof target === 'number') return { index: target } - if (typeof target.fraction === 'number') { - const [index, anchor] = this.#sectionProgress.getSection(target.fraction) - return { index, anchor } - } - if (CFI.isCFI.test(target)) return this.resolveCFI(target) - return this.book.resolveHref(target) - } catch (e) { - console.error(e) - console.error(`Could not resolve target ${target}`) - } + } + async goToFraction(frac) { + const [index, anchor] = this.#sectionProgress.getSection(frac); + await this.renderer.goTo({ index, anchor }); + this.history.pushState({ fraction: frac }); + } + async select(target) { + try { + const obj = await this.resolveNavigation(target); + await this.renderer.goTo({ ...obj, select: true }); + this.history.pushState(target); + } catch (e) { + console.error(e); + console.error(`Could not go to ${target}`); } - async goTo(target) { - const resolved = this.resolveNavigation(target) - try { - await this.renderer.goTo(resolved) - this.history.pushState(target) - return resolved - } catch(e) { - console.error(e) - console.error(`Could not go to ${target}`) - } + } + deselect() { + for (const { doc } of this.renderer.getContents()) + doc.defaultView.getSelection().removeAllRanges(); + } + getSectionFractions() { + return (this.#sectionProgress?.sectionFractions ?? []).map( + (x) => x + Number.EPSILON, + ); + } + getProgressOf(index, range) { + const tocItem = this.#tocProgress?.getProgress(index, range); + const pageItem = this.#pageProgress?.getProgress(index, range); + return { tocItem, pageItem }; + } + async getTOCItemOf(target) { + try { + const { index, anchor } = await this.resolveNavigation(target); + const doc = await this.book.sections[index].createDocument(); + const frag = anchor(doc); + const isRange = frag instanceof Range; + const range = isRange ? frag : doc.createRange(); + if (!isRange) range.selectNodeContents(frag); + return this.#tocProgress.getProgress(index, range); + } catch (e) { + console.error(e); + console.error(`Could not get ${target}`); } - async goToFraction(frac) { - const [index, anchor] = this.#sectionProgress.getSection(frac) - await this.renderer.goTo({ index, anchor }) - this.history.pushState({ fraction: frac }) + } + async prev(distance) { + await this.renderer.prev(distance); + } + async next(distance) { + await this.renderer.next(distance); + } + goLeft() { + return this.book.dir === "rtl" ? this.next() : this.prev(); + } + goRight() { + return this.book.dir === "rtl" ? this.prev() : this.next(); + } + async *#searchSection(matcher, query, index) { + const doc = await this.book.sections[index].createDocument(); + for (const { range, excerpt } of matcher(doc, query)) + yield { cfi: this.getCFI(index, range), excerpt }; + } + async *#searchBook(matcher, query) { + const { sections } = this.book; + for (const [index, { createDocument }] of sections.entries()) { + if (!createDocument) continue; + const doc = await createDocument(); + const subitems = Array.from( + matcher(doc, query), + ({ range, excerpt }) => ({ cfi: this.getCFI(index, range), excerpt }), + ); + const progress = (index + 1) / sections.length; + yield { progress }; + if (subitems.length) yield { index, subitems }; } - async select(target) { - try { - const obj = await this.resolveNavigation(target) - await this.renderer.goTo({ ...obj, select: true }) - this.history.pushState(target) - } catch(e) { - console.error(e) - console.error(`Could not go to ${target}`) - } - } - deselect() { - for (const { doc } of this.renderer.getContents()) - doc.defaultView.getSelection().removeAllRanges() - } - getSectionFractions() { - return (this.#sectionProgress?.sectionFractions ?? []) - .map(x => x + Number.EPSILON) - } - getProgressOf(index, range) { - const tocItem = this.#tocProgress?.getProgress(index, range) - const pageItem = this.#pageProgress?.getProgress(index, range) - return { tocItem, pageItem } - } - async getTOCItemOf(target) { - try { - const { index, anchor } = await this.resolveNavigation(target) - const doc = await this.book.sections[index].createDocument() - const frag = anchor(doc) - const isRange = frag instanceof Range - const range = isRange ? frag : doc.createRange() - if (!isRange) range.selectNodeContents(frag) - return this.#tocProgress.getProgress(index, range) - } catch(e) { - console.error(e) - console.error(`Could not get ${target}`) - } - } - async prev(distance) { - await this.renderer.prev(distance) - } - async next(distance) { - await this.renderer.next(distance) - } - goLeft() { - return this.book.dir === 'rtl' ? this.next() : this.prev() - } - goRight() { - return this.book.dir === 'rtl' ? this.prev() : this.next() - } - async * #searchSection(matcher, query, index) { - const doc = await this.book.sections[index].createDocument() - for (const { range, excerpt } of matcher(doc, query)) - yield { cfi: this.getCFI(index, range), excerpt } - } - async * #searchBook(matcher, query) { - const { sections } = this.book - for (const [index, { createDocument }] of sections.entries()) { - if (!createDocument) continue - const doc = await createDocument() - const subitems = Array.from(matcher(doc, query), ({ range, excerpt }) => - ({ cfi: this.getCFI(index, range), excerpt })) - const progress = (index + 1) / sections.length - yield { progress } - if (subitems.length) yield { index, subitems } - } - } - async * search(opts) { - this.clearSearch() - this.#searchDraw = opts.draw ?? Overlayer.outline - this.#searchDrawOptions = opts.drawOptions - const { searchMatcher } = await import('./search.js') - const { query, index } = opts - const matcher = searchMatcher(textWalker, - { defaultLocale: this.language, ...opts }) - const iter = index != null - ? this.#searchSection(matcher, query, index) - : this.#searchBook(matcher, query) + } + async *search(opts) { + this.clearSearch(); + this.#searchDraw = opts.draw ?? Overlayer.outline; + this.#searchDrawOptions = opts.drawOptions; + const { searchMatcher } = await import("./search.js"); + const { query, index } = opts; + const matcher = searchMatcher(textWalker, { + defaultLocale: this.language, + ...opts, + }); + const iter = + index != null + ? this.#searchSection(matcher, query, index) + : this.#searchBook(matcher, query); - const list = [] - this.#searchResults.set(index, list) + const list = []; + this.#searchResults.set(index, list); - for await (const result of iter) { - if (result.subitems){ - const list = result.subitems - .map(({ cfi }) => ({ value: SEARCH_PREFIX + cfi })) - this.#searchResults.set(result.index, list) - for (const item of list) this.addAnnotation(item) - yield { - label: this.#tocProgress.getProgress(result.index)?.label ?? '', - subitems: result.subitems, - } - } - else { - if (result.cfi) { - const item = { value: SEARCH_PREFIX + result.cfi } - list.push(item) - this.addAnnotation(item) - } - yield result - } + for await (const result of iter) { + if (result.subitems) { + const list = result.subitems.map(({ cfi }) => ({ + value: SEARCH_PREFIX + cfi, + })); + this.#searchResults.set(result.index, list); + for (const item of list) this.addAnnotation(item); + yield { + label: this.#tocProgress.getProgress(result.index)?.label ?? "", + subitems: result.subitems, + }; + } else { + if (result.cfi) { + const item = { value: SEARCH_PREFIX + result.cfi }; + list.push(item); + this.addAnnotation(item); } - yield 'done' - } - clearSearch() { - for (const list of this.#searchResults.values()) - for (const item of list) this.deleteAnnotation(item) - this.#searchResults.clear() - } - async initTTS(granularity = 'word', highlight) { - const doc = this.renderer.getContents()[0].doc - if (this.tts && this.tts.doc === doc) return - const { TTS } = await import('./tts.js') - this.tts = new TTS(doc, textWalker, highlight || (range => - this.renderer.scrollToAnchor(range, true)), granularity) - } - startMediaOverlay() { - const { index } = this.renderer.getContents()[0] - return this.mediaOverlay.start(index) + yield result; + } } + yield "done"; + } + clearSearch() { + for (const list of this.#searchResults.values()) + for (const item of list) this.deleteAnnotation(item); + this.#searchResults.clear(); + } + async initTTS(granularity = "word", highlight) { + const doc = this.renderer.getContents()[0].doc; + if (this.tts && this.tts.doc === doc) return; + const { TTS } = await import("./tts.js"); + this.tts = new TTS( + doc, + textWalker, + highlight || ((range) => this.renderer.scrollToAnchor(range, true)), + granularity, + ); + } + startMediaOverlay() { + const { index } = this.renderer.getContents()[0]; + return this.mediaOverlay.start(index); + } } -customElements.define('foliate-view', View) +customElements.define("foliate-view", View);