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);