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 = `