From 244d38afe8b3f45846458e24734fd5ff2ee49815 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 16 Jan 2026 02:00:38 -0600 Subject: [PATCH] feat(reader): add settings system with per-book delta and defaults --- client/public/assets/foliate/comic-book.js | 84 +- client/public/assets/foliate/dict.js | 419 +-- client/public/assets/foliate/epub.js | 2074 ++++++------ client/public/assets/foliate/epubcfi.js | 536 ++-- client/public/assets/foliate/fb2.js | 550 ++-- client/public/assets/foliate/fixed-layout.js | 565 ++-- client/public/assets/foliate/mobi.js | 2209 ++++++------- client/public/assets/foliate/overlayer.js | 336 +- client/public/assets/foliate/paginator.js | 1877 ++++++----- client/public/assets/foliate/progress.js | 194 +- client/public/assets/foliate/search.js | 217 +- .../public/assets/foliate/streaming-loader.js | 12 +- client/public/assets/foliate/text-walker.js | 62 +- client/public/assets/foliate/tts.js | 489 +-- client/public/assets/foliate/vendor/fflate.js | 221 +- client/public/assets/foliate/vendor/zip.js | 2768 ++++++++++++++++- client/public/assets/foliate/view.js | 385 +-- client/src/components/ui/sonner/Sonner.vue | 3 +- .../library/components/FolderPickerModal.vue | 34 +- .../components/LibraryCreatorFolders.vue | 5 +- client/src/features/reader/ReaderView.vue | 73 +- .../reader/components/CbzReaderView.vue | 21 + .../reader/components/PdfReaderView.vue | 19 + .../reader/composables/useCbzSettings.ts | 45 +- .../reader/composables/useReaderSettings.ts | 196 ++ .../reader/composables/useReaderState.ts | 44 +- .../src/features/settings/ComicsSettings.vue | 166 +- .../src/features/settings/EbookSettings.vue | 280 +- .../features/settings/LibrariesSettings.vue | 4 +- client/src/features/settings/OidcSettings.vue | 128 +- client/src/features/settings/PdfSettings.vue | 190 +- .../src/features/settings/ReaderSettings.vue | 134 +- .../src/features/settings/SettingsDrawer.vue | 5 +- client/tsconfig.node.json | 9 +- client/vite.config.ts | 8 +- packages/types/src/auth.ts | 68 +- packages/types/src/index.ts | 5 +- packages/types/src/library.ts | 4 +- packages/types/src/reader-settings.ts | 96 + server/eslint.config.mjs | 2 +- server/src/app.module.ts | 2 + server/src/db/migrations/0000_baseline.sql | 21 + .../src/db/migrations/meta/0000_snapshot.json | 449 +-- server/src/db/migrations/meta/_journal.json | 2 +- server/src/db/schema/reader.ts | 38 +- server/src/modules/auth/jwt.strategy.ts | 5 +- .../dto/upsert-preference.dto.ts | 6 + .../reader-preferences.controller.ts | 47 + .../reader-preferences.module.ts | 11 + .../reader-preferences.repository.ts | 57 + .../reader-preferences.service.ts | 43 + server/src/modules/user/user.repository.ts | 8 +- server/test/app.e2e-spec.ts | 5 +- 53 files changed, 9554 insertions(+), 5677 deletions(-) create mode 100644 client/src/features/reader/composables/useReaderSettings.ts create mode 100644 packages/types/src/reader-settings.ts create mode 100644 server/src/modules/reader-preferences/dto/upsert-preference.dto.ts create mode 100644 server/src/modules/reader-preferences/reader-preferences.controller.ts create mode 100644 server/src/modules/reader-preferences/reader-preferences.module.ts create mode 100644 server/src/modules/reader-preferences/reader-preferences.repository.ts create mode 100644 server/src/modules/reader-preferences/reader-preferences.service.ts diff --git a/client/public/assets/foliate/comic-book.js b/client/public/assets/foliate/comic-book.js index 88c7a35a4..76b10a6cb 100644 --- a/client/public/assets/foliate/comic-book.js +++ b/client/public/assets/foliate/comic-book.js @@ -1,45 +1,47 @@ 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() - 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() + 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/client/public/assets/foliate/dict.js b/client/public/assets/foliate/dict.js index 9afa7ea1e..81c77e6b5 100644 --- a/client/public/assets/foliate/dict.js +++ b/client/public/assets/foliate/dict.js @@ -2,240 +2,249 @@ const decoder = new TextDecoder() const decode = decoder.decode.bind(decoder) const concatTypedArray = (a, b) => { - const result = new a.constructor(a.length + b.length) - result.set(a) - result.set(b, a.length) - return result + const result = new a.constructor(a.length + b.length) + result.set(a) + result.set(b, a.length) + return result } const strcmp = (a, b) => { - a = a.toLowerCase(), b = b.toLowerCase() - return a < b ? -1 : a > b ? 1 : 0 + ;((a = a.toLowerCase()), (b = b.toLowerCase())) + return a < b ? -1 : a > b ? 1 : 0 } class DictZip { - #chlen - #chunks - #compressed - inflate - async load(file) { - const header = new DataView(await file.slice(0, 12).arrayBuffer()) - if (header.getUint8(0) !== 31 || header.getUint8(1) !== 139 - || header.getUint8(2) !== 8) throw new Error('Not a DictZip file') - const flg = header.getUint8(3) - if (!flg & 0b100) throw new Error('Missing FEXTRA flag') + #chlen + #chunks + #compressed + inflate + async load(file) { + const header = new DataView(await file.slice(0, 12).arrayBuffer()) + if (header.getUint8(0) !== 31 || header.getUint8(1) !== 139 || header.getUint8(2) !== 8) throw new Error('Not a DictZip file') + const flg = header.getUint8(3) + if (!flg & 0b100) throw new Error('Missing FEXTRA flag') - const xlen = header.getUint16(10, true) - const extra = new DataView(await file.slice(12, 12 + xlen).arrayBuffer()) - if (extra.getUint8(0) !== 82 || extra.getUint8(1) !== 65) - throw new Error('Subfield ID should be RA') - if (extra.getUint16(4, true) !== 1) throw new Error('Unsupported version') + const xlen = header.getUint16(10, true) + const extra = new DataView(await file.slice(12, 12 + xlen).arrayBuffer()) + if (extra.getUint8(0) !== 82 || extra.getUint8(1) !== 65) throw new Error('Subfield ID should be RA') + if (extra.getUint16(4, true) !== 1) throw new Error('Unsupported version') - this.#chlen = extra.getUint16(6, true) - const chcnt = extra.getUint16(8, true) - this.#chunks = [] - for (let i = 0, chunkOffset = 0; i < chcnt; i++) { - const chunkSize = extra.getUint16(10 + 2 * i, true) - this.#chunks.push([chunkOffset, chunkSize]) - chunkOffset = chunkOffset + chunkSize - } - - // skip to compressed data - let offset = 12 + xlen - const max = Math.min(offset + 512, file.size) - const strArr = new Uint8Array(await file.slice(0, max).arrayBuffer()) - if (flg & 0b1000) { // fname - const i = strArr.indexOf(0, offset) - if (i < 0) throw new Error('Header too long') - offset = i + 1 - } - if (flg & 0b10000) { // fcomment - const i = strArr.indexOf(0, offset) - if (i < 0) throw new Error('Header too long') - offset = i + 1 - } - if (flg & 0b10) offset += 2 // fhcrc - this.#compressed = file.slice(offset) + this.#chlen = extra.getUint16(6, true) + const chcnt = extra.getUint16(8, true) + this.#chunks = [] + for (let i = 0, chunkOffset = 0; i < chcnt; i++) { + const chunkSize = extra.getUint16(10 + 2 * i, true) + this.#chunks.push([chunkOffset, chunkSize]) + chunkOffset = chunkOffset + chunkSize } - async read(offset, size) { - const chunks = this.#chunks - const startIndex = Math.trunc(offset / this.#chlen) - const endIndex = Math.trunc((offset + size) / this.#chlen) - const buf = await this.#compressed.slice(chunks[startIndex][0], - chunks[endIndex][0] + chunks[endIndex][1]).arrayBuffer() - let arr = new Uint8Array() - for (let pos = 0, i = startIndex; i <= endIndex; i++) { - const data = new Uint8Array(buf, pos, chunks[i][1]) - arr = concatTypedArray(arr, await this.inflate(data)) - pos += chunks[i][1] - } - const startOffset = offset - startIndex * this.#chlen - return arr.subarray(startOffset, startOffset + size) + + // skip to compressed data + let offset = 12 + xlen + const max = Math.min(offset + 512, file.size) + const strArr = new Uint8Array(await file.slice(0, max).arrayBuffer()) + if (flg & 0b1000) { + // fname + const i = strArr.indexOf(0, offset) + if (i < 0) throw new Error('Header too long') + offset = i + 1 } + if (flg & 0b10000) { + // fcomment + const i = strArr.indexOf(0, offset) + if (i < 0) throw new Error('Header too long') + offset = i + 1 + } + if (flg & 0b10) offset += 2 // fhcrc + this.#compressed = file.slice(offset) + } + async read(offset, size) { + const chunks = this.#chunks + const startIndex = Math.trunc(offset / this.#chlen) + const endIndex = Math.trunc((offset + size) / this.#chlen) + const buf = await this.#compressed.slice(chunks[startIndex][0], chunks[endIndex][0] + chunks[endIndex][1]).arrayBuffer() + let arr = new Uint8Array() + for (let pos = 0, i = startIndex; i <= endIndex; i++) { + const data = new Uint8Array(buf, pos, chunks[i][1]) + arr = concatTypedArray(arr, await this.inflate(data)) + pos += chunks[i][1] + } + const startOffset = offset - startIndex * this.#chlen + return arr.subarray(startOffset, startOffset + size) + } } class Index { - strcmp = strcmp - // binary search - bisect(query, start = 0, end = this.words.length - 1) { - if (end - start === 1) { - if (!this.strcmp(query, this.getWord(start))) return start - if (!this.strcmp(query, this.getWord(end))) return end - return null - } - const mid = Math.floor(start + (end - start) / 2) - const cmp = this.strcmp(query, this.getWord(mid)) - if (cmp < 0) return this.bisect(query, start, mid) - if (cmp > 0) return this.bisect(query, mid, end) - return mid + strcmp = strcmp + // binary search + bisect(query, start = 0, end = this.words.length - 1) { + if (end - start === 1) { + if (!this.strcmp(query, this.getWord(start))) return start + if (!this.strcmp(query, this.getWord(end))) return end + return null } - // check for multiple definitions - checkAdjacent(query, i) { - if (i == null) return [] - let j = i - const equals = i => { - const word = this.getWord(i) - return word ? this.strcmp(query, word) === 0 : false - } - while (equals(j - 1)) j-- - let k = i - while (equals(k + 1)) k++ - return j === k ? [i] : Array.from({ length: k + 1 - j }, (_, i) => j + i) - } - lookup(query) { - return this.checkAdjacent(query, this.bisect(query)) + const mid = Math.floor(start + (end - start) / 2) + const cmp = this.strcmp(query, this.getWord(mid)) + if (cmp < 0) return this.bisect(query, start, mid) + if (cmp > 0) return this.bisect(query, mid, end) + return mid + } + // check for multiple definitions + checkAdjacent(query, i) { + if (i == null) return [] + let j = i + const equals = (i) => { + const word = this.getWord(i) + return word ? this.strcmp(query, word) === 0 : false } + while (equals(j - 1)) j-- + let k = i + while (equals(k + 1)) k++ + return j === k ? [i] : Array.from({ length: k + 1 - j }, (_, i) => j + i) + } + lookup(query) { + return this.checkAdjacent(query, this.bisect(query)) + } } -const decodeBase64Number = str => { - const { length } = str - let n = 0 - for (let i = 0; i < length; i++) { - const c = str.charCodeAt(i) - n += (c === 43 ? 62 // "+" - : c === 47 ? 63 // "/" - : c < 58 ? c + 4 // 0-9 -> 52-61 - : c < 91 ? c - 65 // A-Z -> 0-25 - : c - 71 // a-z -> 26-51 - ) * 64 ** (length - 1 - i) - } - return n +const decodeBase64Number = (str) => { + const { length } = str + let n = 0 + for (let i = 0; i < length; i++) { + const c = str.charCodeAt(i) + n += + (c === 43 + ? 62 // "+" + : c === 47 + ? 63 // "/" + : c < 58 + ? c + 4 // 0-9 -> 52-61 + : c < 91 + ? c - 65 // A-Z -> 0-25 + : c - 71) * // a-z -> 26-51 + 64 ** (length - 1 - i) + } + return n } class DictdIndex extends Index { - getWord(i) { - return this.words[i] - } - async load(file) { - const words = [] - const offsets = [] - const sizes = [] - for (const line of decode(await file.arrayBuffer()).split('\n')) { - const a = line.split('\t') - words.push(a[0]) - offsets.push(decodeBase64Number(a[1])) - sizes.push(decodeBase64Number(a[2])) - } - this.words = words - this.offsets = offsets - this.sizes = sizes + getWord(i) { + return this.words[i] + } + async load(file) { + const words = [] + const offsets = [] + const sizes = [] + for (const line of decode(await file.arrayBuffer()).split('\n')) { + const a = line.split('\t') + words.push(a[0]) + offsets.push(decodeBase64Number(a[1])) + sizes.push(decodeBase64Number(a[2])) } + this.words = words + this.offsets = offsets + this.sizes = sizes + } } export class DictdDict { - #dict = new DictZip() - #idx = new DictdIndex() - loadDict(file, inflate) { - this.#dict.inflate = inflate - return this.#dict.load(file) - } - async #readWord(i) { - const word = this.#idx.getWord(i) - const offset = this.#idx.offsets[i] - const size = this.#idx.sizes[i] - return { word, data: ['m', this.#dict.read(offset, size)] } - } - #readWords(arr) { - return Promise.all(arr.map(this.#readWord.bind(this))) - } - lookup(query) { - return this.#readWords(this.#idx.lookup(query)) - } + #dict = new DictZip() + #idx = new DictdIndex() + loadDict(file, inflate) { + this.#dict.inflate = inflate + return this.#dict.load(file) + } + async #readWord(i) { + const word = this.#idx.getWord(i) + const offset = this.#idx.offsets[i] + const size = this.#idx.sizes[i] + return { word, data: ['m', this.#dict.read(offset, size)] } + } + #readWords(arr) { + return Promise.all(arr.map(this.#readWord.bind(this))) + } + lookup(query) { + return this.#readWords(this.#idx.lookup(query)) + } } class StarDictIndex extends Index { - isSyn - #arr - getWord(i) { - const word = this.words[i] - if (!word) return - return decode(this.#arr.subarray(word[0], word[1])) - } - async load(file) { - const { isSyn } = this - const buf = await file.arrayBuffer() - const arr = new Uint8Array(buf) - this.#arr = arr - const view = new DataView(buf) - const words = [] - const offsets = [] - const sizes = [] - for (let i = 0; i < arr.length;) { - const newI = arr.subarray(0, i + 256).indexOf(0, i) - if (newI < 0) throw new Error('Word too big') - words.push([i, newI]) - offsets.push(view.getUint32(newI + 1)) - if (isSyn) i = newI + 5 - else { - sizes.push(view.getUint32(newI + 5)) - i = newI + 9 - } - } - this.words = words - this.offsets = offsets - this.sizes = sizes + isSyn + #arr + getWord(i) { + const word = this.words[i] + if (!word) return + return decode(this.#arr.subarray(word[0], word[1])) + } + async load(file) { + const { isSyn } = this + const buf = await file.arrayBuffer() + const arr = new Uint8Array(buf) + this.#arr = arr + const view = new DataView(buf) + const words = [] + const offsets = [] + const sizes = [] + for (let i = 0; i < arr.length; ) { + const newI = arr.subarray(0, i + 256).indexOf(0, i) + if (newI < 0) throw new Error('Word too big') + words.push([i, newI]) + offsets.push(view.getUint32(newI + 1)) + if (isSyn) i = newI + 5 + else { + sizes.push(view.getUint32(newI + 5)) + i = newI + 9 + } } + this.words = words + this.offsets = offsets + this.sizes = sizes + } } export class StarDict { - #dict = new DictZip() - #idx = new StarDictIndex() - #syn = Object.assign(new StarDictIndex(), { isSyn: true }) - async loadIfo(file) { - const str = decode(await file.arrayBuffer()) - this.ifo = Object.fromEntries(str.split('\n').map(line => { - const sep = line.indexOf('=') - if (sep < 0) return - return [line.slice(0, sep), line.slice(sep + 1)] - }).filter(x => x)) - } - loadDict(file, inflate) { - this.#dict.inflate = inflate - return this.#dict.load(file) - } - loadIdx(file) { - return this.#idx.load(file) - } - loadSyn(file) { - if (file) return this.#syn.load(file) - } - async #readWord(i) { - const word = this.#idx.getWord(i) - const offset = this.#idx.offsets[i] - const size = this.#idx.sizes[i] - const data = await this.#dict.read(offset, size) - const seq = this.ifo.sametypesequence - if (!seq) throw new Error('TODO') - if (seq.length === 1) return { word, data: [[seq[0], data]] } - throw new Error('TODO') - } - #readWords(arr) { - return Promise.all(arr.map(this.#readWord.bind(this))) - } - lookup(query) { - return this.#readWords(this.#idx.lookup(query)) - } - synonyms(query) { - return this.#readWords(this.#syn.lookup(query).map(i => this.#syn.offsets[i])) - } + #dict = new DictZip() + #idx = new StarDictIndex() + #syn = Object.assign(new StarDictIndex(), { isSyn: true }) + async loadIfo(file) { + const str = decode(await file.arrayBuffer()) + this.ifo = Object.fromEntries( + str + .split('\n') + .map((line) => { + const sep = line.indexOf('=') + if (sep < 0) return + return [line.slice(0, sep), line.slice(sep + 1)] + }) + .filter((x) => x), + ) + } + loadDict(file, inflate) { + this.#dict.inflate = inflate + return this.#dict.load(file) + } + loadIdx(file) { + return this.#idx.load(file) + } + loadSyn(file) { + if (file) return this.#syn.load(file) + } + async #readWord(i) { + const word = this.#idx.getWord(i) + const offset = this.#idx.offsets[i] + const size = this.#idx.sizes[i] + const data = await this.#dict.read(offset, size) + const seq = this.ifo.sametypesequence + if (!seq) throw new Error('TODO') + if (seq.length === 1) return { word, data: [[seq[0], data]] } + throw new Error('TODO') + } + #readWords(arr) { + return Promise.all(arr.map(this.#readWord.bind(this))) + } + lookup(query) { + return this.#readWords(this.#idx.lookup(query)) + } + synonyms(query) { + return this.#readWords(this.#syn.lookup(query).map((i) => this.#syn.offsets[i])) + } } diff --git a/client/public/assets/foliate/epub.js b/client/public/assets/foliate/epub.js index c837ee839..00f5f04c3 100644 --- a/client/public/assets/foliate/epub.js +++ b/client/public/assets/foliate/epub.js @@ -1,1184 +1,1162 @@ 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) +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 + ? (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: makeContributor(dc.publisher?.[0]), - 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 - } - 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')]) ?? []) + + // 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: makeContributor(dc.publisher?.[0]), + 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 + } + 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 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 +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 } } - 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' ? .001 - : 1 - return n * f + 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 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://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), + 'http://www.idpf.org/2008/embedding': { + key: (opf) => + sha1( + getIdentifier(opf) + // eslint-disable-next-line no-control-regex + .replaceAll(/[\u0020\u0009\u000d\u000a]/g, ''), + ), + decode: (key, blob) => deobfuscate(key, 1040, blob), + }, + 'http://ns.adobe.com/pdf/enc#RC': { + key: (opf) => { + const uuid = getUUID(opf).replaceAll('-', '') + return Uint8Array.from({ length: 16 }, (_, i) => parseInt(uuid.slice(i * 2, i * 2 + 2), 16)) }, + decode: (key, blob) => deobfuscate(key, 1024, blob), + }, }) class Encryption { - #uris = new Map() - #decoders = new Map() - #algorithms - constructor(algorithms) { - this.#algorithms = algorithms - } - async init(encryption, opf) { - if (!encryption) return - const data = Array.from( - encryption.getElementsByTagNameNS(NS.ENC, 'EncryptedData'), el => ({ - algorithm: el.getElementsByTagNameNS(NS.ENC, 'EncryptionMethod')[0] - ?.getAttribute('Algorithm'), - uri: el.getElementsByTagNameNS(NS.ENC, 'CipherReference')[0] - ?.getAttribute('URI'), - })) - for (const { algorithm, uri } of data) { - if (!this.#decoders.has(algorithm)) { - const algo = this.#algorithms[algorithm] - if (!algo) { - console.warn('Unknown encryption algorithm') - continue - } - const key = await algo.key(opf) - this.#decoders.set(algorithm, blob => algo.decode(key, blob)) - } - this.#uris.set(uri, algorithm) + #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() - #getDirectUrl = null // Function to get direct streaming URLs for lazy loading - eventTarget = new EventTarget() - constructor({ loadText, loadBlob, resources, getDirectUrl }) { - this.loadText = loadText - this.loadBlob = loadBlob - this.manifest = resources.manifest - this.assets = resources.manifest - this.#getDirectUrl = getDirectUrl ?? null - // 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() + #getDirectUrl = null // Function to get direct streaming URLs for lazy loading + eventTarget = new EventTarget() + constructor({ loadText, loadBlob, resources, getDirectUrl }) { + this.loadText = loadText + this.loadBlob = loadBlob + this.manifest = resources.manifest + this.assets = resources.manifest + this.#getDirectUrl = getDirectUrl ?? null + // needed only when replacing in (X)HTML w/o parsing (see below) + //.filter(({ mediaType }) => ![MIME.XHTML, MIME.HTML].includes(mediaType)) + } - // Check if a media type is an image - #isImage(mediaType) { - return mediaType?.startsWith('image/') ?? false - } + // Check if a media type is an image + #isImage(mediaType) { + return mediaType?.startsWith('image/') ?? false + } - // Check if a media type is a font - #isFont(mediaType) { - return mediaType?.includes('font') || - mediaType === 'application/vnd.ms-opentype' || - mediaType === 'application/x-font-ttf' || - mediaType === 'application/x-font-otf' || - mediaType === 'application/x-font-woff' || - mediaType === 'font/otf' || - mediaType === 'font/ttf' || - mediaType === 'font/woff' || - mediaType === 'font/woff2' + // Check if a media type is a font + #isFont(mediaType) { + return ( + mediaType?.includes('font') || + mediaType === 'application/vnd.ms-opentype' || + mediaType === 'application/x-font-ttf' || + mediaType === 'application/x-font-otf' || + mediaType === 'application/x-font-woff' || + mediaType === 'font/otf' || + mediaType === 'font/ttf' || + mediaType === 'font/woff' || + mediaType === 'font/woff2' + ) + } + 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 - } - 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]) + } + // replace hrefs (excluding anchors) + const replace = async (el, attr) => el.setAttribute(attr, await this.loadHref(el.getAttribute(attr), href, parents)) + + // Helper to check if element is an image that should use direct URL + const shouldUseDirectUrl = (el, attr) => { + if (!this.#getDirectUrl) return false + const srcAttr = el.getAttribute(attr) + if (!srcAttr || isExternal(srcAttr)) return false + const path = resolveURL(srcAttr, href) + const item = this.manifest.find((item) => item.href === path) + return item && this.#isImage(item.mediaType) + } + + // Replace with direct URL (for images) - browser loads on-demand + const replaceWithDirectUrl = (el, attr) => { + const srcAttr = el.getAttribute(attr) + const path = resolveURL(srcAttr, href) + const directUrl = this.#getDirectUrl(path) + if (directUrl) { + el.setAttribute(attr, directUrl) + el.setAttribute('loading', 'lazy') // Native lazy loading hint + return true } - 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 + return false + } - 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 + // Process link elements (stylesheets, etc.) + for (const el of doc.querySelectorAll('link[href]')) await replace(el, 'href') - 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) + // Process elements with src attribute + // For images: use direct URLs so browser can load on-demand + // For others: load and create blob URL + for (const el of doc.querySelectorAll('[src]')) { + if (shouldUseDirectUrl(el, 'src')) { + replaceWithDirectUrl(el, 'src') + } else { + await replace(el, 'src') } - 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)) - - // Helper to check if element is an image that should use direct URL - const shouldUseDirectUrl = (el, attr) => { - if (!this.#getDirectUrl) return false - const srcAttr = el.getAttribute(attr) - if (!srcAttr || isExternal(srcAttr)) return false - const path = resolveURL(srcAttr, href) - const item = this.manifest.find(item => item.href === path) - return item && this.#isImage(item.mediaType) - } - - // Replace with direct URL (for images) - browser loads on-demand - const replaceWithDirectUrl = (el, attr) => { - const srcAttr = el.getAttribute(attr) - const path = resolveURL(srcAttr, href) - const directUrl = this.#getDirectUrl(path) - if (directUrl) { - el.setAttribute(attr, directUrl) - el.setAttribute('loading', 'lazy') // Native lazy loading hint - return true - } - return false - } - - // Process link elements (stylesheets, etc.) - for (const el of doc.querySelectorAll('link[href]')) await replace(el, 'href') - - // Process elements with src attribute - // For images: use direct URLs so browser can load on-demand - // For others: load and create blob URL - for (const el of doc.querySelectorAll('[src]')) { - if (shouldUseDirectUrl(el, 'src')) { - replaceWithDirectUrl(el, 'src') - } else { - await replace(el, 'src') - } - } - - // Process poster attributes (videos) - for (const el of doc.querySelectorAll('[poster]')) { - if (shouldUseDirectUrl(el, 'poster')) { - replaceWithDirectUrl(el, 'poster') - } else { - 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)) - - // Process srcset attributes - 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) + // Process poster attributes (videos) + for (const el of doc.querySelectorAll('[poster]')) { + if (shouldUseDirectUrl(el, 'poster')) { + replaceWithDirectUrl(el, 'poster') + } else { + await replace(el, 'poster') } + } - 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 = []) { - // For url() references, use direct URLs for fonts and images (lazy loading) - // This avoids fetching all fonts/images upfront - browser fetches on-demand - const replacedUrls = await replaceSeries(str, - /url\(\s*["']?([^'"\n]*?)\s*["']?\s*\)/gi, - async (_, url) => { - if (isExternal(url)) return `url("${url}")` + 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)) - const path = resolveURL(url, href) - const item = this.manifest.find(item => item.href === path) - - // If we have getDirectUrl and this is a font or image, use direct URL - // This lets the browser fetch on-demand instead of us prefetching everything - if (this.#getDirectUrl && item && (this.#isFont(item.mediaType) || this.#isImage(item.mediaType))) { - const directUrl = this.#getDirectUrl(path) - if (directUrl) return `url("${directUrl}")` - } - - // For other resources (like CSS), load them normally - const loadedUrl = await this.loadHref(url, href, parents) - return `url("${loadedUrl}")` - }) - - // apart from `url()`, strings can be used for `@import` (but why?!) - // CSS imports need to be loaded and processed - const replacedImports = await replaceSeries(replacedUrls, - /@import\s*["']([^"'\n]*?)["']/gi, - (_, url) => this.loadHref(url, href, parents) - .then(url => `@import "${url}"`)) - - // Add font-display: swap to @font-face rules that don't have it - // This ensures text is visible immediately while fonts load - return replacedImports.replace( - /@font-face\s*\{([^}]*)\}/gi, - (match, content) => { - // Check if font-display is already set - if (/font-display\s*:/i.test(content)) { - return match - } - // Add font-display: swap before the closing brace - return `@font-face {${content}font-display: swap;}` - } + // Process srcset attributes + 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) } - // 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 = []) { + // For url() references, use direct URLs for fonts and images (lazy loading) + // This avoids fetching all fonts/images upfront - browser fetches on-demand + const replacedUrls = await replaceSeries(str, /url\(\s*["']?([^'"\n]*?)\s*["']?\s*\)/gi, async (_, url) => { + if (isExternal(url)) return `url("${url}")` + + const path = resolveURL(url, href) + const item = this.manifest.find((item) => item.href === path) + + // If we have getDirectUrl and this is a font or image, use direct URL + // This lets the browser fetch on-demand instead of us prefetching everything + if (this.#getDirectUrl && item && (this.#isFont(item.mediaType) || this.#isImage(item.mediaType))) { + const directUrl = this.#getDirectUrl(path) + if (directUrl) return `url("${directUrl}")` + } + + // For other resources (like CSS), load them normally + const loadedUrl = await this.loadHref(url, href, parents) + return `url("${loadedUrl}")` + }) + + // apart from `url()`, strings can be used for `@import` (but why?!) + // CSS imports need to be loaded and processed + const replacedImports = await replaceSeries(replacedUrls, /@import\s*["']([^"'\n]*?)["']/gi, (_, url) => + this.loadHref(url, href, parents).then((url) => `@import "${url}"`), + ) + + // Add font-display: swap to @font-face rules that don't have it + // This ensures text is visible immediately while fonts load + return replacedImports.replace(/@font-face\s*\{([^}]*)\}/gi, (match, content) => { + // Check if font-display is already set + if (/font-display\s*:/i.test(content)) { + return match + } + // Add font-display: swap before the closing brace + return `@font-face {${content}font-display: swap;}` + }) + } + // 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 - #getDirectUrl = null - constructor({ loadText, loadBlob, getSize, getDirectUrl, sha1 }) { - this.loadText = loadText - this.loadBlob = loadBlob - this.getSize = getSize - this.#getDirectUrl = getDirectUrl ?? null - 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} + parser = new DOMParser() + #loader + #encryption + #getDirectUrl = null + constructor({ loadText, loadBlob, getSize, getDirectUrl, sha1 }) { + this.loadText = loadText + this.loadBlob = loadBlob + this.getSize = getSize + this.#getDirectUrl = getDirectUrl ?? null + 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') + 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, - getDirectUrl: this.#getDirectUrl, - }) - 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, + getDirectUrl: this.#getDirectUrl, + }) + 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) + 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, } - this.landmarks ??= this.resources.guide + }) + .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/client/public/assets/foliate/epubcfi.js b/client/public/assets/foliate/epubcfi.js index 173609128..19d82886c 100644 --- a/client/public/assets/foliate/epubcfi.js +++ b/client/public/assets/foliate/epubcfi.js @@ -1,203 +1,216 @@ -const findIndices = (arr, f) => arr - .map((x, i, a) => f(x, i, a) ? i : null).filter(x => x != null) -const splitAt = (arr, is) => [-1, ...is, arr.length].reduce(({ xs, a }, b) => - ({ xs: xs?.concat([arr.slice(a + 1, b)]) ?? [], a: b }), {}).xs +const findIndices = (arr, f) => arr.map((x, i, a) => (f(x, i, a) ? i : null)).filter((x) => x != null) +const splitAt = (arr, is) => [-1, ...is, arr.length].reduce(({ xs, a }, b) => ({ xs: xs?.concat([arr.slice(a + 1, b)]) ?? [], a: b }), {}).xs const concatArrays = (a, b) => - a.slice(0, -1).concat([a[a.length - 1].concat(b[0])]).concat(b.slice(1)) + a + .slice(0, -1) + .concat([a[a.length - 1].concat(b[0])]) + .concat(b.slice(1)) const isNumber = /\d/ export const isCFI = /^epubcfi\((.*)\)$/ -const escapeCFI = str => str.replace(/[\^[\](),;=]/g, '^$&') +const escapeCFI = (str) => str.replace(/[\^[\](),;=]/g, '^$&') -const wrap = x => isCFI.test(x) ? x : `epubcfi(${x})` -const unwrap = x => x.match(isCFI)?.[1] ?? x -const lift = f => (...xs) => - `epubcfi(${f(...xs.map(x => x.match(isCFI)?.[1] ?? x))})` +const wrap = (x) => (isCFI.test(x) ? x : `epubcfi(${x})`) +const unwrap = (x) => x.match(isCFI)?.[1] ?? x +const lift = + (f) => + (...xs) => + `epubcfi(${f(...xs.map((x) => x.match(isCFI)?.[1] ?? x))})` export const joinIndir = lift((...xs) => xs.join('!')) -const tokenizer = str => { - const tokens = [] - let state, escape, value = '' - const push = x => (tokens.push(x), state = null, value = '') - const cat = x => (value += x, escape = false) - for (const char of Array.from(str.trim()).concat('')) { - if (char === '^' && !escape) { - escape = true - continue - } - if (state === '!') push(['!']) - else if (state === ',') push([',']) - else if (state === '/' || state === ':') { - if (isNumber.test(char)) { - cat(char) - continue - } else push([state, parseInt(value)]) - } else if (state === '~') { - if (isNumber.test(char) || char === '.') { - cat(char) - continue - } else push(['~', parseFloat(value)]) - } else if (state === '@') { - if (char === ':') { - push(['@', parseFloat(value)]) - state = '@' - continue - } - if (isNumber.test(char) || char === '.') { - cat(char) - continue - } else push(['@', parseFloat(value)]) - } else if (state === '[') { - if (char === ';' && !escape) { - push(['[', value]) - state = ';' - } else if (char === ',' && !escape) { - push(['[', value]) - state = '[' - } else if (char === ']' && !escape) push(['[', value]) - else cat(char) - continue - } else if (state?.startsWith(';')) { - if (char === '=' && !escape) { - state = `;${value}` - value = '' - } else if (char === ';' && !escape) { - push([state, value]) - state = ';' - } else if (char === ']' && !escape) push([state, value]) - else cat(char) - continue - } - if (char === '/' || char === ':' || char === '~' || char === '@' - || char === '[' || char === '!' || char === ',') state = char +const tokenizer = (str) => { + const tokens = [] + let state, + escape, + value = '' + const push = (x) => (tokens.push(x), (state = null), (value = '')) + const cat = (x) => ((value += x), (escape = false)) + for (const char of Array.from(str.trim()).concat('')) { + if (char === '^' && !escape) { + escape = true + continue } - return tokens + if (state === '!') push(['!']) + else if (state === ',') push([',']) + else if (state === '/' || state === ':') { + if (isNumber.test(char)) { + cat(char) + continue + } else push([state, parseInt(value)]) + } else if (state === '~') { + if (isNumber.test(char) || char === '.') { + cat(char) + continue + } else push(['~', parseFloat(value)]) + } else if (state === '@') { + if (char === ':') { + push(['@', parseFloat(value)]) + state = '@' + continue + } + if (isNumber.test(char) || char === '.') { + cat(char) + continue + } else push(['@', parseFloat(value)]) + } else if (state === '[') { + if (char === ';' && !escape) { + push(['[', value]) + state = ';' + } else if (char === ',' && !escape) { + push(['[', value]) + state = '[' + } else if (char === ']' && !escape) push(['[', value]) + else cat(char) + continue + } else if (state?.startsWith(';')) { + if (char === '=' && !escape) { + state = `;${value}` + value = '' + } else if (char === ';' && !escape) { + push([state, value]) + state = ';' + } else if (char === ']' && !escape) push([state, value]) + else cat(char) + continue + } + if (char === '/' || char === ':' || char === '~' || char === '@' || char === '[' || char === '!' || char === ',') state = char + } + return tokens } const findTokens = (tokens, x) => findIndices(tokens, ([t]) => t === x) -const parser = tokens => { - const parts = [] - let state - for (const [type, val] of tokens) { - if (type === '/') parts.push({ index: val }) +const parser = (tokens) => { + const parts = [] + let state + for (const [type, val] of tokens) { + if (type === '/') parts.push({ index: val }) + else { + const last = parts[parts.length - 1] + if (type === ':') last.offset = val + else if (type === '~') last.temporal = val + else if (type === '@') last.spatial = (last.spatial ?? []).concat(val) + else if (type === ';s') last.side = val + else if (type === '[') { + if (state === '/' && val) last.id = val else { - const last = parts[parts.length - 1] - if (type === ':') last.offset = val - else if (type === '~') last.temporal = val - else if (type === '@') last.spatial = (last.spatial ?? []).concat(val) - else if (type === ';s') last.side = val - else if (type === '[') { - if (state === '/' && val) last.id = val - else { - last.text = (last.text ?? []).concat(val) - continue - } - } + last.text = (last.text ?? []).concat(val) + continue } - state = type + } } - return parts + state = type + } + return parts } // split at step indirections, then parse each part -const parserIndir = tokens => - splitAt(tokens, findTokens(tokens, '!')).map(parser) +const parserIndir = (tokens) => splitAt(tokens, findTokens(tokens, '!')).map(parser) -export const parse = cfi => { - const tokens = tokenizer(unwrap(cfi)) - const commas = findTokens(tokens, ',') - if (!commas.length) return parserIndir(tokens) - const [parent, start, end] = splitAt(tokens, commas).map(parserIndir) - return { parent, start, end } +export const parse = (cfi) => { + const tokens = tokenizer(unwrap(cfi)) + const commas = findTokens(tokens, ',') + if (!commas.length) return parserIndir(tokens) + const [parent, start, end] = splitAt(tokens, commas).map(parserIndir) + return { parent, start, end } } const partToString = ({ index, id, offset, temporal, spatial, text, side }) => { - const param = side ? `;s=${side}` : '' - return `/${index}` - + (id ? `[${escapeCFI(id)}${param}]` : '') - // "CFI expressions [..] SHOULD include an explicit character offset" - + (offset != null && index % 2 ? `:${offset}` : '') - + (temporal ? `~${temporal}` : '') - + (spatial ? `@${spatial.join(':')}` : '') - + (text || (!id && side) ? '[' - + (text?.map(escapeCFI)?.join(',') ?? '') - + param + ']' : '') + const param = side ? `;s=${side}` : '' + return ( + `/${index}` + + (id ? `[${escapeCFI(id)}${param}]` : '') + + // "CFI expressions [..] SHOULD include an explicit character offset" + (offset != null && index % 2 ? `:${offset}` : '') + + (temporal ? `~${temporal}` : '') + + (spatial ? `@${spatial.join(':')}` : '') + + (text || (!id && side) ? '[' + (text?.map(escapeCFI)?.join(',') ?? '') + param + ']' : '') + ) } -const toInnerString = parsed => parsed.parent +const toInnerString = (parsed) => + parsed.parent ? [parsed.parent, parsed.start, parsed.end].map(toInnerString).join(',') - : parsed.map(parts => parts.map(partToString).join('')).join('!') + : parsed.map((parts) => parts.map(partToString).join('')).join('!') -const toString = parsed => wrap(toInnerString(parsed)) +const toString = (parsed) => wrap(toInnerString(parsed)) -export const collapse = (x, toEnd) => typeof x === 'string' - ? toString(collapse(parse(x), toEnd)) - : x.parent ? concatArrays(x.parent, x[toEnd ? 'end' : 'start']) : x +export const collapse = (x, toEnd) => + typeof x === 'string' ? toString(collapse(parse(x), toEnd)) : x.parent ? concatArrays(x.parent, x[toEnd ? 'end' : 'start']) : x // create range CFI from two CFIs const buildRange = (from, to) => { - if (typeof from === 'string') from = parse(from) - if (typeof to === 'string') to = parse(to) - from = collapse(from) - to = collapse(to, true) - // ranges across multiple documents are not allowed; handle local paths only - const localFrom = from[from.length - 1], localTo = to[to.length - 1] - const localParent = [], localStart = [], localEnd = [] - let pushToParent = true - const len = Math.max(localFrom.length, localTo.length) - for (let i = 0; i < len; i++) { - const a = localFrom[i], b = localTo[i] - pushToParent &&= a?.index === b?.index && !a?.offset && !b?.offset - if (pushToParent) localParent.push(a) - else { - if (a) localStart.push(a) - if (b) localEnd.push(b) - } + if (typeof from === 'string') from = parse(from) + if (typeof to === 'string') to = parse(to) + from = collapse(from) + to = collapse(to, true) + // ranges across multiple documents are not allowed; handle local paths only + const localFrom = from[from.length - 1], + localTo = to[to.length - 1] + const localParent = [], + localStart = [], + localEnd = [] + let pushToParent = true + const len = Math.max(localFrom.length, localTo.length) + for (let i = 0; i < len; i++) { + const a = localFrom[i], + b = localTo[i] + pushToParent &&= a?.index === b?.index && !a?.offset && !b?.offset + if (pushToParent) localParent.push(a) + else { + if (a) localStart.push(a) + if (b) localEnd.push(b) } - // copy non-local paths from `from` - const parent = from.slice(0, -1).concat([localParent]) - return toString({ parent, start: [localStart], end: [localEnd] }) + } + // copy non-local paths from `from` + const parent = from.slice(0, -1).concat([localParent]) + return toString({ parent, start: [localStart], end: [localEnd] }) } export const compare = (a, b) => { - if (typeof a === 'string') a = parse(a) - if (typeof b === 'string') b = parse(b) - if (a.start || b.start) return compare(collapse(a), collapse(b)) - || compare(collapse(a, true), collapse(b, true)) + if (typeof a === 'string') a = parse(a) + if (typeof b === 'string') b = parse(b) + if (a.start || b.start) return compare(collapse(a), collapse(b)) || compare(collapse(a, true), collapse(b, true)) - for (let i = 0; i < Math.max(a.length, b.length); i++) { - const p = a[i] ?? [], q = b[i] ?? [] - const maxIndex = Math.max(p.length, q.length) - 1 - for (let i = 0; i <= maxIndex; i++) { - const x = p[i], y = q[i] - if (!x) return -1 - if (!y) return 1 - if (x.index > y.index) return 1 - if (x.index < y.index) return -1 - if (i === maxIndex) { - // TODO: compare temporal & spatial offsets - if (x.offset > y.offset) return 1 - if (x.offset < y.offset) return -1 - } - } + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const p = a[i] ?? [], + q = b[i] ?? [] + const maxIndex = Math.max(p.length, q.length) - 1 + for (let i = 0; i <= maxIndex; i++) { + const x = p[i], + y = q[i] + if (!x) return -1 + if (!y) return 1 + if (x.index > y.index) return 1 + if (x.index < y.index) return -1 + if (i === maxIndex) { + // TODO: compare temporal & spatial offsets + if (x.offset > y.offset) return 1 + if (x.offset < y.offset) return -1 + } } - return 0 + } + return 0 } const isTextNode = ({ nodeType }) => nodeType === 3 || nodeType === 4 const isElementNode = ({ nodeType }) => nodeType === 1 const getChildNodes = (node, filter) => { - const nodes = Array.from(node.childNodes) - // "content other than element and character data is ignored" - .filter(node => isTextNode(node) || isElementNode(node)) - return filter ? nodes.map(node => { - const accept = filter(node) - if (accept === NodeFilter.FILTER_REJECT) return null - else if (accept === NodeFilter.FILTER_SKIP) return getChildNodes(node, filter) - else return node - }).flat().filter(x => x) : nodes + const nodes = Array.from(node.childNodes) + // "content other than element and character data is ignored" + .filter((node) => isTextNode(node) || isElementNode(node)) + return filter + ? nodes + .map((node) => { + const accept = filter(node) + if (accept === NodeFilter.FILTER_REJECT) return null + else if (accept === NodeFilter.FILTER_SKIP) return getChildNodes(node, filter) + else return node + }) + .flat() + .filter((x) => x) + : nodes } // child nodes are organized such that the result is always @@ -206,140 +219,137 @@ const getChildNodes = (node, filter) => { // so multiple text nodes need to be combined, and nonexistent ones counted; // see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec const indexChildNodes = (node, filter) => { - const nodes = getChildNodes(node, filter) - .reduce((arr, node) => { - let last = arr[arr.length - 1] - if (!last) arr.push(node) - // "there is one chunk between each pair of child elements" - else if (isTextNode(node)) { - if (Array.isArray(last)) last.push(node) - else if (isTextNode(last)) arr[arr.length - 1] = [last, node] - else arr.push(node) - } else { - if (isElementNode(last)) arr.push(null, node) - else arr.push(node) - } - return arr - }, []) - // "the first chunk is located before the first child element" - if (isElementNode(nodes[0])) nodes.unshift('first') - // "the last chunk is located after the last child element" - if (isElementNode(nodes[nodes.length - 1])) nodes.push('last') - // "'virtual' elements" - nodes.unshift('before') // "0 is a valid index" - nodes.push('after') // "n+2 is a valid index" - return nodes + const nodes = getChildNodes(node, filter).reduce((arr, node) => { + let last = arr[arr.length - 1] + if (!last) arr.push(node) + // "there is one chunk between each pair of child elements" + else if (isTextNode(node)) { + if (Array.isArray(last)) last.push(node) + else if (isTextNode(last)) arr[arr.length - 1] = [last, node] + else arr.push(node) + } else { + if (isElementNode(last)) arr.push(null, node) + else arr.push(node) + } + return arr + }, []) + // "the first chunk is located before the first child element" + if (isElementNode(nodes[0])) nodes.unshift('first') + // "the last chunk is located after the last child element" + if (isElementNode(nodes[nodes.length - 1])) nodes.push('last') + // "'virtual' elements" + nodes.unshift('before') // "0 is a valid index" + nodes.push('after') // "n+2 is a valid index" + return nodes } const partsToNode = (node, parts, filter) => { - const { id } = parts[parts.length - 1] - if (id) { - const el = node.ownerDocument.getElementById(id) - if (el) return { node: el, offset: 0 } - } - for (const { index } of parts) { - const newNode = node ? indexChildNodes(node, filter)[index] : null - // handle non-existent nodes - if (newNode === 'first') return { node: node.firstChild ?? node } - if (newNode === 'last') return { node: node.lastChild ?? node } - if (newNode === 'before') return { node, before: true } - if (newNode === 'after') return { node, after: true } - node = newNode - } - const { offset } = parts[parts.length - 1] - if (!Array.isArray(node)) return { node, offset } - // get underlying text node and offset from the chunk - let sum = 0 - for (const n of node) { - const { length } = n.nodeValue - if (sum + length >= offset) return { node: n, offset: offset - sum } - sum += length - } + const { id } = parts[parts.length - 1] + if (id) { + const el = node.ownerDocument.getElementById(id) + if (el) return { node: el, offset: 0 } + } + for (const { index } of parts) { + const newNode = node ? indexChildNodes(node, filter)[index] : null + // handle non-existent nodes + if (newNode === 'first') return { node: node.firstChild ?? node } + if (newNode === 'last') return { node: node.lastChild ?? node } + if (newNode === 'before') return { node, before: true } + if (newNode === 'after') return { node, after: true } + node = newNode + } + const { offset } = parts[parts.length - 1] + if (!Array.isArray(node)) return { node, offset } + // get underlying text node and offset from the chunk + let sum = 0 + for (const n of node) { + const { length } = n.nodeValue + if (sum + length >= offset) return { node: n, offset: offset - sum } + sum += length + } } const nodeToParts = (node, offset, filter) => { - const { parentNode, id } = node - const indexed = indexChildNodes(parentNode, filter) - const index = indexed.findIndex(x => - Array.isArray(x) ? x.some(x => x === node) : x === node) - // adjust offset as if merging the text nodes in the chunk - const chunk = indexed[index] - if (Array.isArray(chunk)) { - let sum = 0 - for (const x of chunk) { - if (x === node) { - sum += offset - break - } else sum += x.nodeValue.length - } - offset = sum + const { parentNode, id } = node + const indexed = indexChildNodes(parentNode, filter) + const index = indexed.findIndex((x) => (Array.isArray(x) ? x.some((x) => x === node) : x === node)) + // adjust offset as if merging the text nodes in the chunk + const chunk = indexed[index] + if (Array.isArray(chunk)) { + let sum = 0 + for (const x of chunk) { + if (x === node) { + sum += offset + break + } else sum += x.nodeValue.length } - const part = { id, index, offset } - return (parentNode !== node.ownerDocument.documentElement - ? nodeToParts(parentNode, null, filter).concat(part) : [part]) - // remove ignored nodes - .filter(x => x.index !== -1) + offset = sum + } + const part = { id, index, offset } + return ( + (parentNode !== node.ownerDocument.documentElement ? nodeToParts(parentNode, null, filter).concat(part) : [part]) + // remove ignored nodes + .filter((x) => x.index !== -1) + ) } export const fromRange = (range, filter) => { - const { startContainer, startOffset, endContainer, endOffset } = range - const start = nodeToParts(startContainer, startOffset, filter) - if (range.collapsed) return toString([start]) - const end = nodeToParts(endContainer, endOffset, filter) - return buildRange([start], [end]) + const { startContainer, startOffset, endContainer, endOffset } = range + const start = nodeToParts(startContainer, startOffset, filter) + if (range.collapsed) return toString([start]) + const end = nodeToParts(endContainer, endOffset, filter) + return buildRange([start], [end]) } export const toRange = (doc, parts, filter) => { - const startParts = collapse(parts) - const endParts = collapse(parts, true) + const startParts = collapse(parts) + const endParts = collapse(parts, true) - const root = doc.documentElement - const start = partsToNode(root, startParts[0], filter) - const end = partsToNode(root, endParts[0], filter) + const root = doc.documentElement + const start = partsToNode(root, startParts[0], filter) + const end = partsToNode(root, endParts[0], filter) - const range = doc.createRange() + const range = doc.createRange() - if (start.before) range.setStartBefore(start.node) - else if (start.after) range.setStartAfter(start.node) - else range.setStart(start.node, start.offset) + if (start.before) range.setStartBefore(start.node) + else if (start.after) range.setStartAfter(start.node) + else range.setStart(start.node, start.offset) - if (end.before) range.setEndBefore(end.node) - else if (end.after) range.setEndAfter(end.node) - else range.setEnd(end.node, end.offset) - return range + if (end.before) range.setEndBefore(end.node) + else if (end.after) range.setEndAfter(end.node) + else range.setEnd(end.node, end.offset) + return range } // faster way of getting CFIs for sorted elements in a single parent -export const fromElements = elements => { - const results = [] - const { parentNode } = elements[0] - const parts = nodeToParts(parentNode) - for (const [index, node] of indexChildNodes(parentNode).entries()) { - const el = elements[results.length] - if (node === el) - results.push(toString([parts.concat({ id: el.id, index })])) - } - return results +export const fromElements = (elements) => { + const results = [] + const { parentNode } = elements[0] + const parts = nodeToParts(parentNode) + for (const [index, node] of indexChildNodes(parentNode).entries()) { + const el = elements[results.length] + if (node === el) results.push(toString([parts.concat({ id: el.id, index })])) + } + return results } -export const toElement = (doc, parts) => - partsToNode(doc.documentElement, collapse(parts)).node +export const toElement = (doc, parts) => partsToNode(doc.documentElement, collapse(parts)).node // turn indices into standard CFIs when you don't have an actual package document export const fake = { - fromIndex: index => wrap(`/6/${(index + 1) * 2}`), - toIndex: parts => parts?.at(-1).index / 2 - 1, + fromIndex: (index) => wrap(`/6/${(index + 1) * 2}`), + toIndex: (parts) => parts?.at(-1).index / 2 - 1, } // get CFI from Calibre bookmarks // see https://github.com/johnfactotum/foliate/issues/849 -export const fromCalibrePos = pos => { - const [parts] = parse(pos) - const item = parts.shift() - parts.shift() - return toString([[{ index: 6 }, item], parts]) +export const fromCalibrePos = (pos) => { + const [parts] = parse(pos) + const item = parts.shift() + parts.shift() + return toString([[{ index: 6 }, item], parts]) } export const fromCalibreHighlight = ({ spine_index, start_cfi, end_cfi }) => { - const pre = fake.fromIndex(spine_index) + '!' - return buildRange(pre + start_cfi.slice(2), pre + end_cfi.slice(2)) + const pre = fake.fromIndex(spine_index) + '!' + return buildRange(pre + start_cfi.slice(2), pre + end_cfi.slice(2)) } diff --git a/client/public/assets/foliate/fb2.js b/client/public/assets/foliate/fb2.js index ce9bdc0ca..e18985126 100644 --- a/client/public/assets/foliate/fb2.js +++ b/client/public/assets/foliate/fb2.js @@ -1,176 +1,197 @@ -const normalizeWhitespace = str => str ? str - .replace(/[\t\n\f\r ]+/g, ' ') - .replace(/^[\t\n\f\r ]+/, '') - .replace(/[\t\n\f\r ]+$/, '') : '' -const getElementText = el => normalizeWhitespace(el?.textContent) +const normalizeWhitespace = (str) => + str + ? str + .replace(/[\t\n\f\r ]+/g, ' ') + .replace(/^[\t\n\f\r ]+/, '') + .replace(/[\t\n\f\r ]+$/, '') + : '' +const getElementText = (el) => normalizeWhitespace(el?.textContent) const NS = { - XLINK: 'http://www.w3.org/1999/xlink', - EPUB: 'http://www.idpf.org/2007/ops', + XLINK: 'http://www.w3.org/1999/xlink', + EPUB: 'http://www.idpf.org/2007/ops', } const MIME = { - XML: 'application/xml', - XHTML: 'application/xhtml+xml', + XML: 'application/xml', + XHTML: 'application/xhtml+xml', } const STYLE = { - 'strong': ['strong', 'self'], - 'emphasis': ['em', 'self'], - 'style': ['span', 'self'], - 'a': 'anchor', - 'strikethrough': ['s', 'self'], - 'sub': ['sub', 'self'], - 'sup': ['sup', 'self'], - 'code': ['code', 'self'], - 'image': 'image', + strong: ['strong', 'self'], + emphasis: ['em', 'self'], + style: ['span', 'self'], + a: 'anchor', + strikethrough: ['s', 'self'], + sub: ['sub', 'self'], + sup: ['sup', 'self'], + code: ['code', 'self'], + image: 'image', } const TABLE = { - 'tr': ['tr', { - 'th': ['th', STYLE, ['colspan', 'rowspan', 'align', 'valign']], - 'td': ['td', STYLE, ['colspan', 'rowspan', 'align', 'valign']], - }, ['align']], + tr: [ + 'tr', + { + th: ['th', STYLE, ['colspan', 'rowspan', 'align', 'valign']], + td: ['td', STYLE, ['colspan', 'rowspan', 'align', 'valign']], + }, + ['align'], + ], } const POEM = { - 'epigraph': ['blockquote'], - 'subtitle': ['h2', STYLE], - 'text-author': ['p', STYLE], - 'date': ['p', STYLE], - 'stanza': 'stanza', + epigraph: ['blockquote'], + subtitle: ['h2', STYLE], + 'text-author': ['p', STYLE], + date: ['p', STYLE], + stanza: 'stanza', } const SECTION = { - 'title': ['header', { - 'p': ['h1', STYLE], - 'empty-line': ['br'], - }], - 'epigraph': ['blockquote', 'self'], - 'image': 'image', - 'annotation': ['aside'], - 'section': ['section', 'self'], - 'p': ['p', STYLE], - 'poem': ['blockquote', POEM], - 'subtitle': ['h2', STYLE], - 'cite': ['blockquote', 'self'], - 'empty-line': ['br'], - 'table': ['table', TABLE], - 'text-author': ['p', STYLE], + title: [ + 'header', + { + p: ['h1', STYLE], + 'empty-line': ['br'], + }, + ], + epigraph: ['blockquote', 'self'], + image: 'image', + annotation: ['aside'], + section: ['section', 'self'], + p: ['p', STYLE], + poem: ['blockquote', POEM], + subtitle: ['h2', STYLE], + cite: ['blockquote', 'self'], + 'empty-line': ['br'], + table: ['table', TABLE], + 'text-author': ['p', STYLE], } POEM['epigraph'].push(SECTION) const BODY = { - 'image': 'image', - 'title': ['section', { - 'p': ['h1', STYLE], - 'empty-line': ['br'], - }], - 'epigraph': ['section', SECTION], - 'section': ['section', SECTION], + image: 'image', + title: [ + 'section', + { + p: ['h1', STYLE], + 'empty-line': ['br'], + }, + ], + epigraph: ['section', SECTION], + section: ['section', SECTION], } class FB2Converter { - constructor(fb2) { - this.fb2 = fb2 - this.doc = document.implementation.createDocument(NS.XHTML, 'html') - // use this instead of `getElementById` to allow images like - // `` - this.bins = new Map(Array.from(this.fb2.getElementsByTagName('binary'), - el => [el.id, el])) - } - getImageSrc(el) { - const href = el.getAttributeNS(NS.XLINK, 'href') - if (!href) return 'data:,' - const [, id] = href.split('#') - if (!id) return href - const bin = this.bins.get(id) - return bin - ? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}` - : href - } - image(node) { - const el = this.doc.createElement('img') - el.alt = node.getAttribute('alt') - el.title = node.getAttribute('title') - el.setAttribute('src', this.getImageSrc(node)) - return el - } - anchor(node) { - const el = this.convert(node, { 'a': ['a', STYLE] }) - el.setAttribute('href', node.getAttributeNS(NS.XLINK, 'href')) - if (node.getAttribute('type') === 'note') - el.setAttributeNS(NS.EPUB, 'epub:type', 'noteref') - return el - } - stanza(node) { - const el = this.convert(node, { - 'stanza': ['p', { - 'title': ['header', { - 'p': ['strong', STYLE], - 'empty-line': ['br'], - }], - 'subtitle': ['p', STYLE], - }], - }) - for (const child of node.children) if (child.nodeName === 'v') { - el.append(this.doc.createTextNode(child.textContent)) - el.append(this.doc.createElement('br')) - } - return el - } - convert(node, def) { - // not an element; return text content - if (node.nodeType === 3) return this.doc.createTextNode(node.textContent) - if (node.nodeType === 4) return this.doc.createCDATASection(node.textContent) - if (node.nodeType === 8) return this.doc.createComment(node.textContent) + constructor(fb2) { + this.fb2 = fb2 + this.doc = document.implementation.createDocument(NS.XHTML, 'html') + // use this instead of `getElementById` to allow images like + // `` + this.bins = new Map(Array.from(this.fb2.getElementsByTagName('binary'), (el) => [el.id, el])) + } + getImageSrc(el) { + const href = el.getAttributeNS(NS.XLINK, 'href') + if (!href) return 'data:,' + const [, id] = href.split('#') + if (!id) return href + const bin = this.bins.get(id) + return bin ? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}` : href + } + image(node) { + const el = this.doc.createElement('img') + el.alt = node.getAttribute('alt') + el.title = node.getAttribute('title') + el.setAttribute('src', this.getImageSrc(node)) + return el + } + anchor(node) { + const el = this.convert(node, { a: ['a', STYLE] }) + el.setAttribute('href', node.getAttributeNS(NS.XLINK, 'href')) + if (node.getAttribute('type') === 'note') el.setAttributeNS(NS.EPUB, 'epub:type', 'noteref') + return el + } + stanza(node) { + const el = this.convert(node, { + stanza: [ + 'p', + { + title: [ + 'header', + { + p: ['strong', STYLE], + 'empty-line': ['br'], + }, + ], + subtitle: ['p', STYLE], + }, + ], + }) + for (const child of node.children) + if (child.nodeName === 'v') { + el.append(this.doc.createTextNode(child.textContent)) + el.append(this.doc.createElement('br')) + } + return el + } + convert(node, def) { + // not an element; return text content + if (node.nodeType === 3) return this.doc.createTextNode(node.textContent) + if (node.nodeType === 4) return this.doc.createCDATASection(node.textContent) + if (node.nodeType === 8) return this.doc.createComment(node.textContent) - const d = def?.[node.nodeName] - if (!d) return null - if (typeof d === 'string') return this[d](node) + const d = def?.[node.nodeName] + if (!d) return null + if (typeof d === 'string') return this[d](node) - const [name, opts, attrs] = d - const el = this.doc.createElement(name) + const [name, opts, attrs] = d + const el = this.doc.createElement(name) - // copy the ID, and set class name from original element name - if (node.id) el.id = node.id - el.classList.add(node.nodeName) + // copy the ID, and set class name from original element name + if (node.id) el.id = node.id + el.classList.add(node.nodeName) - // copy attributes - if (Array.isArray(attrs)) for (const attr of attrs) { - const value = node.getAttribute(attr) - if (value) el.setAttribute(attr, value) - } + // copy attributes + if (Array.isArray(attrs)) + for (const attr of attrs) { + const value = node.getAttribute(attr) + if (value) el.setAttribute(attr, value) + } - // process child elements recursively - const childDef = opts === 'self' ? def : opts - let child = node.firstChild - while (child) { - const childEl = this.convert(child, childDef) - if (childEl) el.append(childEl) - child = child.nextSibling - } - return el + // process child elements recursively + const childDef = opts === 'self' ? def : opts + let child = node.firstChild + while (child) { + const childEl = this.convert(child, childDef) + if (childEl) el.append(childEl) + child = child.nextSibling } + return el + } } -const parseXML = async blob => { - const buffer = await blob.arrayBuffer() - const str = new TextDecoder('utf-8').decode(buffer) - const parser = new DOMParser() - const doc = parser.parseFromString(str, MIME.XML) - const encoding = doc.xmlEncoding - // `Document.xmlEncoding` is deprecated, and already removed in Firefox - // so parse the XML declaration manually - || str.match(/^<\?xml\s+version\s*=\s*["']1.\d+"\s+encoding\s*=\s*["']([A-Za-z0-9._-]*)["']/)?.[1] - if (encoding && encoding.toLowerCase() !== 'utf-8') { - const str = new TextDecoder(encoding).decode(buffer) - return parser.parseFromString(str, MIME.XML) - } - return doc +const parseXML = async (blob) => { + const buffer = await blob.arrayBuffer() + const str = new TextDecoder('utf-8').decode(buffer) + const parser = new DOMParser() + const doc = parser.parseFromString(str, MIME.XML) + const encoding = + doc.xmlEncoding || + // `Document.xmlEncoding` is deprecated, and already removed in Firefox + // so parse the XML declaration manually + str.match(/^<\?xml\s+version\s*=\s*["']1.\d+"\s+encoding\s*=\s*["']([A-Za-z0-9._-]*)["']/)?.[1] + if (encoding && encoding.toLowerCase() !== 'utf-8') { + const str = new TextDecoder(encoding).decode(buffer) + return parser.parseFromString(str, MIME.XML) + } + return doc } -const style = URL.createObjectURL(new Blob([` +const style = URL.createObjectURL( + new Blob( + [ + ` @namespace epub "http://www.idpf.org/2007/ops"; body > img, section > img { display: block; @@ -218,9 +239,13 @@ a[epub|type~="noteref"] { body:not(.notesBodyType) > .title, body:not(.notesBodyType) > .epigraph { margin: 3em 0; } -`], { type: 'text/css' })) +`, + ], + { type: 'text/css' }, + ), +) -const template = html => ` +const template = (html) => ` ${html} @@ -229,131 +254,136 @@ const template = html => ` // name of custom ID attribute for TOC items const dataID = 'data-foliate-id' -export const makeFB2 = async blob => { - const book = {} - const doc = await parseXML(blob) - const converter = new FB2Converter(doc) +export const makeFB2 = async (blob) => { + const book = {} + const doc = await parseXML(blob) + const converter = new FB2Converter(doc) - const $ = x => doc.querySelector(x) - const $$ = x => [...doc.querySelectorAll(x)] - const getPerson = el => { - const nick = getElementText(el.querySelector('nickname')) - if (nick) return nick - const first = getElementText(el.querySelector('first-name')) - const middle = getElementText(el.querySelector('middle-name')) - const last = getElementText(el.querySelector('last-name')) - const name = [first, middle, last].filter(x => x).join(' ') - const sortAs = last - ? [last, [first, middle].filter(x => x).join(' ')].join(', ') - : null - return { name, sortAs } - } - const getDate = el => el?.getAttribute('value') ?? getElementText(el) - const annotation = $('title-info annotation') - book.metadata = { - title: getElementText($('title-info book-title')), - identifier: getElementText($('document-info id')), - language: getElementText($('title-info lang')), - author: $$('title-info author').map(getPerson), - translator: $$('title-info translator').map(getPerson), - contributor: $$('document-info author').map(getPerson) - // techincially the program probably shouldn't get the `bkp` role - // but it has been so used by calibre, so ¯\_(ツ)_/¯ - .concat($$('document-info program-used').map(getElementText)) - .map(x => Object.assign(typeof x === 'string' ? { name: x } : x, - { role: 'bkp' })), - publisher: getElementText($('publish-info publisher')), - published: getDate($('title-info date')), - modified: getDate($('document-info date')), - description: annotation ? converter.convert(annotation, - { annotation: ['div', SECTION] }).innerHTML : null, - subject: $$('title-info genre').map(getElementText), - } - if ($('coverpage image')) { - const src = converter.getImageSrc($('coverpage image')) - book.getCover = () => fetch(src).then(res => res.blob()) - } else book.getCover = () => null + const $ = (x) => doc.querySelector(x) + const $$ = (x) => [...doc.querySelectorAll(x)] + const getPerson = (el) => { + const nick = getElementText(el.querySelector('nickname')) + if (nick) return nick + const first = getElementText(el.querySelector('first-name')) + const middle = getElementText(el.querySelector('middle-name')) + const last = getElementText(el.querySelector('last-name')) + const name = [first, middle, last].filter((x) => x).join(' ') + const sortAs = last ? [last, [first, middle].filter((x) => x).join(' ')].join(', ') : null + return { name, sortAs } + } + const getDate = (el) => el?.getAttribute('value') ?? getElementText(el) + const annotation = $('title-info annotation') + book.metadata = { + title: getElementText($('title-info book-title')), + identifier: getElementText($('document-info id')), + language: getElementText($('title-info lang')), + author: $$('title-info author').map(getPerson), + translator: $$('title-info translator').map(getPerson), + contributor: $$('document-info author') + .map(getPerson) + // techincially the program probably shouldn't get the `bkp` role + // but it has been so used by calibre, so ¯\_(ツ)_/¯ + .concat($$('document-info program-used').map(getElementText)) + .map((x) => Object.assign(typeof x === 'string' ? { name: x } : x, { role: 'bkp' })), + publisher: getElementText($('publish-info publisher')), + published: getDate($('title-info date')), + modified: getDate($('document-info date')), + description: annotation ? converter.convert(annotation, { annotation: ['div', SECTION] }).innerHTML : null, + subject: $$('title-info genre').map(getElementText), + } + if ($('coverpage image')) { + const src = converter.getImageSrc($('coverpage image')) + book.getCover = () => fetch(src).then((res) => res.blob()) + } else book.getCover = () => null - // get convert each body - const bodyData = Array.from(doc.querySelectorAll('body'), body => { - const converted = converter.convert(body, { body: ['body', BODY] }) - return [Array.from(converted.children, el => { - // get list of IDs in the section - const ids = [el, ...el.querySelectorAll('[id]')].map(el => el.id) - return { el, ids } - }), converted] + // get convert each body + const bodyData = Array.from(doc.querySelectorAll('body'), (body) => { + const converted = converter.convert(body, { body: ['body', BODY] }) + return [ + Array.from(converted.children, (el) => { + // get list of IDs in the section + const ids = [el, ...el.querySelectorAll('[id]')].map((el) => el.id) + return { el, ids } + }), + converted, + ] + }) + + const urls = [] + const sectionData = bodyData[0][0] + // make a separate section for each section in the first body + .map(({ el, ids }) => { + // set up titles for TOC + const titles = Array.from(el.querySelectorAll(':scope > section > .title'), (el, index) => { + el.setAttribute(dataID, index) + return { title: getElementText(el), index } + }) + return { ids, titles, el } + }) + // for additional bodies, only make one section for each body + .concat( + bodyData.slice(1).map(([sections, body]) => { + const ids = sections.map((s) => s.ids).flat() + body.classList.add('notesBodyType') + return { ids, el: body, linear: 'no' } + }), + ) + .map(({ ids, titles, el, linear }) => { + const str = template(el.outerHTML) + const blob = new Blob([str], { type: MIME.XHTML }) + const url = URL.createObjectURL(blob) + urls.push(url) + const title = normalizeWhitespace( + el.querySelector('.title, .subtitle, p')?.textContent ?? (el.classList.contains('title') ? el.textContent : ''), + ) + return { + ids, + title, + titles, + load: () => url, + createDocument: () => new DOMParser().parseFromString(str, MIME.XHTML), + // doo't count image data as it'd skew the size too much + size: blob.size - Array.from(el.querySelectorAll('[src]'), (el) => el.getAttribute('src')?.length ?? 0).reduce((a, b) => a + b, 0), + linear, + } }) - const urls = [] - const sectionData = bodyData[0][0] - // make a separate section for each section in the first body - .map(({ el, ids }) => { - // set up titles for TOC - const titles = Array.from( - el.querySelectorAll(':scope > section > .title'), - (el, index) => { - el.setAttribute(dataID, index) - return { title: getElementText(el), index } - }) - return { ids, titles, el } - }) - // for additional bodies, only make one section for each body - .concat(bodyData.slice(1).map(([sections, body]) => { - const ids = sections.map(s => s.ids).flat() - body.classList.add('notesBodyType') - return { ids, el: body, linear: 'no' } - })) - .map(({ ids, titles, el, linear }) => { - const str = template(el.outerHTML) - const blob = new Blob([str], { type: MIME.XHTML }) - const url = URL.createObjectURL(blob) - urls.push(url) - const title = normalizeWhitespace( - el.querySelector('.title, .subtitle, p')?.textContent - ?? (el.classList.contains('title') ? el.textContent : '')) - return { - ids, title, titles, load: () => url, - createDocument: () => new DOMParser().parseFromString(str, MIME.XHTML), - // doo't count image data as it'd skew the size too much - size: blob.size - Array.from(el.querySelectorAll('[src]'), - el => el.getAttribute('src')?.length ?? 0) - .reduce((a, b) => a + b, 0), - linear, - } - }) + const idMap = new Map() + book.sections = sectionData.map((section, index) => { + const { ids, load, createDocument, size, linear } = section + for (const id of ids) if (id) idMap.set(id, index) + return { id: index, load, createDocument, size, linear } + }) - const idMap = new Map() - book.sections = sectionData.map((section, index) => { - const { ids, load, createDocument, size, linear } = section - for (const id of ids) if (id) idMap.set(id, index) - return { id: index, load, createDocument, size, linear } + book.toc = sectionData + .map(({ title, titles }, index) => { + const id = index.toString() + return { + label: title, + href: id, + subitems: titles?.length + ? titles.map(({ title, index }) => ({ + label: title, + href: `${id}#${index}`, + })) + : null, + } }) + .filter((item) => item) - book.toc = sectionData.map(({ title, titles }, index) => { - const id = index.toString() - return { - label: title, - href: id, - subitems: titles?.length ? titles.map(({ title, index }) => ({ - label: title, - href: `${id}#${index}`, - })) : null, - } - }).filter(item => item) + book.resolveHref = (href) => { + const [a, b] = href.split('#') + return a + ? // the link is from the TOC + { index: Number(a), anchor: (doc) => doc.querySelector(`[${dataID}="${b}"]`) } + : // link from within the page + { index: idMap.get(b), anchor: (doc) => doc.getElementById(b) } + } + book.splitTOCHref = (href) => href?.split('#')?.map((x) => Number(x)) ?? [] + book.getTOCFragment = (doc, id) => doc.querySelector(`[${dataID}="${id}"]`) - book.resolveHref = href => { - const [a, b] = href.split('#') - return a - // the link is from the TOC - ? { index: Number(a), anchor: doc => doc.querySelector(`[${dataID}="${b}"]`) } - // link from within the page - : { index: idMap.get(b), anchor: doc => doc.getElementById(b) } - } - book.splitTOCHref = href => href?.split('#')?.map(x => Number(x)) ?? [] - book.getTOCFragment = (doc, id) => doc.querySelector(`[${dataID}="${id}"]`) - - book.destroy = () => { - for (const url of urls) URL.revokeObjectURL(url) - } - return book + book.destroy = () => { + for (const url of urls) URL.revokeObjectURL(url) + } + return book } diff --git a/client/public/assets/foliate/fixed-layout.js b/client/public/assets/foliate/fixed-layout.js index c582abad1..dbbf5fbc9 100644 --- a/client/public/assets/foliate/fixed-layout.js +++ b/client/public/assets/foliate/fixed-layout.js @@ -1,54 +1,53 @@ -const parseViewport = str => str +const parseViewport = (str) => + str ?.split(/[,;\s]/) // NOTE: technically, only the comma is valid - ?.filter(x => x) - ?.map(x => x.split('=').map(x => x.trim())) + ?.filter((x) => x) + ?.map((x) => x.split('=').map((x) => x.trim())) const getViewport = (doc, viewport) => { - // use `viewBox` for SVG - if (doc.documentElement.localName === 'svg') { - const [, , width, height] = doc.documentElement - .getAttribute('viewBox')?.split(/\s/) ?? [] - return { width, height } - } + // use `viewBox` for SVG + if (doc.documentElement.localName === 'svg') { + const [, , width, height] = doc.documentElement.getAttribute('viewBox')?.split(/\s/) ?? [] + return { width, height } + } - // get `viewport` `meta` element - const meta = parseViewport(doc.querySelector('meta[name="viewport"]') - ?.getAttribute('content')) - if (meta) return Object.fromEntries(meta) + // get `viewport` `meta` element + const meta = parseViewport(doc.querySelector('meta[name="viewport"]')?.getAttribute('content')) + if (meta) return Object.fromEntries(meta) - // fallback to book's viewport - if (typeof viewport === 'string') return parseViewport(viewport) - if (viewport?.width && viewport.height) return viewport + // fallback to book's viewport + if (typeof viewport === 'string') return parseViewport(viewport) + if (viewport?.width && viewport.height) return viewport - // if no viewport (possibly with image directly in spine), get image size - const img = doc.querySelector('img') - if (img) return { width: img.naturalWidth, height: img.naturalHeight } + // if no viewport (possibly with image directly in spine), get image size + const img = doc.querySelector('img') + if (img) return { width: img.naturalWidth, height: img.naturalHeight } - // just show *something*, i guess... - console.warn(new Error('Missing viewport properties')) - return { width: 1000, height: 2000 } + // just show *something*, i guess... + console.warn(new Error('Missing viewport properties')) + return { width: 1000, height: 2000 } } export class FixedLayout extends HTMLElement { - static observedAttributes = ['zoom'] - #root = this.attachShadow({ mode: 'closed' }) - #observer = new ResizeObserver(() => this.#render()) - #spreads - #index = -1 - defaultViewport - spread - #portrait = false - #left - #right - #center - #side - #zoom - constructor() { - super() + static observedAttributes = ['zoom'] + #root = this.attachShadow({ mode: 'closed' }) + #observer = new ResizeObserver(() => this.#render()) + #spreads + #index = -1 + defaultViewport + spread + #portrait = false + #left + #right + #center + #side + #zoom + constructor() { + super() - const sheet = new CSSStyleSheet() - this.#root.adoptedStyleSheets = [sheet] - sheet.replaceSync(`:host { + const sheet = new CSSStyleSheet() + this.#root.adoptedStyleSheets = [sheet] + sheet.replaceSync(`:host { width: 100%; height: 100%; display: flex; @@ -57,263 +56,259 @@ export class FixedLayout extends HTMLElement { overflow: auto; }`) - this.#observer.observe(this) + this.#observer.observe(this) + } + attributeChangedCallback(name, _, value) { + switch (name) { + case 'zoom': + this.#zoom = value !== 'fit-width' && value !== 'fit-page' ? parseFloat(value) : value + this.#render() + break } - attributeChangedCallback(name, _, value) { - switch (name) { - case 'zoom': - this.#zoom = value !== 'fit-width' && value !== 'fit-page' - ? parseFloat(value) : value - this.#render() - break - } - } - async #createFrame({ index, src: srcOption }) { - const srcOptionIsString = typeof srcOption === 'string' - const src = srcOptionIsString ? srcOption : srcOption?.src - const onZoom = srcOptionIsString ? null : srcOption?.onZoom - const element = document.createElement('div') - element.setAttribute('dir', 'ltr') - const iframe = document.createElement('iframe') - element.append(iframe) - Object.assign(iframe.style, { - border: '0', - display: 'none', - overflow: 'hidden', - }) - // `allow-scripts` is needed for events because of WebKit bug - // https://bugs.webkit.org/show_bug.cgi?id=218086 - iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') - iframe.setAttribute('scrolling', 'no') - iframe.setAttribute('part', 'filter') - this.#root.append(element) - if (!src) return { blank: true, element, iframe } - return new Promise(resolve => { - iframe.addEventListener('load', () => { - const doc = iframe.contentDocument - this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } })) - const { width, height } = getViewport(doc, this.defaultViewport) - resolve({ - element, iframe, - width: parseFloat(width), - height: parseFloat(height), - onZoom, - }) - }, { once: true }) - iframe.src = src - }) - } - #render(side = this.#side) { - if (!side) return - const left = this.#left ?? {} - const right = this.#center ?? this.#right ?? {} - const target = side === 'left' ? left : right - const { width, height } = this.getBoundingClientRect() - const portrait = this.spread !== 'both' && this.spread !== 'portrait' - && height > width - this.#portrait = portrait - const blankWidth = left.width ?? right.width ?? 0 - const blankHeight = left.height ?? right.height ?? 0 + } + async #createFrame({ index, src: srcOption }) { + const srcOptionIsString = typeof srcOption === 'string' + const src = srcOptionIsString ? srcOption : srcOption?.src + const onZoom = srcOptionIsString ? null : srcOption?.onZoom + const element = document.createElement('div') + element.setAttribute('dir', 'ltr') + const iframe = document.createElement('iframe') + element.append(iframe) + Object.assign(iframe.style, { + border: '0', + display: 'none', + overflow: 'hidden', + }) + // `allow-scripts` is needed for events because of WebKit bug + // https://bugs.webkit.org/show_bug.cgi?id=218086 + iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') + iframe.setAttribute('scrolling', 'no') + iframe.setAttribute('part', 'filter') + this.#root.append(element) + if (!src) return { blank: true, element, iframe } + return new Promise((resolve) => { + iframe.addEventListener( + 'load', + () => { + const doc = iframe.contentDocument + this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } })) + const { width, height } = getViewport(doc, this.defaultViewport) + resolve({ + element, + iframe, + width: parseFloat(width), + height: parseFloat(height), + onZoom, + }) + }, + { once: true }, + ) + iframe.src = src + }) + } + #render(side = this.#side) { + if (!side) return + const left = this.#left ?? {} + const right = this.#center ?? this.#right ?? {} + const target = side === 'left' ? left : right + const { width, height } = this.getBoundingClientRect() + const portrait = this.spread !== 'both' && this.spread !== 'portrait' && height > width + this.#portrait = portrait + const blankWidth = left.width ?? right.width ?? 0 + const blankHeight = left.height ?? right.height ?? 0 - const scale = typeof this.#zoom === 'number' && !isNaN(this.#zoom) - ? this.#zoom - : (this.#zoom === 'fit-width' - ? (portrait || this.#center - ? width / (target.width ?? blankWidth) - : width / ((left.width ?? blankWidth) + (right.width ?? blankWidth))) - : (portrait || this.#center - ? Math.min( - width / (target.width ?? blankWidth), - height / (target.height ?? blankHeight)) - : Math.min( - width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)), - height / Math.max( - left.height ?? blankHeight, - right.height ?? blankHeight))) - ) || 1 + const scale = + typeof this.#zoom === 'number' && !isNaN(this.#zoom) + ? this.#zoom + : (this.#zoom === 'fit-width' + ? portrait || this.#center + ? width / (target.width ?? blankWidth) + : width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)) + : portrait || this.#center + ? Math.min(width / (target.width ?? blankWidth), height / (target.height ?? blankHeight)) + : Math.min( + width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)), + height / Math.max(left.height ?? blankHeight, right.height ?? blankHeight), + )) || 1 - const transform = frame => { - let { element, iframe, width, height, blank, onZoom } = frame - if (!iframe) return - if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale }) - const iframeScale = onZoom ? scale : 1 - Object.assign(iframe.style, { - width: `${width * iframeScale}px`, - height: `${height * iframeScale}px`, - transform: onZoom ? 'none' : `scale(${scale})`, - transformOrigin: 'top left', - display: blank ? 'none' : 'block', - }) - Object.assign(element.style, { - width: `${(width ?? blankWidth) * scale}px`, - height: `${(height ?? blankHeight) * scale}px`, - overflow: 'hidden', - display: 'block', - flexShrink: '0', - marginBlock: 'auto', - }) - if (portrait && frame !== target) { - element.style.display = 'none' - } - } - if (this.#center) { - transform(this.#center) - } else { - transform(left) - transform(right) - } + const transform = (frame) => { + let { element, iframe, width, height, blank, onZoom } = frame + if (!iframe) return + if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale }) + const iframeScale = onZoom ? scale : 1 + Object.assign(iframe.style, { + width: `${width * iframeScale}px`, + height: `${height * iframeScale}px`, + transform: onZoom ? 'none' : `scale(${scale})`, + transformOrigin: 'top left', + display: blank ? 'none' : 'block', + }) + Object.assign(element.style, { + width: `${(width ?? blankWidth) * scale}px`, + height: `${(height ?? blankHeight) * scale}px`, + overflow: 'hidden', + display: 'block', + flexShrink: '0', + marginBlock: 'auto', + }) + if (portrait && frame !== target) { + element.style.display = 'none' + } } - async #showSpread({ left, right, center, side }) { - this.#root.replaceChildren() - this.#left = null - this.#right = null - this.#center = null - if (center) { - this.#center = await this.#createFrame(center) - this.#side = 'center' - this.#render() - } else { - this.#left = await this.#createFrame(left) - this.#right = await this.#createFrame(right) - this.#side = this.#left.blank ? 'right' - : this.#right.blank ? 'left' : side - this.#render() - } + if (this.#center) { + transform(this.#center) + } else { + transform(left) + transform(right) } - #goLeft() { - if (this.#center || this.#left?.blank) return - if (this.#portrait && this.#left?.element?.style?.display === 'none') { - this.#side = 'left' - this.#render() - this.#reportLocation('page') - return true - } + } + async #showSpread({ left, right, center, side }) { + this.#root.replaceChildren() + this.#left = null + this.#right = null + this.#center = null + if (center) { + this.#center = await this.#createFrame(center) + this.#side = 'center' + this.#render() + } else { + this.#left = await this.#createFrame(left) + this.#right = await this.#createFrame(right) + this.#side = this.#left.blank ? 'right' : this.#right.blank ? 'left' : side + this.#render() } - #goRight() { - if (this.#center || this.#right?.blank) return - if (this.#portrait && this.#right?.element?.style?.display === 'none') { - this.#side = 'right' - this.#render() - this.#reportLocation('page') - return true - } + } + #goLeft() { + if (this.#center || this.#left?.blank) return + if (this.#portrait && this.#left?.element?.style?.display === 'none') { + this.#side = 'left' + this.#render() + this.#reportLocation('page') + return true } - open(book) { - this.book = book - const { rendition } = book - this.spread = rendition?.spread - this.defaultViewport = rendition?.viewport + } + #goRight() { + if (this.#center || this.#right?.blank) return + if (this.#portrait && this.#right?.element?.style?.display === 'none') { + this.#side = 'right' + this.#render() + this.#reportLocation('page') + return true + } + } + open(book) { + this.book = book + const { rendition } = book + this.spread = rendition?.spread + this.defaultViewport = rendition?.viewport - const rtl = book.dir === 'rtl' - const ltr = !rtl - this.rtl = rtl + const rtl = book.dir === 'rtl' + const ltr = !rtl + this.rtl = rtl - if (rendition?.spread === 'none') - this.#spreads = book.sections.map(section => ({ center: section })) - else this.#spreads = book.sections.reduce((arr, section, i) => { - const last = arr[arr.length - 1] - const { pageSpread } = section - const newSpread = () => { - const spread = {} - arr.push(spread) - return spread - } - if (pageSpread === 'center') { - const spread = last.left || last.right ? newSpread() : last - spread.center = section - } - else if (pageSpread === 'left') { - const spread = last.center || last.left || ltr && i ? newSpread() : last - spread.left = section - } - else if (pageSpread === 'right') { - const spread = last.center || last.right || rtl && i ? newSpread() : last - spread.right = section - } - else if (ltr) { - if (last.center || last.right) newSpread().left = section - else if (last.left || !i) last.right = section - else last.left = section - } - else { - if (last.center || last.left) newSpread().right = section - else if (last.right || !i) last.left = section - else last.right = section - } - return arr - }, [{}]) + if (rendition?.spread === 'none') this.#spreads = book.sections.map((section) => ({ center: section })) + else + this.#spreads = book.sections.reduce( + (arr, section, i) => { + const last = arr[arr.length - 1] + const { pageSpread } = section + const newSpread = () => { + const spread = {} + arr.push(spread) + return spread + } + if (pageSpread === 'center') { + const spread = last.left || last.right ? newSpread() : last + spread.center = section + } else if (pageSpread === 'left') { + const spread = last.center || last.left || (ltr && i) ? newSpread() : last + spread.left = section + } else if (pageSpread === 'right') { + const spread = last.center || last.right || (rtl && i) ? newSpread() : last + spread.right = section + } else if (ltr) { + if (last.center || last.right) newSpread().left = section + else if (last.left || !i) last.right = section + else last.left = section + } else { + if (last.center || last.left) newSpread().right = section + else if (last.right || !i) last.left = section + else last.right = section + } + return arr + }, + [{}], + ) + } + get index() { + const spread = this.#spreads[this.#index] + const section = spread?.center ?? (this.#side === 'left' ? (spread.left ?? spread.right) : (spread.right ?? spread.left)) + return this.book.sections.indexOf(section) + } + #reportLocation(reason) { + this.dispatchEvent(new CustomEvent('relocate', { detail: { reason, range: null, index: this.index, fraction: 0, size: 1 } })) + } + getSpreadOf(section) { + const spreads = this.#spreads + for (let index = 0; index < spreads.length; index++) { + const { left, right, center } = spreads[index] + if (left === section) return { index, side: 'left' } + if (right === section) return { index, side: 'right' } + if (center === section) return { index, side: 'center' } } - get index() { - const spread = this.#spreads[this.#index] - const section = spread?.center ?? (this.#side === 'left' - ? spread.left ?? spread.right : spread.right ?? spread.left) - return this.book.sections.indexOf(section) + } + async goToSpread(index, side, reason) { + if (index < 0 || index > this.#spreads.length - 1) return + if (index === this.#index) { + this.#render(side) + return } - #reportLocation(reason) { - this.dispatchEvent(new CustomEvent('relocate', { detail: - { reason, range: null, index: this.index, fraction: 0, size: 1 } })) - } - getSpreadOf(section) { - const spreads = this.#spreads - for (let index = 0; index < spreads.length; index++) { - const { left, right, center } = spreads[index] - if (left === section) return { index, side: 'left' } - if (right === section) return { index, side: 'right' } - if (center === section) return { index, side: 'center' } - } - } - async goToSpread(index, side, reason) { - if (index < 0 || index > this.#spreads.length - 1) return - if (index === this.#index) { - this.#render(side) - return - } - this.#index = index - const spread = this.#spreads[index] - if (spread.center) { - const index = this.book.sections.indexOf(spread.center) - const src = await spread.center?.load?.() - await this.#showSpread({ center: { index, src } }) - } else { - const indexL = this.book.sections.indexOf(spread.left) - const indexR = this.book.sections.indexOf(spread.right) - const srcL = await spread.left?.load?.() - const srcR = await spread.right?.load?.() - const left = { index: indexL, src: srcL } - const right = { index: indexR, src: srcR } - await this.#showSpread({ left, right, side }) - } - this.#reportLocation(reason) - } - async select(target) { - await this.goTo(target) - // TODO - } - async goTo(target) { - const { book } = this - const resolved = await target - const section = book.sections[resolved.index] - if (!section) return - const { index, side } = this.getSpreadOf(section) - await this.goToSpread(index, side) - } - async next() { - const s = this.rtl ? this.#goLeft() : this.#goRight() - if (!s) return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left', 'page') - } - async prev() { - const s = this.rtl ? this.#goRight() : this.#goLeft() - if (!s) return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right', 'page') - } - getContents() { - return Array.from(this.#root.querySelectorAll('iframe'), frame => ({ - doc: frame.contentDocument, - // TODO: index, overlayer - })) - } - destroy() { - this.#observer.unobserve(this) + this.#index = index + const spread = this.#spreads[index] + if (spread.center) { + const index = this.book.sections.indexOf(spread.center) + const src = await spread.center?.load?.() + await this.#showSpread({ center: { index, src } }) + } else { + const indexL = this.book.sections.indexOf(spread.left) + const indexR = this.book.sections.indexOf(spread.right) + const srcL = await spread.left?.load?.() + const srcR = await spread.right?.load?.() + const left = { index: indexL, src: srcL } + const right = { index: indexR, src: srcR } + await this.#showSpread({ left, right, side }) } + this.#reportLocation(reason) + } + async select(target) { + await this.goTo(target) + // TODO + } + async goTo(target) { + const { book } = this + const resolved = await target + const section = book.sections[resolved.index] + if (!section) return + const { index, side } = this.getSpreadOf(section) + await this.goToSpread(index, side) + } + async next() { + const s = this.rtl ? this.#goLeft() : this.#goRight() + if (!s) return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left', 'page') + } + async prev() { + const s = this.rtl ? this.#goRight() : this.#goLeft() + if (!s) return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right', 'page') + } + getContents() { + return Array.from(this.#root.querySelectorAll('iframe'), (frame) => ({ + doc: frame.contentDocument, + // TODO: index, overlayer + })) + } + destroy() { + this.#observer.unobserve(this) + } } customElements.define('foliate-fxl', FixedLayout) diff --git a/client/public/assets/foliate/mobi.js b/client/public/assets/foliate/mobi.js index 811b0d3cd..c2f0a1bfc 100644 --- a/client/public/assets/foliate/mobi.js +++ b/client/public/assets/foliate/mobi.js @@ -1,1236 +1,1327 @@ -const unescapeHTML = str => { - if (!str) return '' - const textarea = document.createElement('textarea') - textarea.innerHTML = str - return textarea.value +const unescapeHTML = (str) => { + if (!str) return '' + const textarea = document.createElement('textarea') + textarea.innerHTML = str + return textarea.value } const MIME = { - XML: 'application/xml', - XHTML: 'application/xhtml+xml', - HTML: 'text/html', - CSS: 'text/css', - SVG: 'image/svg+xml', + XML: 'application/xml', + XHTML: 'application/xhtml+xml', + HTML: 'text/html', + CSS: 'text/css', + SVG: 'image/svg+xml', } const PDB_HEADER = { - name: [0, 32, 'string'], - type: [60, 4, 'string'], - creator: [64, 4, 'string'], - numRecords: [76, 2, 'uint'], + name: [0, 32, 'string'], + type: [60, 4, 'string'], + creator: [64, 4, 'string'], + numRecords: [76, 2, 'uint'], } const PALMDOC_HEADER = { - compression: [0, 2, 'uint'], - numTextRecords: [8, 2, 'uint'], - recordSize: [10, 2, 'uint'], - encryption: [12, 2, 'uint'], + compression: [0, 2, 'uint'], + numTextRecords: [8, 2, 'uint'], + recordSize: [10, 2, 'uint'], + encryption: [12, 2, 'uint'], } const MOBI_HEADER = { - magic: [16, 4, 'string'], - length: [20, 4, 'uint'], - type: [24, 4, 'uint'], - encoding: [28, 4, 'uint'], - uid: [32, 4, 'uint'], - version: [36, 4, 'uint'], - titleOffset: [84, 4, 'uint'], - titleLength: [88, 4, 'uint'], - localeRegion: [94, 1, 'uint'], - localeLanguage: [95, 1, 'uint'], - resourceStart: [108, 4, 'uint'], - huffcdic: [112, 4, 'uint'], - numHuffcdic: [116, 4, 'uint'], - exthFlag: [128, 4, 'uint'], - trailingFlags: [240, 4, 'uint'], - indx: [244, 4, 'uint'], + magic: [16, 4, 'string'], + length: [20, 4, 'uint'], + type: [24, 4, 'uint'], + encoding: [28, 4, 'uint'], + uid: [32, 4, 'uint'], + version: [36, 4, 'uint'], + titleOffset: [84, 4, 'uint'], + titleLength: [88, 4, 'uint'], + localeRegion: [94, 1, 'uint'], + localeLanguage: [95, 1, 'uint'], + resourceStart: [108, 4, 'uint'], + huffcdic: [112, 4, 'uint'], + numHuffcdic: [116, 4, 'uint'], + exthFlag: [128, 4, 'uint'], + trailingFlags: [240, 4, 'uint'], + indx: [244, 4, 'uint'], } const KF8_HEADER = { - resourceStart: [108, 4, 'uint'], - fdst: [192, 4, 'uint'], - numFdst: [196, 4, 'uint'], - frag: [248, 4, 'uint'], - skel: [252, 4, 'uint'], - guide: [260, 4, 'uint'], + resourceStart: [108, 4, 'uint'], + fdst: [192, 4, 'uint'], + numFdst: [196, 4, 'uint'], + frag: [248, 4, 'uint'], + skel: [252, 4, 'uint'], + guide: [260, 4, 'uint'], } const EXTH_HEADER = { - magic: [0, 4, 'string'], - length: [4, 4, 'uint'], - count: [8, 4, 'uint'], + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + count: [8, 4, 'uint'], } const INDX_HEADER = { - magic: [0, 4, 'string'], - length: [4, 4, 'uint'], - type: [8, 4, 'uint'], - idxt: [20, 4, 'uint'], - numRecords: [24, 4, 'uint'], - encoding: [28, 4, 'uint'], - language: [32, 4, 'uint'], - total: [36, 4, 'uint'], - ordt: [40, 4, 'uint'], - ligt: [44, 4, 'uint'], - numLigt: [48, 4, 'uint'], - numCncx: [52, 4, 'uint'], + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + type: [8, 4, 'uint'], + idxt: [20, 4, 'uint'], + numRecords: [24, 4, 'uint'], + encoding: [28, 4, 'uint'], + language: [32, 4, 'uint'], + total: [36, 4, 'uint'], + ordt: [40, 4, 'uint'], + ligt: [44, 4, 'uint'], + numLigt: [48, 4, 'uint'], + numCncx: [52, 4, 'uint'], } const TAGX_HEADER = { - magic: [0, 4, 'string'], - length: [4, 4, 'uint'], - numControlBytes: [8, 4, 'uint'], + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + numControlBytes: [8, 4, 'uint'], } const HUFF_HEADER = { - magic: [0, 4, 'string'], - offset1: [8, 4, 'uint'], - offset2: [12, 4, 'uint'], + magic: [0, 4, 'string'], + offset1: [8, 4, 'uint'], + offset2: [12, 4, 'uint'], } const CDIC_HEADER = { - magic: [0, 4, 'string'], - length: [4, 4, 'uint'], - numEntries: [8, 4, 'uint'], - codeLength: [12, 4, 'uint'], + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + numEntries: [8, 4, 'uint'], + codeLength: [12, 4, 'uint'], } const FDST_HEADER = { - magic: [0, 4, 'string'], - numEntries: [8, 4, 'uint'], + magic: [0, 4, 'string'], + numEntries: [8, 4, 'uint'], } const FONT_HEADER = { - flags: [8, 4, 'uint'], - dataStart: [12, 4, 'uint'], - keyLength: [16, 4, 'uint'], - keyStart: [20, 4, 'uint'], + flags: [8, 4, 'uint'], + dataStart: [12, 4, 'uint'], + keyLength: [16, 4, 'uint'], + keyStart: [20, 4, 'uint'], } const MOBI_ENCODING = { - 1252: 'windows-1252', - 65001: 'utf-8', + 1252: 'windows-1252', + 65001: 'utf-8', } const EXTH_RECORD_TYPE = { - 100: ['creator', 'string', true], - 101: ['publisher'], - 103: ['description'], - 104: ['isbn'], - 105: ['subject', 'string', true], - 106: ['date'], - 108: ['contributor', 'string', true], - 109: ['rights'], - 110: ['subjectCode', 'string', true], - 112: ['source', 'string', true], - 113: ['asin'], - 121: ['boundary', 'uint'], - 122: ['fixedLayout'], - 125: ['numResources', 'uint'], - 126: ['originalResolution'], - 127: ['zeroGutter'], - 128: ['zeroMargin'], - 129: ['coverURI'], - 132: ['regionMagnification'], - 201: ['coverOffset', 'uint'], - 202: ['thumbnailOffset', 'uint'], - 503: ['title'], - 524: ['language', 'string', true], - 527: ['pageProgressionDirection'], + 100: ['creator', 'string', true], + 101: ['publisher'], + 103: ['description'], + 104: ['isbn'], + 105: ['subject', 'string', true], + 106: ['date'], + 108: ['contributor', 'string', true], + 109: ['rights'], + 110: ['subjectCode', 'string', true], + 112: ['source', 'string', true], + 113: ['asin'], + 121: ['boundary', 'uint'], + 122: ['fixedLayout'], + 125: ['numResources', 'uint'], + 126: ['originalResolution'], + 127: ['zeroGutter'], + 128: ['zeroMargin'], + 129: ['coverURI'], + 132: ['regionMagnification'], + 201: ['coverOffset', 'uint'], + 202: ['thumbnailOffset', 'uint'], + 503: ['title'], + 524: ['language', 'string', true], + 527: ['pageProgressionDirection'], } const MOBI_LANG = { - 1: ['ar', 'ar-SA', 'ar-IQ', 'ar-EG', 'ar-LY', 'ar-DZ', 'ar-MA', 'ar-TN', 'ar-OM', - 'ar-YE', 'ar-SY', 'ar-JO', 'ar-LB', 'ar-KW', 'ar-AE', 'ar-BH', 'ar-QA'], - 2: ['bg'], 3: ['ca'], 4: ['zh', 'zh-TW', 'zh-CN', 'zh-HK', 'zh-SG'], 5: ['cs'], - 6: ['da'], 7: ['de', 'de-DE', 'de-CH', 'de-AT', 'de-LU', 'de-LI'], 8: ['el'], - 9: ['en', 'en-US', 'en-GB', 'en-AU', 'en-CA', 'en-NZ', 'en-IE', 'en-ZA', - 'en-JM', null, 'en-BZ', 'en-TT', 'en-ZW', 'en-PH'], - 10: ['es', 'es-ES', 'es-MX', null, 'es-GT', 'es-CR', 'es-PA', 'es-DO', - 'es-VE', 'es-CO', 'es-PE', 'es-AR', 'es-EC', 'es-CL', 'es-UY', 'es-PY', - 'es-BO', 'es-SV', 'es-HN', 'es-NI', 'es-PR'], - 11: ['fi'], 12: ['fr', 'fr-FR', 'fr-BE', 'fr-CA', 'fr-CH', 'fr-LU', 'fr-MC'], - 13: ['he'], 14: ['hu'], 15: ['is'], 16: ['it', 'it-IT', 'it-CH'], - 17: ['ja'], 18: ['ko'], 19: ['nl', 'nl-NL', 'nl-BE'], 20: ['no', 'nb', 'nn'], - 21: ['pl'], 22: ['pt', 'pt-BR', 'pt-PT'], 23: ['rm'], 24: ['ro'], 25: ['ru'], - 26: ['hr', null, 'sr'], 27: ['sk'], 28: ['sq'], 29: ['sv', 'sv-SE', 'sv-FI'], - 30: ['th'], 31: ['tr'], 32: ['ur'], 33: ['id'], 34: ['uk'], 35: ['be'], - 36: ['sl'], 37: ['et'], 38: ['lv'], 39: ['lt'], 41: ['fa'], 42: ['vi'], - 43: ['hy'], 44: ['az'], 45: ['eu'], 46: ['hsb'], 47: ['mk'], 48: ['st'], - 49: ['ts'], 50: ['tn'], 52: ['xh'], 53: ['zu'], 54: ['af'], 55: ['ka'], - 56: ['fo'], 57: ['hi'], 58: ['mt'], 59: ['se'], 62: ['ms'], 63: ['kk'], - 65: ['sw'], 67: ['uz', null, 'uz-UZ'], 68: ['tt'], 69: ['bn'], 70: ['pa'], - 71: ['gu'], 72: ['or'], 73: ['ta'], 74: ['te'], 75: ['kn'], 76: ['ml'], - 77: ['as'], 78: ['mr'], 79: ['sa'], 82: ['cy', 'cy-GB'], 83: ['gl', 'gl-ES'], - 87: ['kok'], 97: ['ne'], 98: ['fy'], + 1: [ + 'ar', + 'ar-SA', + 'ar-IQ', + 'ar-EG', + 'ar-LY', + 'ar-DZ', + 'ar-MA', + 'ar-TN', + 'ar-OM', + 'ar-YE', + 'ar-SY', + 'ar-JO', + 'ar-LB', + 'ar-KW', + 'ar-AE', + 'ar-BH', + 'ar-QA', + ], + 2: ['bg'], + 3: ['ca'], + 4: ['zh', 'zh-TW', 'zh-CN', 'zh-HK', 'zh-SG'], + 5: ['cs'], + 6: ['da'], + 7: ['de', 'de-DE', 'de-CH', 'de-AT', 'de-LU', 'de-LI'], + 8: ['el'], + 9: ['en', 'en-US', 'en-GB', 'en-AU', 'en-CA', 'en-NZ', 'en-IE', 'en-ZA', 'en-JM', null, 'en-BZ', 'en-TT', 'en-ZW', 'en-PH'], + 10: [ + 'es', + 'es-ES', + 'es-MX', + null, + 'es-GT', + 'es-CR', + 'es-PA', + 'es-DO', + 'es-VE', + 'es-CO', + 'es-PE', + 'es-AR', + 'es-EC', + 'es-CL', + 'es-UY', + 'es-PY', + 'es-BO', + 'es-SV', + 'es-HN', + 'es-NI', + 'es-PR', + ], + 11: ['fi'], + 12: ['fr', 'fr-FR', 'fr-BE', 'fr-CA', 'fr-CH', 'fr-LU', 'fr-MC'], + 13: ['he'], + 14: ['hu'], + 15: ['is'], + 16: ['it', 'it-IT', 'it-CH'], + 17: ['ja'], + 18: ['ko'], + 19: ['nl', 'nl-NL', 'nl-BE'], + 20: ['no', 'nb', 'nn'], + 21: ['pl'], + 22: ['pt', 'pt-BR', 'pt-PT'], + 23: ['rm'], + 24: ['ro'], + 25: ['ru'], + 26: ['hr', null, 'sr'], + 27: ['sk'], + 28: ['sq'], + 29: ['sv', 'sv-SE', 'sv-FI'], + 30: ['th'], + 31: ['tr'], + 32: ['ur'], + 33: ['id'], + 34: ['uk'], + 35: ['be'], + 36: ['sl'], + 37: ['et'], + 38: ['lv'], + 39: ['lt'], + 41: ['fa'], + 42: ['vi'], + 43: ['hy'], + 44: ['az'], + 45: ['eu'], + 46: ['hsb'], + 47: ['mk'], + 48: ['st'], + 49: ['ts'], + 50: ['tn'], + 52: ['xh'], + 53: ['zu'], + 54: ['af'], + 55: ['ka'], + 56: ['fo'], + 57: ['hi'], + 58: ['mt'], + 59: ['se'], + 62: ['ms'], + 63: ['kk'], + 65: ['sw'], + 67: ['uz', null, 'uz-UZ'], + 68: ['tt'], + 69: ['bn'], + 70: ['pa'], + 71: ['gu'], + 72: ['or'], + 73: ['ta'], + 74: ['te'], + 75: ['kn'], + 76: ['ml'], + 77: ['as'], + 78: ['mr'], + 79: ['sa'], + 82: ['cy', 'cy-GB'], + 83: ['gl', 'gl-ES'], + 87: ['kok'], + 97: ['ne'], + 98: ['fy'], } const concatTypedArray = (a, b) => { - const result = new a.constructor(a.length + b.length) - result.set(a) - result.set(b, a.length) - return result + const result = new a.constructor(a.length + b.length) + result.set(a) + result.set(b, a.length) + return result } const concatTypedArray3 = (a, b, c) => { - const result = new a.constructor(a.length + b.length + c.length) - result.set(a) - result.set(b, a.length) - result.set(c, a.length + b.length) - return result + const result = new a.constructor(a.length + b.length + c.length) + result.set(a) + result.set(b, a.length) + result.set(c, a.length + b.length) + return result } const decoder = new TextDecoder() -const getString = buffer => decoder.decode(buffer) -const getUint = buffer => { - if (!buffer) return - const l = buffer.byteLength - const func = l === 4 ? 'getUint32' : l === 2 ? 'getUint16' : 'getUint8' - return new DataView(buffer)[func](0) +const getString = (buffer) => decoder.decode(buffer) +const getUint = (buffer) => { + if (!buffer) return + const l = buffer.byteLength + const func = l === 4 ? 'getUint32' : l === 2 ? 'getUint16' : 'getUint8' + return new DataView(buffer)[func](0) } -const getStruct = (def, buffer) => Object.fromEntries(Array.from(Object.entries(def)) - .map(([key, [start, len, type]]) => [key, - (type === 'string' ? getString : getUint)(buffer.slice(start, start + len))])) +const getStruct = (def, buffer) => + Object.fromEntries( + Array.from(Object.entries(def)).map(([key, [start, len, type]]) => [ + key, + (type === 'string' ? getString : getUint)(buffer.slice(start, start + len)), + ]), + ) -const getDecoder = x => new TextDecoder(MOBI_ENCODING[x]) +const getDecoder = (x) => new TextDecoder(MOBI_ENCODING[x]) const getVarLen = (byteArray, i = 0) => { - let value = 0, length = 0 - for (const byte of byteArray.subarray(i, i + 4)) { - value = (value << 7) | (byte & 0b111_1111) >>> 0 - length++ - if (byte & 0b1000_0000) break - } - return { value, length } + let value = 0, + length = 0 + for (const byte of byteArray.subarray(i, i + 4)) { + value = (value << 7) | ((byte & 0b111_1111) >>> 0) + length++ + if (byte & 0b1000_0000) break + } + return { value, length } } // variable-length quantity, but read from the end of data -const getVarLenFromEnd = byteArray => { - let value = 0 - for (const byte of byteArray.subarray(-4)) { - // `byte & 0b1000_0000` indicates the start of value - if (byte & 0b1000_0000) value = 0 - value = (value << 7) | (byte & 0b111_1111) +const getVarLenFromEnd = (byteArray) => { + let value = 0 + for (const byte of byteArray.subarray(-4)) { + // `byte & 0b1000_0000` indicates the start of value + if (byte & 0b1000_0000) value = 0 + value = (value << 7) | (byte & 0b111_1111) + } + return value +} + +const countBitsSet = (x) => { + let count = 0 + for (; x > 0; x = x >> 1) if ((x & 1) === 1) count++ + return count +} + +const countUnsetEnd = (x) => { + let count = 0 + while ((x & 1) === 0) ((x = x >> 1), count++) + return count +} + +const decompressPalmDOC = (array) => { + let output = [] + for (let i = 0; i < array.length; i++) { + const byte = array[i] + if (byte === 0) + output.push(0) // uncompressed literal, just copy it + else if (byte <= 8) + // copy next 1-8 bytes + for (const x of array.subarray(i + 1, (i += byte) + 1)) output.push(x) + else if (byte <= 0b0111_1111) + output.push(byte) // uncompressed literal + else if (byte <= 0b1011_1111) { + // 1st and 2nd bits are 10, meaning this is a length-distance pair + // read next byte and combine it with current byte + const bytes = (byte << 8) | array[i++ + 1] + // the 3rd to 13th bits encode distance + const distance = (bytes & 0b0011_1111_1111_1111) >>> 3 + // the last 3 bits, plus 3, is the length to copy + const length = (bytes & 0b111) + 3 + for (let j = 0; j < length; j++) output.push(output[output.length - distance]) } - return value -} - -const countBitsSet = x => { - let count = 0 - for (; x > 0; x = x >> 1) if ((x & 1) === 1) count++ - return count -} - -const countUnsetEnd = x => { - let count = 0 - while ((x & 1) === 0) x = x >> 1, count++ - return count -} - -const decompressPalmDOC = array => { - let output = [] - for (let i = 0; i < array.length; i++) { - const byte = array[i] - if (byte === 0) output.push(0) // uncompressed literal, just copy it - else if (byte <= 8) // copy next 1-8 bytes - for (const x of array.subarray(i + 1, (i += byte) + 1)) - output.push(x) - else if (byte <= 0b0111_1111) output.push(byte) // uncompressed literal - else if (byte <= 0b1011_1111) { - // 1st and 2nd bits are 10, meaning this is a length-distance pair - // read next byte and combine it with current byte - const bytes = (byte << 8) | array[i++ + 1] - // the 3rd to 13th bits encode distance - const distance = (bytes & 0b0011_1111_1111_1111) >>> 3 - // the last 3 bits, plus 3, is the length to copy - const length = (bytes & 0b111) + 3 - for (let j = 0; j < length; j++) - output.push(output[output.length - distance]) - } - // compressed from space plus char - else output.push(32, byte ^ 0b1000_0000) - } - return Uint8Array.from(output) + // compressed from space plus char + else output.push(32, byte ^ 0b1000_0000) + } + return Uint8Array.from(output) } const read32Bits = (byteArray, from) => { - const startByte = from >> 3 - const end = from + 32 - const endByte = end >> 3 - let bits = 0n - for (let i = startByte; i <= endByte; i++) - bits = bits << 8n | BigInt(byteArray[i] ?? 0) - return (bits >> (8n - BigInt(end & 7))) & 0xffffffffn + const startByte = from >> 3 + const end = from + 32 + const endByte = end >> 3 + let bits = 0n + for (let i = startByte; i <= endByte; i++) bits = (bits << 8n) | BigInt(byteArray[i] ?? 0) + return (bits >> (8n - BigInt(end & 7))) & 0xffffffffn } const huffcdic = async (mobi, loadRecord) => { - const huffRecord = await loadRecord(mobi.huffcdic) - const { magic, offset1, offset2 } = getStruct(HUFF_HEADER, huffRecord) - if (magic !== 'HUFF') throw new Error('Invalid HUFF record') + const huffRecord = await loadRecord(mobi.huffcdic) + const { magic, offset1, offset2 } = getStruct(HUFF_HEADER, huffRecord) + if (magic !== 'HUFF') throw new Error('Invalid HUFF record') - // table1 is indexed by byte value - const table1 = Array.from({ length: 256 }, (_, i) => offset1 + i * 4) - .map(offset => getUint(huffRecord.slice(offset, offset + 4))) - .map(x => [x & 0b1000_0000, x & 0b1_1111, x >>> 8]) + // table1 is indexed by byte value + const table1 = Array.from({ length: 256 }, (_, i) => offset1 + i * 4) + .map((offset) => getUint(huffRecord.slice(offset, offset + 4))) + .map((x) => [x & 0b1000_0000, x & 0b1_1111, x >>> 8]) - // table2 is indexed by code length - const table2 = [null].concat(Array.from({ length: 32 }, (_, i) => offset2 + i * 8) - .map(offset => [ - getUint(huffRecord.slice(offset, offset + 4)), - getUint(huffRecord.slice(offset + 4, offset + 8))])) + // table2 is indexed by code length + const table2 = [null].concat( + Array.from({ length: 32 }, (_, i) => offset2 + i * 8).map((offset) => [ + getUint(huffRecord.slice(offset, offset + 4)), + getUint(huffRecord.slice(offset + 4, offset + 8)), + ]), + ) - const dictionary = [] - for (let i = 1; i < mobi.numHuffcdic; i++) { - const record = await loadRecord(mobi.huffcdic + i) - const cdic = getStruct(CDIC_HEADER, record) - if (cdic.magic !== 'CDIC') throw new Error('Invalid CDIC record') - // `numEntries` is the total number of dictionary data across CDIC records - // so `n` here is the number of entries in *this* record - const n = Math.min(1 << cdic.codeLength, cdic.numEntries - dictionary.length) - const buffer = record.slice(cdic.length) - for (let i = 0; i < n; i++) { - const offset = getUint(buffer.slice(i * 2, i * 2 + 2)) - const x = getUint(buffer.slice(offset, offset + 2)) - const length = x & 0x7fff - const decompressed = x & 0x8000 - const value = new Uint8Array( - buffer.slice(offset + 2, offset + 2 + length)) - dictionary.push([value, decompressed]) - } + const dictionary = [] + for (let i = 1; i < mobi.numHuffcdic; i++) { + const record = await loadRecord(mobi.huffcdic + i) + const cdic = getStruct(CDIC_HEADER, record) + if (cdic.magic !== 'CDIC') throw new Error('Invalid CDIC record') + // `numEntries` is the total number of dictionary data across CDIC records + // so `n` here is the number of entries in *this* record + const n = Math.min(1 << cdic.codeLength, cdic.numEntries - dictionary.length) + const buffer = record.slice(cdic.length) + for (let i = 0; i < n; i++) { + const offset = getUint(buffer.slice(i * 2, i * 2 + 2)) + const x = getUint(buffer.slice(offset, offset + 2)) + const length = x & 0x7fff + const decompressed = x & 0x8000 + const value = new Uint8Array(buffer.slice(offset + 2, offset + 2 + length)) + dictionary.push([value, decompressed]) } + } - const decompress = byteArray => { - let output = new Uint8Array() - const bitLength = byteArray.byteLength * 8 - for (let i = 0; i < bitLength;) { - const bits = Number(read32Bits(byteArray, i)) - let [found, codeLength, value] = table1[bits >>> 24] - if (!found) { - while (bits >>> (32 - codeLength) < table2[codeLength][0]) - codeLength += 1 - value = table2[codeLength][1] - } - if ((i += codeLength) > bitLength) break + const decompress = (byteArray) => { + let output = new Uint8Array() + const bitLength = byteArray.byteLength * 8 + for (let i = 0; i < bitLength; ) { + const bits = Number(read32Bits(byteArray, i)) + let [found, codeLength, value] = table1[bits >>> 24] + if (!found) { + while (bits >>> (32 - codeLength) < table2[codeLength][0]) codeLength += 1 + value = table2[codeLength][1] + } + if ((i += codeLength) > bitLength) break - const code = value - (bits >>> (32 - codeLength)) - let [result, decompressed] = dictionary[code] - if (!decompressed) { - // the result is itself compressed - result = decompress(result) - // cache the result for next time - dictionary[code] = [result, true] - } - output = concatTypedArray(output, result) - } - return output + const code = value - (bits >>> (32 - codeLength)) + let [result, decompressed] = dictionary[code] + if (!decompressed) { + // the result is itself compressed + result = decompress(result) + // cache the result for next time + dictionary[code] = [result, true] + } + output = concatTypedArray(output, result) } - return decompress + return output + } + return decompress } const getIndexData = async (indxIndex, loadRecord) => { - const indxRecord = await loadRecord(indxIndex) - const indx = getStruct(INDX_HEADER, indxRecord) + const indxRecord = await loadRecord(indxIndex) + const indx = getStruct(INDX_HEADER, indxRecord) + if (indx.magic !== 'INDX') throw new Error('Invalid INDX record') + const decoder = getDecoder(indx.encoding) + + const tagxBuffer = indxRecord.slice(indx.length) + const tagx = getStruct(TAGX_HEADER, tagxBuffer) + if (tagx.magic !== 'TAGX') throw new Error('Invalid TAGX section') + const numTags = (tagx.length - 12) / 4 + const tagTable = Array.from({ length: numTags }, (_, i) => new Uint8Array(tagxBuffer.slice(12 + i * 4, 12 + i * 4 + 4))) + + const cncx = {} + let cncxRecordOffset = 0 + for (let i = 0; i < indx.numCncx; i++) { + const record = await loadRecord(indxIndex + indx.numRecords + i + 1) + const array = new Uint8Array(record) + for (let pos = 0; pos < array.byteLength; ) { + const index = pos + const { value, length } = getVarLen(array, pos) + pos += length + const result = record.slice(pos, pos + value) + pos += value + cncx[cncxRecordOffset + index] = decoder.decode(result) + } + cncxRecordOffset += 0x10000 + } + + const table = [] + for (let i = 0; i < indx.numRecords; i++) { + const record = await loadRecord(indxIndex + 1 + i) + const array = new Uint8Array(record) + const indx = getStruct(INDX_HEADER, record) if (indx.magic !== 'INDX') throw new Error('Invalid INDX record') - const decoder = getDecoder(indx.encoding) + for (let j = 0; j < indx.numRecords; j++) { + const offsetOffset = indx.idxt + 4 + 2 * j + const offset = getUint(record.slice(offsetOffset, offsetOffset + 2)) - const tagxBuffer = indxRecord.slice(indx.length) - const tagx = getStruct(TAGX_HEADER, tagxBuffer) - if (tagx.magic !== 'TAGX') throw new Error('Invalid TAGX section') - const numTags = (tagx.length - 12) / 4 - const tagTable = Array.from({ length: numTags }, (_, i) => - new Uint8Array(tagxBuffer.slice(12 + i * 4, 12 + i * 4 + 4))) + const length = getUint(record.slice(offset, offset + 1)) + const name = getString(record.slice(offset + 1, offset + 1 + length)) - const cncx = {} - let cncxRecordOffset = 0 - for (let i = 0; i < indx.numCncx; i++) { - const record = await loadRecord(indxIndex + indx.numRecords + i + 1) - const array = new Uint8Array(record) - for (let pos = 0; pos < array.byteLength;) { - const index = pos + const tags = [] + const startPos = offset + 1 + length + let controlByteIndex = 0 + let pos = startPos + tagx.numControlBytes + for (const [tag, numValues, mask, end] of tagTable) { + if (end & 1) { + controlByteIndex++ + continue + } + const offset = startPos + controlByteIndex + const value = getUint(record.slice(offset, offset + 1)) & mask + if (value === mask) { + if (countBitsSet(mask) > 1) { const { value, length } = getVarLen(array, pos) + tags.push([tag, null, value, numValues]) pos += length - const result = record.slice(pos, pos + value) - pos += value - cncx[cncxRecordOffset + index] = decoder.decode(result) + } else tags.push([tag, 1, null, numValues]) + } else tags.push([tag, value >> countUnsetEnd(mask), null, numValues]) + } + + const tagMap = {} + for (const [tag, valueCount, valueBytes, numValues] of tags) { + const values = [] + if (valueCount != null) { + for (let i = 0; i < valueCount * numValues; i++) { + const { value, length } = getVarLen(array, pos) + values.push(value) + pos += length + } + } else { + let count = 0 + while (count < valueBytes) { + const { value, length } = getVarLen(array, pos) + values.push(value) + pos += length + count += length + } } - cncxRecordOffset += 0x10000 + tagMap[tag] = values + } + table.push({ name, tagMap }) } - - const table = [] - for (let i = 0; i < indx.numRecords; i++) { - const record = await loadRecord(indxIndex + 1 + i) - const array = new Uint8Array(record) - const indx = getStruct(INDX_HEADER, record) - if (indx.magic !== 'INDX') throw new Error('Invalid INDX record') - for (let j = 0; j < indx.numRecords; j++) { - const offsetOffset = indx.idxt + 4 + 2 * j - const offset = getUint(record.slice(offsetOffset, offsetOffset + 2)) - - const length = getUint(record.slice(offset, offset + 1)) - const name = getString(record.slice(offset + 1, offset + 1 + length)) - - const tags = [] - const startPos = offset + 1 + length - let controlByteIndex = 0 - let pos = startPos + tagx.numControlBytes - for (const [tag, numValues, mask, end] of tagTable) { - if (end & 1) { - controlByteIndex++ - continue - } - const offset = startPos + controlByteIndex - const value = getUint(record.slice(offset, offset + 1)) & mask - if (value === mask) { - if (countBitsSet(mask) > 1) { - const { value, length } = getVarLen(array, pos) - tags.push([tag, null, value, numValues]) - pos += length - } else tags.push([tag, 1, null, numValues]) - } else tags.push([tag, value >> countUnsetEnd(mask), null, numValues]) - } - - const tagMap = {} - for (const [tag, valueCount, valueBytes, numValues] of tags) { - const values = [] - if (valueCount != null) { - for (let i = 0; i < valueCount * numValues; i++) { - const { value, length } = getVarLen(array, pos) - values.push(value) - pos += length - } - } else { - let count = 0 - while (count < valueBytes) { - const { value, length } = getVarLen(array, pos) - values.push(value) - pos += length - count += length - } - } - tagMap[tag] = values - } - table.push({ name, tagMap }) - } - } - return { table, cncx } + } + return { table, cncx } } const getNCX = async (indxIndex, loadRecord) => { - const { table, cncx } = await getIndexData(indxIndex, loadRecord) - const items = table.map(({ tagMap }, index) => ({ - index, - offset: tagMap[1]?.[0], - size: tagMap[2]?.[0], - label: cncx[tagMap[3]] ?? '', - headingLevel: tagMap[4]?.[0], - pos: tagMap[6], - parent: tagMap[21]?.[0], - firstChild: tagMap[22]?.[0], - lastChild: tagMap[23]?.[0], - })) - const getChildren = item => { - if (item.firstChild == null) return item - item.children = items.filter(x => x.parent === item.index).map(getChildren) - return item - } - return items.filter(item => item.headingLevel === 0).map(getChildren) + const { table, cncx } = await getIndexData(indxIndex, loadRecord) + const items = table.map(({ tagMap }, index) => ({ + index, + offset: tagMap[1]?.[0], + size: tagMap[2]?.[0], + label: cncx[tagMap[3]] ?? '', + headingLevel: tagMap[4]?.[0], + pos: tagMap[6], + parent: tagMap[21]?.[0], + firstChild: tagMap[22]?.[0], + lastChild: tagMap[23]?.[0], + })) + const getChildren = (item) => { + if (item.firstChild == null) return item + item.children = items.filter((x) => x.parent === item.index).map(getChildren) + return item + } + return items.filter((item) => item.headingLevel === 0).map(getChildren) } const getEXTH = (buf, encoding) => { - const { magic, count } = getStruct(EXTH_HEADER, buf) - if (magic !== 'EXTH') throw new Error('Invalid EXTH header') - const decoder = getDecoder(encoding) - const results = {} - let offset = 12 - for (let i = 0; i < count; i++) { - const type = getUint(buf.slice(offset, offset + 4)) - const length = getUint(buf.slice(offset + 4, offset + 8)) - if (type in EXTH_RECORD_TYPE) { - const [name, typ, many] = EXTH_RECORD_TYPE[type] - const data = buf.slice(offset + 8, offset + length) - const value = typ === 'uint' ? getUint(data) : decoder.decode(data) - if (many) { - results[name] ??= [] - results[name].push(value) - } else results[name] = value - } - offset += length + const { magic, count } = getStruct(EXTH_HEADER, buf) + if (magic !== 'EXTH') throw new Error('Invalid EXTH header') + const decoder = getDecoder(encoding) + const results = {} + let offset = 12 + for (let i = 0; i < count; i++) { + const type = getUint(buf.slice(offset, offset + 4)) + const length = getUint(buf.slice(offset + 4, offset + 8)) + if (type in EXTH_RECORD_TYPE) { + const [name, typ, many] = EXTH_RECORD_TYPE[type] + const data = buf.slice(offset + 8, offset + length) + const value = typ === 'uint' ? getUint(data) : decoder.decode(data) + if (many) { + results[name] ??= [] + results[name].push(value) + } else results[name] = value } - return results + offset += length + } + return results } const getFont = async (buf, unzlib) => { - const { flags, dataStart, keyLength, keyStart } = getStruct(FONT_HEADER, buf) - const array = new Uint8Array(buf.slice(dataStart)) - // deobfuscate font - if (flags & 0b10) { - const bytes = keyLength === 16 ? 1024 : 1040 - const key = new Uint8Array(buf.slice(keyStart, keyStart + keyLength)) - const length = Math.min(bytes, array.length) - for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length] - } - // decompress font - if (flags & 1) try { - return await unzlib(array) + const { flags, dataStart, keyLength, keyStart } = getStruct(FONT_HEADER, buf) + const array = new Uint8Array(buf.slice(dataStart)) + // deobfuscate font + if (flags & 0b10) { + const bytes = keyLength === 16 ? 1024 : 1040 + const key = new Uint8Array(buf.slice(keyStart, keyStart + keyLength)) + const length = Math.min(bytes, array.length) + for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length] + } + // decompress font + if (flags & 1) + try { + return await unzlib(array) } catch (e) { - console.warn(e) - console.warn('Failed to decompress font') + console.warn(e) + console.warn('Failed to decompress font') } - return array + return array } -export const isMOBI = async file => { - const magic = getString(await file.slice(60, 68).arrayBuffer()) - return magic === 'BOOKMOBI'// || magic === 'TEXtREAd' +export const isMOBI = async (file) => { + const magic = getString(await file.slice(60, 68).arrayBuffer()) + return magic === 'BOOKMOBI' // || magic === 'TEXtREAd' } class PDB { - #file - #offsets - pdb - async open(file) { - this.#file = file - const pdb = getStruct(PDB_HEADER, await file.slice(0, 78).arrayBuffer()) - this.pdb = pdb - const buffer = await file.slice(78, 78 + pdb.numRecords * 8).arrayBuffer() - // get start and end offsets for each record - this.#offsets = Array.from({ length: pdb.numRecords }, - (_, i) => getUint(buffer.slice(i * 8, i * 8 + 4))) - .map((x, i, a) => [x, a[i + 1]]) - } - loadRecord(index) { - const offsets = this.#offsets[index] - if (!offsets) throw new RangeError('Record index out of bounds') - return this.#file.slice(...offsets).arrayBuffer() - } - async loadMagic(index) { - const start = this.#offsets[index][0] - return getString(await this.#file.slice(start, start + 4).arrayBuffer()) - } + #file + #offsets + pdb + async open(file) { + this.#file = file + const pdb = getStruct(PDB_HEADER, await file.slice(0, 78).arrayBuffer()) + this.pdb = pdb + const buffer = await file.slice(78, 78 + pdb.numRecords * 8).arrayBuffer() + // get start and end offsets for each record + this.#offsets = Array.from({ length: pdb.numRecords }, (_, i) => getUint(buffer.slice(i * 8, i * 8 + 4))).map((x, i, a) => [x, a[i + 1]]) + } + loadRecord(index) { + const offsets = this.#offsets[index] + if (!offsets) throw new RangeError('Record index out of bounds') + return this.#file.slice(...offsets).arrayBuffer() + } + async loadMagic(index) { + const start = this.#offsets[index][0] + return getString(await this.#file.slice(start, start + 4).arrayBuffer()) + } } export class MOBI extends PDB { - #start = 0 - #resourceStart - #decoder - #encoder - #decompress - #removeTrailingEntries - constructor({ unzlib }) { - super() - this.unzlib = unzlib - } - async open(file) { - await super.open(file) - // TODO: if (this.pdb.type === 'TEXt') - this.headers = this.#getHeaders(await super.loadRecord(0)) - this.#resourceStart = this.headers.mobi.resourceStart - let isKF8 = this.headers.mobi.version >= 8 - if (!isKF8) { - const boundary = this.headers.exth?.boundary - if (boundary < 0xffffffff) try { - // it's a "combo" MOBI/KF8 file; try to open the KF8 part - this.headers = this.#getHeaders(await super.loadRecord(boundary)) - this.#start = boundary - isKF8 = true - } catch (e) { - console.warn(e) - console.warn('Failed to open KF8; falling back to MOBI') - } + #start = 0 + #resourceStart + #decoder + #encoder + #decompress + #removeTrailingEntries + constructor({ unzlib }) { + super() + this.unzlib = unzlib + } + async open(file) { + await super.open(file) + // TODO: if (this.pdb.type === 'TEXt') + this.headers = this.#getHeaders(await super.loadRecord(0)) + this.#resourceStart = this.headers.mobi.resourceStart + let isKF8 = this.headers.mobi.version >= 8 + if (!isKF8) { + const boundary = this.headers.exth?.boundary + if (boundary < 0xffffffff) + try { + // it's a "combo" MOBI/KF8 file; try to open the KF8 part + this.headers = this.#getHeaders(await super.loadRecord(boundary)) + this.#start = boundary + isKF8 = true + } catch (e) { + console.warn(e) + console.warn('Failed to open KF8; falling back to MOBI') } - await this.#setup() - return isKF8 ? new KF8(this).init() : new MOBI6(this).init() } - #getHeaders(buf) { - const palmdoc = getStruct(PALMDOC_HEADER, buf) - const mobi = getStruct(MOBI_HEADER, buf) - if (mobi.magic !== 'MOBI') throw new Error('Missing MOBI header') + await this.#setup() + return isKF8 ? new KF8(this).init() : new MOBI6(this).init() + } + #getHeaders(buf) { + const palmdoc = getStruct(PALMDOC_HEADER, buf) + const mobi = getStruct(MOBI_HEADER, buf) + if (mobi.magic !== 'MOBI') throw new Error('Missing MOBI header') - const { titleOffset, titleLength, localeLanguage, localeRegion } = mobi - mobi.title = buf.slice(titleOffset, titleOffset + titleLength) - const lang = MOBI_LANG[localeLanguage] - mobi.language = lang?.[localeRegion >> 2] ?? lang?.[0] + const { titleOffset, titleLength, localeLanguage, localeRegion } = mobi + mobi.title = buf.slice(titleOffset, titleOffset + titleLength) + const lang = MOBI_LANG[localeLanguage] + mobi.language = lang?.[localeRegion >> 2] ?? lang?.[0] - const exth = mobi.exthFlag & 0b100_0000 - ? getEXTH(buf.slice(mobi.length + 16), mobi.encoding) : null - const kf8 = mobi.version >= 8 ? getStruct(KF8_HEADER, buf) : null - return { palmdoc, mobi, exth, kf8 } - } - async #setup() { - const { palmdoc, mobi } = this.headers - this.#decoder = getDecoder(mobi.encoding) - // `TextEncoder` only supports UTF-8 - // we are only encoding ASCII anyway, so I think it's fine - this.#encoder = new TextEncoder() + const exth = mobi.exthFlag & 0b100_0000 ? getEXTH(buf.slice(mobi.length + 16), mobi.encoding) : null + const kf8 = mobi.version >= 8 ? getStruct(KF8_HEADER, buf) : null + return { palmdoc, mobi, exth, kf8 } + } + async #setup() { + const { palmdoc, mobi } = this.headers + this.#decoder = getDecoder(mobi.encoding) + // `TextEncoder` only supports UTF-8 + // we are only encoding ASCII anyway, so I think it's fine + this.#encoder = new TextEncoder() - // set up decompressor - const { compression } = palmdoc - this.#decompress = compression === 1 ? f => f - : compression === 2 ? decompressPalmDOC - : compression === 17480 ? await huffcdic(mobi, this.loadRecord.bind(this)) + // set up decompressor + const { compression } = palmdoc + this.#decompress = + compression === 1 + ? (f) => f + : compression === 2 + ? decompressPalmDOC + : compression === 17480 + ? await huffcdic(mobi, this.loadRecord.bind(this)) : null - if (!this.#decompress) throw new Error('Unknown compression type') + if (!this.#decompress) throw new Error('Unknown compression type') - // set up function for removing trailing bytes - const { trailingFlags } = mobi - const multibyte = trailingFlags & 1 - const numTrailingEntries = countBitsSet(trailingFlags >>> 1) - this.#removeTrailingEntries = array => { - for (let i = 0; i < numTrailingEntries; i++) { - const length = getVarLenFromEnd(array) - array = array.subarray(0, -length) - } - if (multibyte) { - const length = (array[array.length - 1] & 0b11) + 1 - array = array.subarray(0, -length) - } - return array - } + // set up function for removing trailing bytes + const { trailingFlags } = mobi + const multibyte = trailingFlags & 1 + const numTrailingEntries = countBitsSet(trailingFlags >>> 1) + this.#removeTrailingEntries = (array) => { + for (let i = 0; i < numTrailingEntries; i++) { + const length = getVarLenFromEnd(array) + array = array.subarray(0, -length) + } + if (multibyte) { + const length = (array[array.length - 1] & 0b11) + 1 + array = array.subarray(0, -length) + } + return array } - decode(...args) { - return this.#decoder.decode(...args) + } + decode(...args) { + return this.#decoder.decode(...args) + } + encode(...args) { + return this.#encoder.encode(...args) + } + loadRecord(index) { + return super.loadRecord(this.#start + index) + } + loadMagic(index) { + return super.loadMagic(this.#start + index) + } + loadText(index) { + return this.loadRecord(index + 1) + .then((buf) => new Uint8Array(buf)) + .then(this.#removeTrailingEntries) + .then(this.#decompress) + } + async loadResource(index) { + const buf = await super.loadRecord(this.#resourceStart + index) + const magic = getString(buf.slice(0, 4)) + if (magic === 'FONT') return getFont(buf, this.unzlib) + if (magic === 'VIDE' || magic === 'AUDI') return buf.slice(12) + return buf + } + getNCX() { + const index = this.headers.mobi.indx + if (index < 0xffffffff) return getNCX(index, this.loadRecord.bind(this)) + } + getMetadata() { + const { mobi, exth } = this.headers + return { + identifier: mobi.uid.toString(), + title: unescapeHTML(exth?.title || this.decode(mobi.title)), + author: exth?.creator?.map(unescapeHTML), + publisher: unescapeHTML(exth?.publisher), + language: exth?.language ?? mobi.language, + published: exth?.date, + description: unescapeHTML(exth?.description), + subject: exth?.subject?.map(unescapeHTML), + rights: unescapeHTML(exth?.rights), + contributor: exth?.contributor, } - encode(...args) { - return this.#encoder.encode(...args) - } - loadRecord(index) { - return super.loadRecord(this.#start + index) - } - loadMagic(index) { - return super.loadMagic(this.#start + index) - } - loadText(index) { - return this.loadRecord(index + 1) - .then(buf => new Uint8Array(buf)) - .then(this.#removeTrailingEntries) - .then(this.#decompress) - } - async loadResource(index) { - const buf = await super.loadRecord(this.#resourceStart + index) - const magic = getString(buf.slice(0, 4)) - if (magic === 'FONT') return getFont(buf, this.unzlib) - if (magic === 'VIDE' || magic === 'AUDI') return buf.slice(12) - return buf - } - getNCX() { - const index = this.headers.mobi.indx - if (index < 0xffffffff) return getNCX(index, this.loadRecord.bind(this)) - } - getMetadata() { - const { mobi, exth } = this.headers - return { - identifier: mobi.uid.toString(), - title: unescapeHTML(exth?.title || this.decode(mobi.title)), - author: exth?.creator?.map(unescapeHTML), - publisher: unescapeHTML(exth?.publisher), - language: exth?.language ?? mobi.language, - published: exth?.date, - description: unescapeHTML(exth?.description), - subject: exth?.subject?.map(unescapeHTML), - rights: unescapeHTML(exth?.rights), - contributor: exth?.contributor, - } - } - async getCover() { - const { exth } = this.headers - const offset = exth?.coverOffset < 0xffffffff ? exth?.coverOffset - : exth?.thumbnailOffset < 0xffffffff ? exth?.thumbnailOffset : null - if (offset != null) { - const buf = await this.loadResource(offset) - return new Blob([buf]) - } + } + async getCover() { + const { exth } = this.headers + const offset = exth?.coverOffset < 0xffffffff ? exth?.coverOffset : exth?.thumbnailOffset < 0xffffffff ? exth?.thumbnailOffset : null + if (offset != null) { + const buf = await this.loadResource(offset) + return new Blob([buf]) } + } } const mbpPagebreakRegex = /<\s*(?:mbp:)?pagebreak[^>]*>/gi const fileposRegex = /<[^<>]+filepos=['"]{0,1}(\d+)[^<>]*>/gi -const getIndent = el => { - let x = 0 - while (el) { - const parent = el.parentElement - if (parent) { - const tag = parent.tagName.toLowerCase() - if (tag === 'p') x += 1.5 - else if (tag === 'blockquote') x += 2 - } - el = parent +const getIndent = (el) => { + let x = 0 + while (el) { + const parent = el.parentElement + if (parent) { + const tag = parent.tagName.toLowerCase() + if (tag === 'p') x += 1.5 + else if (tag === 'blockquote') x += 2 } - return x + el = parent + } + return x } function rawBytesToString(uint8Array) { - const chunkSize = 0x8000 - let result = '' - for (let i = 0; i < uint8Array.length; i += chunkSize) { - result += String.fromCharCode.apply(null, uint8Array.subarray(i, i + chunkSize)) - } - return result + const chunkSize = 0x8000 + let result = '' + for (let i = 0; i < uint8Array.length; i += chunkSize) { + result += String.fromCharCode.apply(null, uint8Array.subarray(i, i + chunkSize)) + } + return result } class MOBI6 { - parser = new DOMParser() - serializer = new XMLSerializer() - #resourceCache = new Map() - #textCache = new Map() - #cache = new Map() - #sections - #fileposList = [] - #type = MIME.HTML - constructor(mobi) { - this.mobi = mobi + parser = new DOMParser() + serializer = new XMLSerializer() + #resourceCache = new Map() + #textCache = new Map() + #cache = new Map() + #sections + #fileposList = [] + #type = MIME.HTML + constructor(mobi) { + this.mobi = mobi + } + async init() { + const recordBuffers = [] + for (let i = 0; i < this.mobi.headers.palmdoc.numTextRecords; i++) { + const buf = await this.mobi.loadText(i) + recordBuffers.push(buf) } - async init() { - const recordBuffers = [] - for (let i = 0; i < this.mobi.headers.palmdoc.numTextRecords; i++) { - const buf = await this.mobi.loadText(i) - recordBuffers.push(buf) - } - const totalLength = recordBuffers.reduce((sum, buf) => sum + buf.byteLength, 0) - // load all text records in an array - const array = new Uint8Array(totalLength) - recordBuffers.reduce((offset, buf) => { - array.set(new Uint8Array(buf), offset) - return offset + buf.byteLength - }, 0) - // convert to string so we can use regex - // note that `filepos` are byte offsets - // so it needs to preserve each byte as a separate character - // (see https://stackoverflow.com/q/50198017) - const str = rawBytesToString(array) + const totalLength = recordBuffers.reduce((sum, buf) => sum + buf.byteLength, 0) + // load all text records in an array + const array = new Uint8Array(totalLength) + recordBuffers.reduce((offset, buf) => { + array.set(new Uint8Array(buf), offset) + return offset + buf.byteLength + }, 0) + // convert to string so we can use regex + // note that `filepos` are byte offsets + // so it needs to preserve each byte as a separate character + // (see https://stackoverflow.com/q/50198017) + const str = rawBytesToString(array) - // split content into sections at each `` - this.#sections = [0] - .concat(Array.from(str.matchAll(mbpPagebreakRegex), m => m.index)) - .map((start, i, a) => { - const end = a[i + 1] ?? array.length - return { book: this, raw: array.subarray(start, end) } - }) - // get start and end filepos for each section - .map((section, i, arr) => { - section.start = arr[i - 1]?.end ?? 0 - section.end = section.start + section.raw.byteLength - return section - }) + // split content into sections at each `` + this.#sections = [0] + .concat(Array.from(str.matchAll(mbpPagebreakRegex), (m) => m.index)) + .map((start, i, a) => { + const end = a[i + 1] ?? array.length + return { book: this, raw: array.subarray(start, end) } + }) + // get start and end filepos for each section + .map((section, i, arr) => { + section.start = arr[i - 1]?.end ?? 0 + section.end = section.start + section.raw.byteLength + return section + }) - this.sections = this.#sections.map((section, index) => ({ - id: index, - load: () => this.loadSection(section), - createDocument: () => this.createDocument(section), - size: section.end - section.start, - })) + this.sections = this.#sections.map((section, index) => ({ + id: index, + load: () => this.loadSection(section), + createDocument: () => this.createDocument(section), + size: section.end - section.start, + })) - try { - this.landmarks = await this.getGuide() - const tocHref = this.landmarks - .find(({ type }) => type?.includes('toc'))?.href - if (tocHref) { - const { index } = this.resolveHref(tocHref) - const doc = await this.sections[index].createDocument() - let lastItem - let lastLevel = 0 - let lastIndent = 0 - const lastLevelOfIndent = new Map() - const lastParentOfLevel = new Map() - this.toc = Array.from(doc.querySelectorAll('a[filepos]')) - .reduce((arr, a) => { - const indent = getIndent(a) - const item = { - label: a.innerText?.trim() ?? '', - href: `filepos:${a.getAttribute('filepos')}`, - } - const level = indent > lastIndent ? lastLevel + 1 - : indent === lastIndent ? lastLevel - : lastLevelOfIndent.get(indent) ?? Math.max(0, lastLevel - 1) - if (level > lastLevel) { - if (lastItem) { - lastItem.subitems ??= [] - lastItem.subitems.push(item) - lastParentOfLevel.set(level, lastItem) - } - else arr.push(item) - } - else { - const parent = lastParentOfLevel.get(level) - if (parent) parent.subitems.push(item) - else arr.push(item) - } - lastItem = item - lastLevel = level - lastIndent = indent - lastLevelOfIndent.set(indent, level) - return arr - }, []) - } - } catch(e) { - console.warn(e) - } + try { + this.landmarks = await this.getGuide() + const tocHref = this.landmarks.find(({ type }) => type?.includes('toc'))?.href + if (tocHref) { + const { index } = this.resolveHref(tocHref) + const doc = await this.sections[index].createDocument() + let lastItem + let lastLevel = 0 + let lastIndent = 0 + const lastLevelOfIndent = new Map() + const lastParentOfLevel = new Map() + this.toc = Array.from(doc.querySelectorAll('a[filepos]')).reduce((arr, a) => { + const indent = getIndent(a) + const item = { + label: a.innerText?.trim() ?? '', + href: `filepos:${a.getAttribute('filepos')}`, + } + const level = + indent > lastIndent ? lastLevel + 1 : indent === lastIndent ? lastLevel : (lastLevelOfIndent.get(indent) ?? Math.max(0, lastLevel - 1)) + if (level > lastLevel) { + if (lastItem) { + lastItem.subitems ??= [] + lastItem.subitems.push(item) + lastParentOfLevel.set(level, lastItem) + } else arr.push(item) + } else { + const parent = lastParentOfLevel.get(level) + if (parent) parent.subitems.push(item) + else arr.push(item) + } + lastItem = item + lastLevel = level + lastIndent = indent + lastLevelOfIndent.set(indent, level) + return arr + }, []) + } + } catch (e) { + console.warn(e) + } - // get list of all `filepos` references in the book, - // which will be used to insert anchor elements - // because only then can they be referenced in the DOM - this.#fileposList = [...new Set( - Array.from(str.matchAll(fileposRegex), m => m[1]))] - .map(filepos => ({ filepos, number: Number(filepos) })) - .sort((a, b) => a.number - b.number) + // get list of all `filepos` references in the book, + // which will be used to insert anchor elements + // because only then can they be referenced in the DOM + this.#fileposList = [...new Set(Array.from(str.matchAll(fileposRegex), (m) => m[1]))] + .map((filepos) => ({ filepos, number: Number(filepos) })) + .sort((a, b) => a.number - b.number) - this.metadata = this.mobi.getMetadata() - this.getCover = this.mobi.getCover.bind(this.mobi) - return this + this.metadata = this.mobi.getMetadata() + this.getCover = this.mobi.getCover.bind(this.mobi) + return this + } + async getGuide() { + const doc = await this.createDocument(this.#sections[0]) + return Array.from(doc.getElementsByTagName('reference'), (ref) => ({ + label: ref.getAttribute('title'), + type: ref.getAttribute('type')?.split(/\s/), + href: `filepos:${ref.getAttribute('filepos')}`, + })) + } + async loadResource(index) { + if (this.#resourceCache.has(index)) return this.#resourceCache.get(index) + const raw = await this.mobi.loadResource(index) + const url = URL.createObjectURL(new Blob([raw])) + this.#resourceCache.set(index, url) + return url + } + async loadRecindex(recindex) { + return this.loadResource(Number(recindex) - 1) + } + async replaceResources(doc) { + for (const img of doc.querySelectorAll('img[recindex]')) { + const recindex = img.getAttribute('recindex') + try { + img.src = await this.loadRecindex(recindex) + } catch { + console.warn(`Failed to load image ${recindex}`) + } } - async getGuide() { - const doc = await this.createDocument(this.#sections[0]) - return Array.from(doc.getElementsByTagName('reference'), ref => ({ - label: ref.getAttribute('title'), - type: ref.getAttribute('type')?.split(/\s/), - href: `filepos:${ref.getAttribute('filepos')}`, - })) + for (const media of doc.querySelectorAll('[mediarecindex]')) { + const mediarecindex = media.getAttribute('mediarecindex') + const recindex = media.getAttribute('recindex') + try { + media.src = await this.loadRecindex(mediarecindex) + if (recindex) media.poster = await this.loadRecindex(recindex) + } catch { + console.warn(`Failed to load media ${mediarecindex}`) + } } - async loadResource(index) { - if (this.#resourceCache.has(index)) return this.#resourceCache.get(index) - const raw = await this.mobi.loadResource(index) - const url = URL.createObjectURL(new Blob([raw])) - this.#resourceCache.set(index, url) - return url + for (const a of doc.querySelectorAll('[filepos]')) { + const filepos = a.getAttribute('filepos') + a.href = `filepos:${filepos}` } - async loadRecindex(recindex) { - return this.loadResource(Number(recindex) - 1) - } - async replaceResources(doc) { - for (const img of doc.querySelectorAll('img[recindex]')) { - const recindex = img.getAttribute('recindex') - try { - img.src = await this.loadRecindex(recindex) - } catch { - console.warn(`Failed to load image ${recindex}`) - } - } - for (const media of doc.querySelectorAll('[mediarecindex]')) { - const mediarecindex = media.getAttribute('mediarecindex') - const recindex = media.getAttribute('recindex') - try { - media.src = await this.loadRecindex(mediarecindex) - if (recindex) media.poster = await this.loadRecindex(recindex) - } catch { - console.warn(`Failed to load media ${mediarecindex}`) - } - } - for (const a of doc.querySelectorAll('[filepos]')) { - const filepos = a.getAttribute('filepos') - a.href = `filepos:${filepos}` - } - } - async loadText(section) { - if (this.#textCache.has(section)) return this.#textCache.get(section) - const { raw } = section + } + async loadText(section) { + if (this.#textCache.has(section)) return this.#textCache.get(section) + const { raw } = section - // insert anchor elements for each `filepos` - const fileposList = this.#fileposList - .filter(({ number }) => number >= section.start && number < section.end) - .map(obj => ({ ...obj, offset: obj.number - section.start })) - let arr = raw - if (fileposList.length) { - arr = raw.subarray(0, fileposList[0].offset) - fileposList.forEach(({ filepos, offset }, i) => { - const next = fileposList[i + 1] - const a = this.mobi.encode(``) - arr = concatTypedArray3(arr, a, raw.subarray(offset, next?.offset)) - }) - } - const str = this.mobi.decode(arr).replaceAll(mbpPagebreakRegex, '') - this.#textCache.set(section, str) - return str + // insert anchor elements for each `filepos` + const fileposList = this.#fileposList + .filter(({ number }) => number >= section.start && number < section.end) + .map((obj) => ({ ...obj, offset: obj.number - section.start })) + let arr = raw + if (fileposList.length) { + arr = raw.subarray(0, fileposList[0].offset) + fileposList.forEach(({ filepos, offset }, i) => { + const next = fileposList[i + 1] + const a = this.mobi.encode(``) + arr = concatTypedArray3(arr, a, raw.subarray(offset, next?.offset)) + }) } - async createDocument(section) { - const str = await this.loadText(section) - return this.parser.parseFromString(str, this.#type) - } - async loadSection(section) { - if (this.#cache.has(section)) return this.#cache.get(section) - const doc = await this.createDocument(section) + const str = this.mobi.decode(arr).replaceAll(mbpPagebreakRegex, '') + this.#textCache.set(section, str) + return str + } + async createDocument(section) { + const str = await this.loadText(section) + return this.parser.parseFromString(str, this.#type) + } + async loadSection(section) { + if (this.#cache.has(section)) return this.#cache.get(section) + const doc = await this.createDocument(section) - // inject default stylesheet - const style = doc.createElement('style') - doc.head.append(style) - // blockquotes in MOBI seem to have only a small left margin by default - // many books seem to rely on this, as it's the only way to set margin - // (since there's no CSS) - style.append(doc.createTextNode(`blockquote { + // inject default stylesheet + const style = doc.createElement('style') + doc.head.append(style) + // blockquotes in MOBI seem to have only a small left margin by default + // many books seem to rely on this, as it's the only way to set margin + // (since there's no CSS) + style.append( + doc.createTextNode(`blockquote { margin-block-start: 0; margin-block-end: 0; margin-inline-start: 1em; margin-inline-end: 0; - }`)) + }`), + ) - await this.replaceResources(doc) - const result = this.serializer.serializeToString(doc) - const url = URL.createObjectURL(new Blob([result], { type: this.#type })) - this.#cache.set(section, url) - return url - } - resolveHref(href) { - const filepos = href.match(/filepos:(.*)/)[1] - const number = Number(filepos) - const index = this.#sections.findIndex(section => section.end > number) - const anchor = doc => doc.getElementById(`filepos${filepos}`) - return { index, anchor } - } - splitTOCHref(href) { - const filepos = href.match(/filepos:(.*)/)[1] - const number = Number(filepos) - const index = this.#sections.findIndex(section => section.end > number) - return [index, `filepos${filepos}`] - } - getTOCFragment(doc, id) { - return doc.getElementById(id) - } - isExternal(uri) { - return /^(?!blob|filepos)\w+:/i.test(uri) - } - destroy() { - for (const url of this.#resourceCache.values()) URL.revokeObjectURL(url) - for (const url of this.#cache.values()) URL.revokeObjectURL(url) - } + await this.replaceResources(doc) + const result = this.serializer.serializeToString(doc) + const url = URL.createObjectURL(new Blob([result], { type: this.#type })) + this.#cache.set(section, url) + return url + } + resolveHref(href) { + const filepos = href.match(/filepos:(.*)/)[1] + const number = Number(filepos) + const index = this.#sections.findIndex((section) => section.end > number) + const anchor = (doc) => doc.getElementById(`filepos${filepos}`) + return { index, anchor } + } + splitTOCHref(href) { + const filepos = href.match(/filepos:(.*)/)[1] + const number = Number(filepos) + const index = this.#sections.findIndex((section) => section.end > number) + return [index, `filepos${filepos}`] + } + getTOCFragment(doc, id) { + return doc.getElementById(id) + } + isExternal(uri) { + return /^(?!blob|filepos)\w+:/i.test(uri) + } + destroy() { + for (const url of this.#resourceCache.values()) URL.revokeObjectURL(url) + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } } // handlers for `kindle:` uris const kindleResourceRegex = /kindle:(flow|embed):(\w+)(?:\?mime=(\w+\/[-+.\w]+))?/ const kindlePosRegex = /kindle:pos:fid:(\w+):off:(\w+)/ -const parseResourceURI = str => { - const [resourceType, id, type] = str.match(kindleResourceRegex).slice(1) - return { resourceType, id: parseInt(id, 32), type } +const parseResourceURI = (str) => { + const [resourceType, id, type] = str.match(kindleResourceRegex).slice(1) + return { resourceType, id: parseInt(id, 32), type } } -const parsePosURI = str => { - const [fid, off] = str.match(kindlePosRegex).slice(1) - return { fid: parseInt(fid, 32), off: parseInt(off, 32) } +const parsePosURI = (str) => { + const [fid, off] = str.match(kindlePosRegex).slice(1) + return { fid: parseInt(fid, 32), off: parseInt(off, 32) } } const makePosURI = (fid = 0, off = 0) => - `kindle:pos:fid:${fid.toString(32).toUpperCase().padStart(4, '0') - }:off:${off.toString(32).toUpperCase().padStart(10, '0')}` + `kindle:pos:fid:${fid.toString(32).toUpperCase().padStart(4, '0')}:off:${off.toString(32).toUpperCase().padStart(10, '0')}` // `kindle:pos:` links are originally links that contain fragments identifiers // so there should exist an element with `id` or `name` // otherwise try to find one with an `aid` attribute -const getFragmentSelector = str => { - const match = str.match(/\s(id|name|aid)\s*=\s*['"]([^'"]*)['"]/i) - if (!match) return - const [, attr, value] = match - return `[${attr}="${CSS.escape(value)}"]` +const getFragmentSelector = (str) => { + const match = str.match(/\s(id|name|aid)\s*=\s*['"]([^'"]*)['"]/i) + if (!match) return + const [, attr, value] = match + return `[${attr}="${CSS.escape(value)}"]` } // replace asynchronously and sequentially const replaceSeries = async (str, regex, f) => { - const matches = [] - str.replace(regex, (...args) => (matches.push(args), null)) - const results = [] - for (const args of matches) results.push(await f(...args)) - return str.replace(regex, () => results.shift()) + 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 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' + } } class KF8 { - parser = new DOMParser() - serializer = new XMLSerializer() - transformTarget = new EventTarget() - #cache = new Map() - #fragmentOffsets = new Map() - #fragmentSelectors = new Map() - #tables = {} - #sections - #fullRawLength - #rawHead = new Uint8Array() - #rawTail = new Uint8Array() - #lastLoadedHead = -1 - #lastLoadedTail = -1 - #type = MIME.XHTML - #inlineMap = new Map() - constructor(mobi) { - this.mobi = mobi + parser = new DOMParser() + serializer = new XMLSerializer() + transformTarget = new EventTarget() + #cache = new Map() + #fragmentOffsets = new Map() + #fragmentSelectors = new Map() + #tables = {} + #sections + #fullRawLength + #rawHead = new Uint8Array() + #rawTail = new Uint8Array() + #lastLoadedHead = -1 + #lastLoadedTail = -1 + #type = MIME.XHTML + #inlineMap = new Map() + constructor(mobi) { + this.mobi = mobi + } + async init() { + const loadRecord = this.mobi.loadRecord.bind(this.mobi) + const { kf8 } = this.mobi.headers + + try { + const fdstBuffer = await loadRecord(kf8.fdst) + const fdst = getStruct(FDST_HEADER, fdstBuffer) + if (fdst.magic !== 'FDST') throw new Error('Missing FDST record') + const fdstTable = Array.from({ length: fdst.numEntries }, (_, i) => 12 + i * 8).map((offset) => [ + getUint(fdstBuffer.slice(offset, offset + 4)), + getUint(fdstBuffer.slice(offset + 4, offset + 8)), + ]) + this.#tables.fdstTable = fdstTable + this.#fullRawLength = fdstTable[fdstTable.length - 1][1] + } catch {} + + const skelTable = (await getIndexData(kf8.skel, loadRecord)).table.map(({ name, tagMap }, index) => ({ + index, + name, + numFrag: tagMap[1][0], + offset: tagMap[6][0], + length: tagMap[6][1], + })) + const fragData = await getIndexData(kf8.frag, loadRecord) + const fragTable = fragData.table.map(({ name, tagMap }) => ({ + insertOffset: parseInt(name), + selector: fragData.cncx[tagMap[2][0]], + index: tagMap[4][0], + offset: tagMap[6][0], + length: tagMap[6][1], + })) + this.#tables.skelTable = skelTable + this.#tables.fragTable = fragTable + + this.#sections = skelTable.reduce((arr, skel) => { + const last = arr[arr.length - 1] + const fragStart = last?.fragEnd ?? 0, + fragEnd = fragStart + skel.numFrag + const frags = fragTable.slice(fragStart, fragEnd) + const length = skel.length + frags.map((f) => f.length).reduce((a, b) => a + b, 0) + const totalLength = (last?.totalLength ?? 0) + length + return arr.concat({ skel, frags, fragEnd, length, totalLength }) + }, []) + + const resources = await this.getResourcesByMagic(['RESC', 'PAGE']) + const pageSpreads = new Map() + if (resources.RESC) { + const buf = await this.mobi.loadRecord(resources.RESC) + const str = this.mobi.decode(buf.slice(16)).replace(/\0/g, '') + // the RESC record lacks the root `` element + // but seem to be otherwise valid XML + const index = str.search(/\?>/) + const xmlStr = `${str.slice(index)}` + const opf = this.parser.parseFromString(xmlStr, MIME.XML) + for (const $itemref of opf.querySelectorAll('spine > itemref')) { + const i = parseInt($itemref.getAttribute('skelid')) + pageSpreads.set(i, getPageSpread($itemref.getAttribute('properties')?.split(' ') ?? [])) + } } - async init() { - const loadRecord = this.mobi.loadRecord.bind(this.mobi) - const { kf8 } = this.mobi.headers - try { - const fdstBuffer = await loadRecord(kf8.fdst) - const fdst = getStruct(FDST_HEADER, fdstBuffer) - if (fdst.magic !== 'FDST') throw new Error('Missing FDST record') - const fdstTable = Array.from({ length: fdst.numEntries }, - (_, i) => 12 + i * 8) - .map(offset => [ - getUint(fdstBuffer.slice(offset, offset + 4)), - getUint(fdstBuffer.slice(offset + 4, offset + 8))]) - this.#tables.fdstTable = fdstTable - this.#fullRawLength = fdstTable[fdstTable.length - 1][1] - } catch {} + this.sections = this.#sections.map((section, index) => + section.frags.length + ? { + id: index, + load: () => this.loadSection(section), + createDocument: () => this.createDocument(section), + size: section.length, + pageSpread: pageSpreads.get(index), + } + : { linear: 'no' }, + ) - const skelTable = (await getIndexData(kf8.skel, loadRecord)).table - .map(({ name, tagMap }, index) => ({ - index, name, - numFrag: tagMap[1][0], - offset: tagMap[6][0], - length: tagMap[6][1], - })) - const fragData = await getIndexData(kf8.frag, loadRecord) - const fragTable = fragData.table.map(({ name, tagMap }) => ({ - insertOffset: parseInt(name), - selector: fragData.cncx[tagMap[2][0]], - index: tagMap[4][0], - offset: tagMap[6][0], - length: tagMap[6][1], - })) - this.#tables.skelTable = skelTable - this.#tables.fragTable = fragTable - - this.#sections = skelTable.reduce((arr, skel) => { - const last = arr[arr.length - 1] - const fragStart = last?.fragEnd ?? 0, fragEnd = fragStart + skel.numFrag - const frags = fragTable.slice(fragStart, fragEnd) - const length = skel.length + frags.map(f => f.length).reduce((a, b) => a + b, 0) - const totalLength = (last?.totalLength ?? 0) + length - return arr.concat({ skel, frags, fragEnd, length, totalLength }) - }, []) - - const resources = await this.getResourcesByMagic(['RESC', 'PAGE']) - const pageSpreads = new Map() - if (resources.RESC) { - const buf = await this.mobi.loadRecord(resources.RESC) - const str = this.mobi.decode(buf.slice(16)).replace(/\0/g, '') - // the RESC record lacks the root `` element - // but seem to be otherwise valid XML - const index = str.search(/\?>/) - const xmlStr = `${str.slice(index)}` - const opf = this.parser.parseFromString(xmlStr, MIME.XML) - for (const $itemref of opf.querySelectorAll('spine > itemref')) { - const i = parseInt($itemref.getAttribute('skelid')) - pageSpreads.set(i, getPageSpread( - $itemref.getAttribute('properties')?.split(' ') ?? [])) - } - } - - this.sections = this.#sections.map((section, index) => - section.frags.length ? ({ - id: index, - load: () => this.loadSection(section), - createDocument: () => this.createDocument(section), - size: section.length, - pageSpread: pageSpreads.get(index), - }) : ({ linear: 'no' })) - - try { - const ncx = await this.mobi.getNCX() - const map = ({ label, pos, children }) => { - const [fid, off] = pos - const href = makePosURI(fid, off) - const arr = this.#fragmentOffsets.get(fid) - if (arr) arr.push(off) - else this.#fragmentOffsets.set(fid, [off]) - return { label: unescapeHTML(label), href, subitems: children?.map(map) } - } - this.toc = ncx?.map(map) - this.landmarks = await this.getGuide() - } catch(e) { - console.warn(e) - } - - const { exth } = this.mobi.headers - this.dir = exth.pageProgressionDirection - this.rendition = { - layout: exth.fixedLayout === 'true' ? 'pre-paginated' : 'reflowable', - viewport: Object.fromEntries(exth.originalResolution - ?.split('x')?.slice(0, 2) - ?.map((x, i) => [i ? 'height' : 'width', x]) ?? []), - } - - this.metadata = this.mobi.getMetadata() - this.getCover = this.mobi.getCover.bind(this.mobi) - return this + try { + const ncx = await this.mobi.getNCX() + const map = ({ label, pos, children }) => { + const [fid, off] = pos + const href = makePosURI(fid, off) + const arr = this.#fragmentOffsets.get(fid) + if (arr) arr.push(off) + else this.#fragmentOffsets.set(fid, [off]) + return { label: unescapeHTML(label), href, subitems: children?.map(map) } + } + this.toc = ncx?.map(map) + this.landmarks = await this.getGuide() + } catch (e) { + console.warn(e) } - // is this really the only way of getting to RESC, PAGE, etc.? - async getResourcesByMagic(keys) { - const results = {} - const start = this.mobi.headers.kf8.resourceStart - const end = this.mobi.pdb.numRecords - for (let i = start; i < end; i++) { - try { - const magic = await this.mobi.loadMagic(i) - const match = keys.find(key => key === magic) - if (match) results[match] = i - } catch {} - } - return results + + const { exth } = this.mobi.headers + this.dir = exth.pageProgressionDirection + this.rendition = { + layout: exth.fixedLayout === 'true' ? 'pre-paginated' : 'reflowable', + viewport: Object.fromEntries( + exth.originalResolution + ?.split('x') + ?.slice(0, 2) + ?.map((x, i) => [i ? 'height' : 'width', x]) ?? [], + ), } - async getGuide() { - const index = this.mobi.headers.kf8.guide - if (index < 0xffffffff) { - const loadRecord = this.mobi.loadRecord.bind(this.mobi) - const { table, cncx } = await getIndexData(index, loadRecord) - return table.map(({ name, tagMap }) => ({ - label: cncx[tagMap[1][0]] ?? '', - type: name?.split(/\s/), - href: makePosURI(tagMap[6]?.[0] ?? tagMap[3]?.[0]), - })) + + this.metadata = this.mobi.getMetadata() + this.getCover = this.mobi.getCover.bind(this.mobi) + return this + } + // is this really the only way of getting to RESC, PAGE, etc.? + async getResourcesByMagic(keys) { + const results = {} + const start = this.mobi.headers.kf8.resourceStart + const end = this.mobi.pdb.numRecords + for (let i = start; i < end; i++) { + try { + const magic = await this.mobi.loadMagic(i) + const match = keys.find((key) => key === magic) + if (match) results[match] = i + } catch {} + } + return results + } + async getGuide() { + const index = this.mobi.headers.kf8.guide + if (index < 0xffffffff) { + const loadRecord = this.mobi.loadRecord.bind(this.mobi) + const { table, cncx } = await getIndexData(index, loadRecord) + return table.map(({ name, tagMap }) => ({ + label: cncx[tagMap[1][0]] ?? '', + type: name?.split(/\s/), + href: makePosURI(tagMap[6]?.[0] ?? tagMap[3]?.[0]), + })) + } + } + async loadResourceBlob(str) { + const { resourceType, id, type } = parseResourceURI(str) + const raw = resourceType === 'flow' ? await this.loadFlow(id) : await this.mobi.loadResource(id - 1) + const result = [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(type) ? await this.replaceResources(this.mobi.decode(raw)) : raw + const detail = { data: result, type } + const event = new CustomEvent('data', { detail }) + this.transformTarget.dispatchEvent(event) + const newData = await event.detail.data + const newType = await event.detail.type + const doc = newType === MIME.SVG ? this.parser.parseFromString(newData, newType) : null + return [ + new Blob([newData], { newType }), + // SVG wrappers need to be inlined + // as browsers don't allow external resources when loading SVG as an image + doc?.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'image')?.length ? doc.documentElement : null, + ] + } + async loadResource(str) { + if (this.#cache.has(str)) return this.#cache.get(str) + const [blob, inline] = await this.loadResourceBlob(str) + const url = inline ? str : URL.createObjectURL(blob) + if (inline) this.#inlineMap.set(url, inline) + this.#cache.set(str, url) + return url + } + replaceResources(str) { + const regex = new RegExp(kindleResourceRegex, 'g') + return replaceSeries(str, regex, this.loadResource.bind(this)) + } + // NOTE: there doesn't seem to be a way to access text randomly? + // how to know the decompressed size of the records without decompressing? + // 4096 is just the maximum size + async loadRaw(start, end) { + // here we load either from the front or back until we have reached the + // required offsets; at worst you'd have to load half the book at once + const distanceHead = end - this.#rawHead.length + const distanceEnd = this.#fullRawLength == null ? Infinity : this.#fullRawLength - this.#rawTail.length - start + // load from the start + if (distanceHead < 0 || distanceHead < distanceEnd) { + while (this.#rawHead.length < end) { + const index = ++this.#lastLoadedHead + const data = await this.mobi.loadText(index) + this.#rawHead = concatTypedArray(this.#rawHead, data) + } + return this.#rawHead.slice(start, end) + } + // load from the end + while (this.#fullRawLength - this.#rawTail.length > start) { + const index = this.mobi.headers.palmdoc.numTextRecords - 1 - ++this.#lastLoadedTail + const data = await this.mobi.loadText(index) + this.#rawTail = concatTypedArray(data, this.#rawTail) + } + const rawTailStart = this.#fullRawLength - this.#rawTail.length + return this.#rawTail.slice(start - rawTailStart, end - rawTailStart) + } + loadFlow(index) { + if (index < 0xffffffff) return this.loadRaw(...this.#tables.fdstTable[index]) + } + async loadText(section) { + const { skel, frags, length } = section + const raw = await this.loadRaw(skel.offset, skel.offset + length) + let skeleton = raw.slice(0, skel.length) + for (const frag of frags) { + const insertOffset = frag.insertOffset - skel.offset + const offset = skel.length + frag.offset + const fragRaw = raw.slice(offset, offset + frag.length) + skeleton = concatTypedArray3(skeleton.slice(0, insertOffset), fragRaw, skeleton.slice(insertOffset)) + + const offsets = this.#fragmentOffsets.get(frag.index) + if (offsets) + for (const offset of offsets) { + const str = this.mobi.decode(fragRaw.slice(offset)) + const selector = getFragmentSelector(str) + this.#setFragmentSelector(frag.index, offset, selector) } } - async loadResourceBlob(str) { - const { resourceType, id, type } = parseResourceURI(str) - const raw = resourceType === 'flow' ? await this.loadFlow(id) - : await this.mobi.loadResource(id - 1) - const result = [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(type) - ? await this.replaceResources(this.mobi.decode(raw)) : raw - const detail = { data: result, type } - const event = new CustomEvent('data', { detail }) - this.transformTarget.dispatchEvent(event) - const newData = await event.detail.data - const newType = await event.detail.type - const doc = newType === MIME.SVG ? this.parser.parseFromString(newData, newType) : null - return [new Blob([newData], { newType }), - // SVG wrappers need to be inlined - // as browsers don't allow external resources when loading SVG as an image - doc?.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'image')?.length - ? doc.documentElement : null] - } - async loadResource(str) { - if (this.#cache.has(str)) return this.#cache.get(str) - const [blob, inline] = await this.loadResourceBlob(str) - const url = inline ? str : URL.createObjectURL(blob) - if (inline) this.#inlineMap.set(url, inline) - this.#cache.set(str, url) - return url - } - replaceResources(str) { - const regex = new RegExp(kindleResourceRegex, 'g') - return replaceSeries(str, regex, this.loadResource.bind(this)) - } - // NOTE: there doesn't seem to be a way to access text randomly? - // how to know the decompressed size of the records without decompressing? - // 4096 is just the maximum size - async loadRaw(start, end) { - // here we load either from the front or back until we have reached the - // required offsets; at worst you'd have to load half the book at once - const distanceHead = end - this.#rawHead.length - const distanceEnd = this.#fullRawLength == null ? Infinity - : (this.#fullRawLength - this.#rawTail.length) - start - // load from the start - if (distanceHead < 0 || distanceHead < distanceEnd) { - while (this.#rawHead.length < end) { - const index = ++this.#lastLoadedHead - const data = await this.mobi.loadText(index) - this.#rawHead = concatTypedArray(this.#rawHead, data) - } - return this.#rawHead.slice(start, end) - } - // load from the end - while (this.#fullRawLength - this.#rawTail.length > start) { - const index = this.mobi.headers.palmdoc.numTextRecords - 1 - - (++this.#lastLoadedTail) - const data = await this.mobi.loadText(index) - this.#rawTail = concatTypedArray(data, this.#rawTail) - } - const rawTailStart = this.#fullRawLength - this.#rawTail.length - return this.#rawTail.slice(start - rawTailStart, end - rawTailStart) - } - loadFlow(index) { - if (index < 0xffffffff) - return this.loadRaw(...this.#tables.fdstTable[index]) - } - async loadText(section) { - const { skel, frags, length } = section - const raw = await this.loadRaw(skel.offset, skel.offset + length) - let skeleton = raw.slice(0, skel.length) - for (const frag of frags) { - const insertOffset = frag.insertOffset - skel.offset - const offset = skel.length + frag.offset - const fragRaw = raw.slice(offset, offset + frag.length) - skeleton = concatTypedArray3( - skeleton.slice(0, insertOffset), fragRaw, - skeleton.slice(insertOffset)) + return this.mobi.decode(skeleton) + } + async createDocument(section) { + const str = await this.loadText(section) + return this.parser.parseFromString(str, this.#type) + } + async loadSection(section) { + if (this.#cache.has(section)) return this.#cache.get(section) + const str = await this.loadText(section) + const replaced = await this.replaceResources(str) - const offsets = this.#fragmentOffsets.get(frag.index) - if (offsets) for (const offset of offsets) { - const str = this.mobi.decode(fragRaw.slice(offset)) - const selector = getFragmentSelector(str) - this.#setFragmentSelector(frag.index, offset, selector) - } - } - return this.mobi.decode(skeleton) + // by default, type is XHTML; change to HTML if it's not valid XHTML + let doc = this.parser.parseFromString(replaced, this.#type) + if (doc.querySelector('parsererror') || !doc.documentElement?.namespaceURI) { + this.#type = MIME.HTML + doc = this.parser.parseFromString(replaced, this.#type) } - async createDocument(section) { - const str = await this.loadText(section) - return this.parser.parseFromString(str, this.#type) + for (const [url, node] of this.#inlineMap) { + for (const el of doc.querySelectorAll(`img[src="${url}"]`)) el.replaceWith(node) } - async loadSection(section) { - if (this.#cache.has(section)) return this.#cache.get(section) - const str = await this.loadText(section) - const replaced = await this.replaceResources(str) + const url = URL.createObjectURL(new Blob([this.serializer.serializeToString(doc)], { type: this.#type })) + this.#cache.set(section, url) + return url + } + getIndexByFID(fid) { + return this.#sections.findIndex((section) => section.frags.some((frag) => frag.index === fid)) + } + #setFragmentSelector(id, offset, selector) { + const map = this.#fragmentSelectors.get(id) + if (map) map.set(offset, selector) + else { + const map = new Map() + this.#fragmentSelectors.set(id, map) + map.set(offset, selector) + } + } + async resolveHref(href) { + const { fid, off } = parsePosURI(href) + const index = this.getIndexByFID(fid) + if (index < 0) return - // by default, type is XHTML; change to HTML if it's not valid XHTML - let doc = this.parser.parseFromString(replaced, this.#type) - if (doc.querySelector('parsererror') || !doc.documentElement?.namespaceURI) { - this.#type = MIME.HTML - doc = this.parser.parseFromString(replaced, this.#type) - } - for (const [url, node] of this.#inlineMap) { - for (const el of doc.querySelectorAll(`img[src="${url}"]`)) - el.replaceWith(node) - } - const url = URL.createObjectURL( - new Blob([this.serializer.serializeToString(doc)], { type: this.#type })) - this.#cache.set(section, url) - return url - } - getIndexByFID(fid) { - return this.#sections.findIndex(section => - section.frags.some(frag => frag.index === fid)) - } - #setFragmentSelector(id, offset, selector) { - const map = this.#fragmentSelectors.get(id) - if (map) map.set(offset, selector) - else { - const map = new Map() - this.#fragmentSelectors.set(id, map) - map.set(offset, selector) - } - } - async resolveHref(href) { - const { fid, off } = parsePosURI(href) - const index = this.getIndexByFID(fid) - if (index < 0) return + const saved = this.#fragmentSelectors.get(fid)?.get(off) + if (saved) return { index, anchor: (doc) => doc.querySelector(saved) } - const saved = this.#fragmentSelectors.get(fid)?.get(off) - if (saved) return { index, anchor: doc => doc.querySelector(saved) } - - const { skel, frags } = this.#sections[index] - const frag = frags.find(frag => frag.index === fid) - const offset = skel.offset + skel.length + frag.offset - const fragRaw = await this.loadRaw(offset, offset + frag.length) - const str = this.mobi.decode(fragRaw.slice(off)) - const selector = getFragmentSelector(str) - this.#setFragmentSelector(fid, off, selector) - const anchor = doc => doc.querySelector(selector) - return { index, anchor } - } - splitTOCHref(href) { - const pos = parsePosURI(href) - const index = this.getIndexByFID(pos.fid) - return [index, pos] - } - getTOCFragment(doc, { fid, off }) { - const selector = this.#fragmentSelectors.get(fid)?.get(off) - return doc.querySelector(selector) - } - isExternal(uri) { - return /^(?!blob|kindle)\w+:/i.test(uri) - } - destroy() { - for (const url of this.#cache.values()) URL.revokeObjectURL(url) - } + const { skel, frags } = this.#sections[index] + const frag = frags.find((frag) => frag.index === fid) + const offset = skel.offset + skel.length + frag.offset + const fragRaw = await this.loadRaw(offset, offset + frag.length) + const str = this.mobi.decode(fragRaw.slice(off)) + const selector = getFragmentSelector(str) + this.#setFragmentSelector(fid, off, selector) + const anchor = (doc) => doc.querySelector(selector) + return { index, anchor } + } + splitTOCHref(href) { + const pos = parsePosURI(href) + const index = this.getIndexByFID(pos.fid) + return [index, pos] + } + getTOCFragment(doc, { fid, off }) { + const selector = this.#fragmentSelectors.get(fid)?.get(off) + return doc.querySelector(selector) + } + isExternal(uri) { + return /^(?!blob|kindle)\w+:/i.test(uri) + } + destroy() { + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } } diff --git a/client/public/assets/foliate/overlayer.js b/client/public/assets/foliate/overlayer.js index 6fd03ab58..11c7a6239 100644 --- a/client/public/assets/foliate/overlayer.js +++ b/client/public/assets/foliate/overlayer.js @@ -1,175 +1,175 @@ -const createSVGElement = tag => - document.createElementNS('http://www.w3.org/2000/svg', tag) +const createSVGElement = (tag) => document.createElementNS('http://www.w3.org/2000/svg', tag) export class Overlayer { - #svg = createSVGElement('svg') - #map = new Map() - constructor() { - Object.assign(this.#svg.style, { - position: 'absolute', top: '0', left: '0', - width: '100%', height: '100%', - pointerEvents: 'none', - }) + #svg = createSVGElement('svg') + #map = new Map() + constructor() { + Object.assign(this.#svg.style, { + position: 'absolute', + top: '0', + left: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + }) + } + get element() { + return this.#svg + } + add(key, range, draw, options) { + if (this.#map.has(key)) this.remove(key) + if (typeof range === 'function') range = range(this.#svg.getRootNode()) + const rects = range.getClientRects() + const element = draw(rects, options) + this.#svg.append(element) + this.#map.set(key, { range, draw, options, element, rects }) + } + remove(key) { + if (!this.#map.has(key)) return + this.#svg.removeChild(this.#map.get(key).element) + this.#map.delete(key) + } + redraw() { + for (const obj of this.#map.values()) { + const { range, draw, options, element } = obj + this.#svg.removeChild(element) + const rects = range.getClientRects() + const el = draw(rects, options) + this.#svg.append(el) + obj.element = el + obj.rects = rects } - get element() { - return this.#svg + } + hitTest({ x, y }) { + const arr = Array.from(this.#map.entries()) + // loop in reverse to hit more recently added items first + for (let i = arr.length - 1; i >= 0; i--) { + const [key, obj] = arr[i] + for (const { left, top, right, bottom } of obj.rects) if (top <= y && left <= x && bottom > y && right > x) return [key, obj.range] } - add(key, range, draw, options) { - if (this.#map.has(key)) this.remove(key) - if (typeof range === 'function') range = range(this.#svg.getRootNode()) - const rects = range.getClientRects() - const element = draw(rects, options) - this.#svg.append(element) - this.#map.set(key, { range, draw, options, element, rects }) + return [] + } + static underline(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, top, height } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', right - strokeWidth) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', strokeWidth) + g.append(el) + } + else + for (const { left, bottom, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', bottom - strokeWidth) + el.setAttribute('height', strokeWidth) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static strikethrough(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, left, top, height } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', (right + left) / 2) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', strokeWidth) + g.append(el) + } + else + for (const { left, top, bottom, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', (top + bottom) / 2) + el.setAttribute('height', strokeWidth) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static squiggly(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', 'none') + g.setAttribute('stroke', color) + g.setAttribute('stroke-width', strokeWidth) + const block = strokeWidth * 1.5 + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, top, height } of rects) { + const el = createSVGElement('path') + const n = Math.round(height / block / 1.5) + const inline = height / n + const ls = Array.from({ length: n }, (_, i) => `l${i % 2 ? -block : block} ${inline}`).join('') + el.setAttribute('d', `M${right} ${top}${ls}`) + g.append(el) + } + else + for (const { left, bottom, width } of rects) { + const el = createSVGElement('path') + const n = Math.round(width / block / 1.5) + const inline = width / n + const ls = Array.from({ length: n }, (_, i) => `l${inline} ${i % 2 ? block : -block}`).join('') + el.setAttribute('d', `M${left} ${bottom}${ls}`) + g.append(el) + } + return g + } + static highlight(rects, options = {}) { + const { color = 'red' } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + g.style.opacity = 'var(--overlayer-highlight-opacity, .3)' + g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)' + for (const { left, top, height, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', width) + g.append(el) } - remove(key) { - if (!this.#map.has(key)) return - this.#svg.removeChild(this.#map.get(key).element) - this.#map.delete(key) - } - redraw() { - for (const obj of this.#map.values()) { - const { range, draw, options, element } = obj - this.#svg.removeChild(element) - const rects = range.getClientRects() - const el = draw(rects, options) - this.#svg.append(el) - obj.element = el - obj.rects = rects - } - } - hitTest({ x, y }) { - const arr = Array.from(this.#map.entries()) - // loop in reverse to hit more recently added items first - for (let i = arr.length - 1; i >= 0; i--) { - const [key, obj] = arr[i] - for (const { left, top, right, bottom } of obj.rects) - if (top <= y && left <= x && bottom > y && right > x) - return [key, obj.range] - } - return [] - } - static underline(rects, options = {}) { - const { color = 'red', width: strokeWidth = 2, writingMode } = options - const g = createSVGElement('g') - g.setAttribute('fill', color) - if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') - for (const { right, top, height } of rects) { - const el = createSVGElement('rect') - el.setAttribute('x', right - strokeWidth) - el.setAttribute('y', top) - el.setAttribute('height', height) - el.setAttribute('width', strokeWidth) - g.append(el) - } - else for (const { left, bottom, width } of rects) { - const el = createSVGElement('rect') - el.setAttribute('x', left) - el.setAttribute('y', bottom - strokeWidth) - el.setAttribute('height', strokeWidth) - el.setAttribute('width', width) - g.append(el) - } - return g - } - static strikethrough(rects, options = {}) { - const { color = 'red', width: strokeWidth = 2, writingMode } = options - const g = createSVGElement('g') - g.setAttribute('fill', color) - if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') - for (const { right, left, top, height } of rects) { - const el = createSVGElement('rect') - el.setAttribute('x', (right + left) / 2) - el.setAttribute('y', top) - el.setAttribute('height', height) - el.setAttribute('width', strokeWidth) - g.append(el) - } - else for (const { left, top, bottom, width } of rects) { - const el = createSVGElement('rect') - el.setAttribute('x', left) - el.setAttribute('y', (top + bottom) / 2) - el.setAttribute('height', strokeWidth) - el.setAttribute('width', width) - g.append(el) - } - return g - } - static squiggly(rects, options = {}) { - const { color = 'red', width: strokeWidth = 2, writingMode } = options - const g = createSVGElement('g') - g.setAttribute('fill', 'none') - g.setAttribute('stroke', color) - g.setAttribute('stroke-width', strokeWidth) - const block = strokeWidth * 1.5 - if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') - for (const { right, top, height } of rects) { - const el = createSVGElement('path') - const n = Math.round(height / block / 1.5) - const inline = height / n - const ls = Array.from({ length: n }, - (_, i) => `l${i % 2 ? -block : block} ${inline}`).join('') - el.setAttribute('d', `M${right} ${top}${ls}`) - g.append(el) - } - else for (const { left, bottom, width } of rects) { - const el = createSVGElement('path') - const n = Math.round(width / block / 1.5) - const inline = width / n - const ls = Array.from({ length: n }, - (_, i) => `l${inline} ${i % 2 ? block : -block}`).join('') - el.setAttribute('d', `M${left} ${bottom}${ls}`) - g.append(el) - } - return g - } - static highlight(rects, options = {}) { - const { color = 'red' } = options - const g = createSVGElement('g') - g.setAttribute('fill', color) - g.style.opacity = 'var(--overlayer-highlight-opacity, .3)' - g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)' - for (const { left, top, height, width } of rects) { - const el = createSVGElement('rect') - el.setAttribute('x', left) - el.setAttribute('y', top) - el.setAttribute('height', height) - el.setAttribute('width', width) - g.append(el) - } - return g - } - static outline(rects, options = {}) { - const { color = 'red', width: strokeWidth = 3, radius = 3 } = options - const g = createSVGElement('g') - g.setAttribute('fill', 'none') - g.setAttribute('stroke', color) - g.setAttribute('stroke-width', strokeWidth) - for (const { left, top, height, width } of rects) { - const el = createSVGElement('rect') - el.setAttribute('x', left) - el.setAttribute('y', top) - el.setAttribute('height', height) - el.setAttribute('width', width) - el.setAttribute('rx', radius) - g.append(el) - } - return g - } - // make an exact copy of an image in the overlay - // one can then apply filters to the entire element, without affecting them; - // it's a bit silly and probably better to just invert images twice - // (though the color will be off in that case if you do heu-rotate) - static copyImage([rect], options = {}) { - const { src } = options - const image = createSVGElement('image') - const { left, top, height, width } = rect - image.setAttribute('href', src) - image.setAttribute('x', left) - image.setAttribute('y', top) - image.setAttribute('height', height) - image.setAttribute('width', width) - return image + return g + } + static outline(rects, options = {}) { + const { color = 'red', width: strokeWidth = 3, radius = 3 } = options + const g = createSVGElement('g') + g.setAttribute('fill', 'none') + g.setAttribute('stroke', color) + g.setAttribute('stroke-width', strokeWidth) + for (const { left, top, height, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', width) + el.setAttribute('rx', radius) + g.append(el) } + return g + } + // make an exact copy of an image in the overlay + // one can then apply filters to the entire element, without affecting them; + // it's a bit silly and probably better to just invert images twice + // (though the color will be off in that case if you do heu-rotate) + static copyImage([rect], options = {}) { + const { src } = options + const image = createSVGElement('image') + const { left, top, height, width } = rect + image.setAttribute('href', src) + image.setAttribute('x', left) + image.setAttribute('y', top) + image.setAttribute('height', height) + image.setAttribute('width', width) + return image + } } - diff --git a/client/public/assets/foliate/paginator.js b/client/public/assets/foliate/paginator.js index 7980c2029..316115b5d 100644 --- a/client/public/assets/foliate/paginator.js +++ b/client/public/assets/foliate/paginator.js @@ -1,78 +1,77 @@ -const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const debounce = (f, wait, immediate) => { - let timeout - return (...args) => { - const later = () => { - timeout = null - if (!immediate) f(...args) - } - const callNow = immediate && !timeout - if (timeout) clearTimeout(timeout) - timeout = setTimeout(later, wait) - if (callNow) f(...args) + let timeout + return (...args) => { + const later = () => { + timeout = null + if (!immediate) f(...args) } + const callNow = immediate && !timeout + if (timeout) clearTimeout(timeout) + timeout = setTimeout(later, wait) + if (callNow) f(...args) + } } const lerp = (min, max, x) => x * (max - min) + min -const easeOutQuad = x => 1 - (1 - x) * (1 - x) -const animate = (a, b, duration, ease, render) => new Promise(resolve => { +const easeOutQuad = (x) => 1 - (1 - x) * (1 - x) +const animate = (a, b, duration, ease, render) => + new Promise((resolve) => { let start - const step = now => { - if (document.hidden) { - render(lerp(a, b, 1)) - return resolve() - } - start ??= now - const fraction = Math.min(1, (now - start) / duration) - render(lerp(a, b, ease(fraction))) - if (fraction < 1) requestAnimationFrame(step) - else resolve() - } - if (document.hidden) { + const step = (now) => { + if (document.hidden) { render(lerp(a, b, 1)) return resolve() + } + start ??= now + const fraction = Math.min(1, (now - start) / duration) + render(lerp(a, b, ease(fraction))) + if (fraction < 1) requestAnimationFrame(step) + else resolve() + } + if (document.hidden) { + render(lerp(a, b, 1)) + return resolve() } requestAnimationFrame(step) -}) + }) // collapsed range doesn't return client rects sometimes (or always?) // try make get a non-collapsed range or element -const uncollapse = range => { - if (!range?.collapsed) return range - const { endOffset, endContainer } = range - if (endContainer.nodeType === 1) { - const node = endContainer.childNodes[endOffset] - if (node?.nodeType === 1) return node - return endContainer - } - if (endOffset + 1 < endContainer.length) range.setEnd(endContainer, endOffset + 1) - else if (endOffset > 1) range.setStart(endContainer, endOffset - 1) - else return endContainer.parentNode - return range +const uncollapse = (range) => { + if (!range?.collapsed) return range + const { endOffset, endContainer } = range + if (endContainer.nodeType === 1) { + const node = endContainer.childNodes[endOffset] + if (node?.nodeType === 1) return node + return endContainer + } + if (endOffset + 1 < endContainer.length) range.setEnd(endContainer, endOffset + 1) + else if (endOffset > 1) range.setStart(endContainer, endOffset - 1) + else return endContainer.parentNode + return range } const makeRange = (doc, node, start, end = start) => { - const range = doc.createRange() - range.setStart(node, start) - range.setEnd(node, end) - return range + const range = doc.createRange() + range.setStart(node, start) + range.setEnd(node, end) + return range } // use binary search to find an offset value in a text node const bisectNode = (doc, node, cb, start = 0, end = node.nodeValue.length) => { - if (end - start === 1) { - const result = cb(makeRange(doc, node, start), makeRange(doc, node, end)) - return result < 0 ? start : end - } - const mid = Math.floor(start + (end - start) / 2) - const result = cb(makeRange(doc, node, start, mid), makeRange(doc, node, mid, end)) - return result < 0 ? bisectNode(doc, node, cb, start, mid) - : result > 0 ? bisectNode(doc, node, cb, mid, end) : mid + if (end - start === 1) { + const result = cb(makeRange(doc, node, start), makeRange(doc, node, end)) + return result < 0 ? start : end + } + const mid = Math.floor(start + (end - start) / 2) + const result = cb(makeRange(doc, node, start, mid), makeRange(doc, node, mid, end)) + return result < 0 ? bisectNode(doc, node, cb, start, mid) : result > 0 ? bisectNode(doc, node, cb, mid, end) : mid } -const { SHOW_ELEMENT, SHOW_TEXT, SHOW_CDATA_SECTION, - FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter +const { SHOW_ELEMENT, SHOW_TEXT, SHOW_CDATA_SECTION, FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter const filter = SHOW_ELEMENT | SHOW_TEXT | SHOW_CDATA_SECTION @@ -80,378 +79,381 @@ const filter = SHOW_ELEMENT | SHOW_TEXT | SHOW_CDATA_SECTION // where it fails to include rects that have zero width and non-zero height // (CSSOM spec says "rectangles [...] of which the height or width is not zero") // which makes the visible range include an extra space at column boundaries -const getBoundingClientRect = target => { - let top = Infinity, right = -Infinity, left = Infinity, bottom = -Infinity - for (const rect of target.getClientRects()) { - left = Math.min(left, rect.left) - top = Math.min(top, rect.top) - right = Math.max(right, rect.right) - bottom = Math.max(bottom, rect.bottom) - } - return new DOMRect(left, top, right - left, bottom - top) +const getBoundingClientRect = (target) => { + let top = Infinity, + right = -Infinity, + left = Infinity, + bottom = -Infinity + for (const rect of target.getClientRects()) { + left = Math.min(left, rect.left) + top = Math.min(top, rect.top) + right = Math.max(right, rect.right) + bottom = Math.max(bottom, rect.bottom) + } + return new DOMRect(left, top, right - left, bottom - top) } const getVisibleRange = (doc, start, end, mapRect) => { - // first get all visible nodes - const acceptNode = node => { - const name = node.localName?.toLowerCase() - // ignore all scripts, styles, and their children - if (name === 'script' || name === 'style') return FILTER_REJECT - if (node.nodeType === 1) { - const { left, right } = mapRect(node.getBoundingClientRect()) - // no need to check child nodes if it's completely out of view - if (right < start || left > end) return FILTER_REJECT - // elements must be completely in view to be considered visible - // because you can't specify offsets for elements - if (left >= start && right <= end) return FILTER_ACCEPT - // TODO: it should probably allow elements that do not contain text - // because they can exceed the whole viewport in both directions - // especially in scrolled mode - } else { - // ignore empty text nodes - if (!node.nodeValue?.trim()) return FILTER_SKIP - // create range to get rect - const range = doc.createRange() - range.selectNodeContents(node) - const { left, right } = mapRect(range.getBoundingClientRect()) - // it's visible if any part of it is in view - if (right >= start && left <= end) return FILTER_ACCEPT - } - return FILTER_SKIP + // first get all visible nodes + const acceptNode = (node) => { + const name = node.localName?.toLowerCase() + // ignore all scripts, styles, and their children + if (name === 'script' || name === 'style') return FILTER_REJECT + if (node.nodeType === 1) { + const { left, right } = mapRect(node.getBoundingClientRect()) + // no need to check child nodes if it's completely out of view + if (right < start || left > end) return FILTER_REJECT + // elements must be completely in view to be considered visible + // because you can't specify offsets for elements + if (left >= start && right <= end) return FILTER_ACCEPT + // TODO: it should probably allow elements that do not contain text + // because they can exceed the whole viewport in both directions + // especially in scrolled mode + } else { + // ignore empty text nodes + if (!node.nodeValue?.trim()) return FILTER_SKIP + // create range to get rect + const range = doc.createRange() + range.selectNodeContents(node) + const { left, right } = mapRect(range.getBoundingClientRect()) + // it's visible if any part of it is in view + if (right >= start && left <= end) return FILTER_ACCEPT } - const walker = doc.createTreeWalker(doc.body, filter, { acceptNode }) - const nodes = [] - for (let node = walker.nextNode(); node; node = walker.nextNode()) - nodes.push(node) + return FILTER_SKIP + } + const walker = doc.createTreeWalker(doc.body, filter, { acceptNode }) + const nodes = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) nodes.push(node) - // we're only interested in the first and last visible nodes - const from = nodes[0] ?? doc.body - const to = nodes[nodes.length - 1] ?? from + // we're only interested in the first and last visible nodes + const from = nodes[0] ?? doc.body + const to = nodes[nodes.length - 1] ?? from - // find the offset at which visibility changes - const startOffset = from.nodeType === 1 ? 0 - : bisectNode(doc, from, (a, b) => { - const p = mapRect(getBoundingClientRect(a)) - const q = mapRect(getBoundingClientRect(b)) - if (p.right < start && q.left > start) return 0 - return q.left > start ? -1 : 1 + // find the offset at which visibility changes + const startOffset = + from.nodeType === 1 + ? 0 + : bisectNode(doc, from, (a, b) => { + const p = mapRect(getBoundingClientRect(a)) + const q = mapRect(getBoundingClientRect(b)) + if (p.right < start && q.left > start) return 0 + return q.left > start ? -1 : 1 }) - const endOffset = to.nodeType === 1 ? 0 - : bisectNode(doc, to, (a, b) => { - const p = mapRect(getBoundingClientRect(a)) - const q = mapRect(getBoundingClientRect(b)) - if (p.right < end && q.left > end) return 0 - return q.left > end ? -1 : 1 + const endOffset = + to.nodeType === 1 + ? 0 + : bisectNode(doc, to, (a, b) => { + const p = mapRect(getBoundingClientRect(a)) + const q = mapRect(getBoundingClientRect(b)) + if (p.right < end && q.left > end) return 0 + return q.left > end ? -1 : 1 }) - const range = doc.createRange() - range.setStart(from, startOffset) - range.setEnd(to, endOffset) - return range + const range = doc.createRange() + range.setStart(from, startOffset) + range.setEnd(to, endOffset) + return range } -const selectionIsBackward = sel => { - const range = document.createRange() - range.setStart(sel.anchorNode, sel.anchorOffset) - range.setEnd(sel.focusNode, sel.focusOffset) - return range.collapsed +const selectionIsBackward = (sel) => { + const range = document.createRange() + range.setStart(sel.anchorNode, sel.anchorOffset) + range.setEnd(sel.focusNode, sel.focusOffset) + return range.collapsed } const setSelectionTo = (target, collapse) => { - let range - if (target.startContainer) range = target.cloneRange() - else if (target.nodeType) { - range = document.createRange() - range.selectNode(target) - } - if (range) { - const sel = range.startContainer.ownerDocument.defaultView.getSelection() - if (sel) { - sel.removeAllRanges() - if (collapse === -1) range.collapse(true) - else if (collapse === 1) range.collapse() - sel.addRange(range) - } + let range + if (target.startContainer) range = target.cloneRange() + else if (target.nodeType) { + range = document.createRange() + range.selectNode(target) + } + if (range) { + const sel = range.startContainer.ownerDocument.defaultView.getSelection() + if (sel) { + sel.removeAllRanges() + if (collapse === -1) range.collapse(true) + else if (collapse === 1) range.collapse() + sel.addRange(range) } + } } -const getDirection = doc => { - const { defaultView } = doc - const { writingMode, direction } = defaultView.getComputedStyle(doc.body) - const vertical = writingMode === 'vertical-rl' - || writingMode === 'vertical-lr' - const rtl = doc.body.dir === 'rtl' - || direction === 'rtl' - || doc.documentElement.dir === 'rtl' - return { vertical, rtl } +const getDirection = (doc) => { + const { defaultView } = doc + const { writingMode, direction } = defaultView.getComputedStyle(doc.body) + const vertical = writingMode === 'vertical-rl' || writingMode === 'vertical-lr' + const rtl = doc.body.dir === 'rtl' || direction === 'rtl' || doc.documentElement.dir === 'rtl' + return { vertical, rtl } } -const getBackground = doc => { - const bodyStyle = doc.defaultView.getComputedStyle(doc.body) - return bodyStyle.backgroundColor === 'rgba(0, 0, 0, 0)' - && bodyStyle.backgroundImage === 'none' - ? doc.defaultView.getComputedStyle(doc.documentElement).background - : bodyStyle.background +const getBackground = (doc) => { + const bodyStyle = doc.defaultView.getComputedStyle(doc.body) + return bodyStyle.backgroundColor === 'rgba(0, 0, 0, 0)' && bodyStyle.backgroundImage === 'none' + ? doc.defaultView.getComputedStyle(doc.documentElement).background + : bodyStyle.background } -const makeMarginals = (length, part) => Array.from({ length }, () => { +const makeMarginals = (length, part) => + Array.from({ length }, () => { const div = document.createElement('div') const child = document.createElement('div') div.append(child) child.setAttribute('part', part) return div -}) + }) const setStylesImportant = (el, styles) => { - const { style } = el - for (const [k, v] of Object.entries(styles)) style.setProperty(k, v, 'important') + const { style } = el + for (const [k, v] of Object.entries(styles)) style.setProperty(k, v, 'important') } class View { - #observer = new ResizeObserver(() => this.expand()) - #element = document.createElement('div') - #iframe = document.createElement('iframe') - #contentRange = document.createRange() - #overlayer - #vertical = false - #rtl = false - #column = true - #size - #layout = {} - constructor({ container, onExpand }) { - this.container = container - this.onExpand = onExpand - this.#iframe.setAttribute('part', 'filter') - this.#element.append(this.#iframe) - Object.assign(this.#element.style, { - boxSizing: 'content-box', - position: 'relative', - overflow: 'hidden', - flex: '0 0 auto', - width: '100%', height: '100%', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - }) - Object.assign(this.#iframe.style, { - overflow: 'hidden', - border: '0', - display: 'none', - width: '100%', height: '100%', - }) - // `allow-scripts` is needed for events because of WebKit bug - // https://bugs.webkit.org/show_bug.cgi?id=218086 - this.#iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') - this.#iframe.setAttribute('scrolling', 'no') - } - get element() { - return this.#element - } - get document() { - return this.#iframe.contentDocument - } - async load(src, afterLoad, beforeRender) { - if (typeof src !== 'string') throw new Error(`${src} is not string`) - return new Promise(resolve => { - this.#iframe.addEventListener('load', () => { - const doc = this.document - afterLoad?.(doc) + #observer = new ResizeObserver(() => this.expand()) + #element = document.createElement('div') + #iframe = document.createElement('iframe') + #contentRange = document.createRange() + #overlayer + #vertical = false + #rtl = false + #column = true + #size + #layout = {} + constructor({ container, onExpand }) { + this.container = container + this.onExpand = onExpand + this.#iframe.setAttribute('part', 'filter') + this.#element.append(this.#iframe) + Object.assign(this.#element.style, { + boxSizing: 'content-box', + position: 'relative', + overflow: 'hidden', + flex: '0 0 auto', + width: '100%', + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }) + Object.assign(this.#iframe.style, { + overflow: 'hidden', + border: '0', + display: 'none', + width: '100%', + height: '100%', + }) + // `allow-scripts` is needed for events because of WebKit bug + // https://bugs.webkit.org/show_bug.cgi?id=218086 + this.#iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') + this.#iframe.setAttribute('scrolling', 'no') + } + get element() { + return this.#element + } + get document() { + return this.#iframe.contentDocument + } + async load(src, afterLoad, beforeRender) { + if (typeof src !== 'string') throw new Error(`${src} is not string`) + return new Promise((resolve) => { + this.#iframe.addEventListener( + 'load', + () => { + const doc = this.document + afterLoad?.(doc) - // it needs to be visible for Firefox to get computed style - this.#iframe.style.display = 'block' - const { vertical, rtl } = getDirection(doc) - const background = getBackground(doc) - this.#iframe.style.display = 'none' + // it needs to be visible for Firefox to get computed style + this.#iframe.style.display = 'block' + const { vertical, rtl } = getDirection(doc) + const background = getBackground(doc) + this.#iframe.style.display = 'none' - this.#vertical = vertical - this.#rtl = rtl + this.#vertical = vertical + this.#rtl = rtl - this.#contentRange.selectNodeContents(doc.body) - const layout = beforeRender?.({ vertical, rtl, background }) - this.#iframe.style.display = 'block' - this.render(layout) - this.#observer.observe(doc.body) + this.#contentRange.selectNodeContents(doc.body) + const layout = beforeRender?.({ vertical, rtl, background }) + this.#iframe.style.display = 'block' + this.render(layout) + this.#observer.observe(doc.body) - // the resize observer above doesn't work in Firefox - // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1832939) - // until the bug is fixed we can at least account for font load - doc.fonts.ready.then(() => this.expand()) + // the resize observer above doesn't work in Firefox + // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1832939) + // until the bug is fixed we can at least account for font load + doc.fonts.ready.then(() => this.expand()) - resolve() - }, { once: true }) - this.#iframe.src = src - }) - } - render(layout) { - if (!layout) return - this.#column = layout.flow !== 'scrolled' - this.#layout = layout - if (this.#column) this.columnize(layout) - else this.scrolled(layout) - } - scrolled({ gap, columnWidth }) { - const vertical = this.#vertical - const doc = this.document - setStylesImportant(doc.documentElement, { - 'box-sizing': 'border-box', - 'padding': vertical ? `${gap}px 0` : `0 ${gap}px`, - 'column-width': 'auto', - 'height': 'auto', - 'width': 'auto', - }) - setStylesImportant(doc.body, { - [vertical ? 'max-height' : 'max-width']: `${columnWidth}px`, - 'margin': 'auto', - }) - this.setImageSize() - this.expand() - } - columnize({ width, height, gap, columnWidth }) { - const vertical = this.#vertical - this.#size = vertical ? height : width + resolve() + }, + { once: true }, + ) + this.#iframe.src = src + }) + } + render(layout) { + if (!layout) return + this.#column = layout.flow !== 'scrolled' + this.#layout = layout + if (this.#column) this.columnize(layout) + else this.scrolled(layout) + } + scrolled({ gap, columnWidth }) { + const vertical = this.#vertical + const doc = this.document + setStylesImportant(doc.documentElement, { + 'box-sizing': 'border-box', + padding: vertical ? `${gap}px 0` : `0 ${gap}px`, + 'column-width': 'auto', + height: 'auto', + width: 'auto', + }) + setStylesImportant(doc.body, { + [vertical ? 'max-height' : 'max-width']: `${columnWidth}px`, + margin: 'auto', + }) + this.setImageSize() + this.expand() + } + columnize({ width, height, gap, columnWidth }) { + const vertical = this.#vertical + this.#size = vertical ? height : width - const doc = this.document - setStylesImportant(doc.documentElement, { - 'box-sizing': 'border-box', - 'column-width': `${Math.trunc(columnWidth)}px`, - 'column-gap': `${gap}px`, - 'column-fill': 'auto', - ...(vertical - ? { 'width': `${width}px` } - : { 'height': `${height}px` }), - 'padding': vertical ? `${gap / 2}px 0` : `0 ${gap / 2}px`, - 'overflow': 'hidden', - // force wrap long words - 'overflow-wrap': 'break-word', - // reset some potentially problematic props - 'position': 'static', 'border': '0', 'margin': '0', - 'max-height': 'none', 'max-width': 'none', - 'min-height': 'none', 'min-width': 'none', - // fix glyph clipping in WebKit - '-webkit-line-box-contain': 'block glyphs replaced', - }) - setStylesImportant(doc.body, { - 'max-height': 'none', - 'max-width': 'none', - 'margin': '0', - }) - this.setImageSize() - this.expand() + const doc = this.document + setStylesImportant(doc.documentElement, { + 'box-sizing': 'border-box', + 'column-width': `${Math.trunc(columnWidth)}px`, + 'column-gap': `${gap}px`, + 'column-fill': 'auto', + ...(vertical ? { width: `${width}px` } : { height: `${height}px` }), + padding: vertical ? `${gap / 2}px 0` : `0 ${gap / 2}px`, + overflow: 'hidden', + // force wrap long words + 'overflow-wrap': 'break-word', + // reset some potentially problematic props + position: 'static', + border: '0', + margin: '0', + 'max-height': 'none', + 'max-width': 'none', + 'min-height': 'none', + 'min-width': 'none', + // fix glyph clipping in WebKit + '-webkit-line-box-contain': 'block glyphs replaced', + }) + setStylesImportant(doc.body, { + 'max-height': 'none', + 'max-width': 'none', + margin: '0', + }) + this.setImageSize() + this.expand() + } + setImageSize() { + const { width, height, margin } = this.#layout + const vertical = this.#vertical + const doc = this.document + for (const el of doc.body.querySelectorAll('img, svg, video')) { + // preserve max size if they are already set + const { maxHeight, maxWidth } = doc.defaultView.getComputedStyle(el) + setStylesImportant(el, { + 'max-height': vertical ? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%') : `${height - margin * 2}px`, + 'max-width': vertical ? `${width - margin * 2}px` : maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%', + 'object-fit': 'contain', + 'page-break-inside': 'avoid', + 'break-inside': 'avoid', + 'box-sizing': 'border-box', + }) } - setImageSize() { - const { width, height, margin } = this.#layout - const vertical = this.#vertical - const doc = this.document - for (const el of doc.body.querySelectorAll('img, svg, video')) { - // preserve max size if they are already set - const { maxHeight, maxWidth } = doc.defaultView.getComputedStyle(el) - setStylesImportant(el, { - 'max-height': vertical - ? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%') - : `${height - margin * 2}px`, - 'max-width': vertical - ? `${width - margin * 2}px` - : (maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%'), - 'object-fit': 'contain', - 'page-break-inside': 'avoid', - 'break-inside': 'avoid', - 'box-sizing': 'border-box', - }) - } - } - expand() { - const { documentElement } = this.document - if (this.#column) { - const side = this.#vertical ? 'height' : 'width' - const otherSide = this.#vertical ? 'width' : 'height' - const contentRect = this.#contentRange.getBoundingClientRect() - const rootRect = documentElement.getBoundingClientRect() - // offset caused by column break at the start of the page - // which seem to be supported only by WebKit and only for horizontal writing - const contentStart = this.#vertical ? 0 - : this.#rtl ? rootRect.right - contentRect.right : contentRect.left - rootRect.left - const contentSize = contentStart + contentRect[side] - const pageCount = Math.ceil(contentSize / this.#size) - const expandedSize = pageCount * this.#size - this.#element.style.padding = '0' - this.#iframe.style[side] = `${expandedSize}px` - this.#element.style[side] = `${expandedSize + this.#size * 2}px` - this.#iframe.style[otherSide] = '100%' - this.#element.style[otherSide] = '100%' - documentElement.style[side] = `${this.#size}px` - if (this.#overlayer) { - this.#overlayer.element.style.margin = '0' - this.#overlayer.element.style.left = this.#vertical ? '0' : `${this.#size}px` - this.#overlayer.element.style.top = this.#vertical ? `${this.#size}px` : '0' - this.#overlayer.element.style[side] = `${expandedSize}px` - this.#overlayer.redraw() - } - } else { - const side = this.#vertical ? 'width' : 'height' - const otherSide = this.#vertical ? 'height' : 'width' - const contentSize = documentElement.getBoundingClientRect()[side] - const expandedSize = contentSize - const { margin } = this.#layout - const padding = this.#vertical ? `0 ${margin}px` : `${margin}px 0` - this.#element.style.padding = padding - this.#iframe.style[side] = `${expandedSize}px` - this.#element.style[side] = `${expandedSize}px` - this.#iframe.style[otherSide] = '100%' - this.#element.style[otherSide] = '100%' - if (this.#overlayer) { - this.#overlayer.element.style.margin = padding - this.#overlayer.element.style.left = '0' - this.#overlayer.element.style.top = '0' - this.#overlayer.element.style[side] = `${expandedSize}px` - this.#overlayer.redraw() - } - } - this.onExpand() - } - set overlayer(overlayer) { - this.#overlayer = overlayer - this.#element.append(overlayer.element) - } - get overlayer() { - return this.#overlayer - } - destroy() { - if (this.document) this.#observer.unobserve(this.document.body) + } + expand() { + const { documentElement } = this.document + if (this.#column) { + const side = this.#vertical ? 'height' : 'width' + const otherSide = this.#vertical ? 'width' : 'height' + const contentRect = this.#contentRange.getBoundingClientRect() + const rootRect = documentElement.getBoundingClientRect() + // offset caused by column break at the start of the page + // which seem to be supported only by WebKit and only for horizontal writing + const contentStart = this.#vertical ? 0 : this.#rtl ? rootRect.right - contentRect.right : contentRect.left - rootRect.left + const contentSize = contentStart + contentRect[side] + const pageCount = Math.ceil(contentSize / this.#size) + const expandedSize = pageCount * this.#size + this.#element.style.padding = '0' + this.#iframe.style[side] = `${expandedSize}px` + this.#element.style[side] = `${expandedSize + this.#size * 2}px` + this.#iframe.style[otherSide] = '100%' + this.#element.style[otherSide] = '100%' + documentElement.style[side] = `${this.#size}px` + if (this.#overlayer) { + this.#overlayer.element.style.margin = '0' + this.#overlayer.element.style.left = this.#vertical ? '0' : `${this.#size}px` + this.#overlayer.element.style.top = this.#vertical ? `${this.#size}px` : '0' + this.#overlayer.element.style[side] = `${expandedSize}px` + this.#overlayer.redraw() + } + } else { + const side = this.#vertical ? 'width' : 'height' + const otherSide = this.#vertical ? 'height' : 'width' + const contentSize = documentElement.getBoundingClientRect()[side] + const expandedSize = contentSize + const { margin } = this.#layout + const padding = this.#vertical ? `0 ${margin}px` : `${margin}px 0` + this.#element.style.padding = padding + this.#iframe.style[side] = `${expandedSize}px` + this.#element.style[side] = `${expandedSize}px` + this.#iframe.style[otherSide] = '100%' + this.#element.style[otherSide] = '100%' + if (this.#overlayer) { + this.#overlayer.element.style.margin = padding + this.#overlayer.element.style.left = '0' + this.#overlayer.element.style.top = '0' + this.#overlayer.element.style[side] = `${expandedSize}px` + this.#overlayer.redraw() + } } + this.onExpand() + } + set overlayer(overlayer) { + this.#overlayer = overlayer + this.#element.append(overlayer.element) + } + get overlayer() { + return this.#overlayer + } + destroy() { + if (this.document) this.#observer.unobserve(this.document.body) + } } // NOTE: everything here assumes the so-called "negative scroll type" for RTL export class Paginator extends HTMLElement { - static observedAttributes = [ - 'flow', 'gap', 'margin', - 'max-inline-size', 'max-block-size', 'max-column-count', - ] - #root = this.attachShadow({ mode: 'closed' }) - #observer = new ResizeObserver(() => this.render()) - #top - #background - #container - #header - #footer - #view - #vertical = false - #rtl = false - #margin = 0 - #index = -1 - #anchor = 0 // anchor view to a fraction (0-1), Range, or Element - #justAnchored = false - #locked = false // while true, prevent any further navigation - #styles - #styleMap = new WeakMap() - #mediaQuery = matchMedia('(prefers-color-scheme: dark)') - #mediaQueryListener - #scrollBounds - #touchState - #touchScrolled - #lastVisibleRange - constructor() { - super() - this.#root.innerHTML = `