books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests

This commit is contained in:
2026-10-24 01:06:33 +00:00
parent 2ca2d0d5a4
commit 364c7ee132
65 changed files with 25709 additions and 24 deletions

21
static/vendor/foliate-js/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 John Factotum
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1
static/vendor/foliate-js/VERSION vendored Normal file
View File

@@ -0,0 +1 @@
foliate-js @ 78914aef4466eb960965702401634c2cb348e9b1 (MIT), https://github.com/johnfactotum/foliate-js. vendor/pdfjs omitted: PDFs use pdfjs-dist from npm.

45
static/vendor/foliate-js/comic-book.js vendored Normal file
View File

@@ -0,0 +1,45 @@
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([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0"><img src="${src}"></body></html>`], { type: 'text/html' }))
urls.set(name, [src, page])
cache.set(name, page)
return page
}
const unload = name => {
urls.get(name)?.forEach?.(url => URL.revokeObjectURL(url))
urls.delete(name)
cache.delete(name)
}
const exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.jxl', '.avif']
const files = entries
.map(entry => entry.filename)
.filter(name => exts.some(ext => name.endsWith(ext)))
.sort(new Intl.Collator([], { numeric: true }).compare)
if (!files.length) throw new Error('No supported image files in archive')
const 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
}

241
static/vendor/foliate-js/dict.js vendored Normal file
View File

@@ -0,0 +1,241 @@
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 strcmp = (a, b) => {
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')
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)
}
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
}
// 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
}
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
}
}
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))
}
}
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
}
}
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]))
}
}

1083
static/vendor/foliate-js/epub.js vendored Normal file

File diff suppressed because it is too large Load Diff

349
static/vendor/foliate-js/epubcfi.js vendored Normal file
View File

@@ -0,0 +1,349 @@
const findIndices = (arr, f) => arr
.map((x, i, a) => f(x, i, a) ? i : null).filter(x => x != null)
const splitAt = (arr, is) => [-1, ...is, arr.length].reduce(({ xs, a }, b) =>
({ xs: xs?.concat([arr.slice(a + 1, b)]) ?? [], a: b }), {}).xs
const concatArrays = (a, b) =>
a.slice(0, -1).concat([a[a.length - 1].concat(b[0])]).concat(b.slice(1))
const isNumber = /\d/
export const isCFI = /^epubcfi\((.*)\)$/
const escapeCFI = str => str.replace(/[\^[\](),;=]/g, '^$&')
const wrap = x => isCFI.test(x) ? x : `epubcfi(${x})`
const unwrap = x => x.match(isCFI)?.[1] ?? x
const lift = f => (...xs) =>
`epubcfi(${f(...xs.map(x => x.match(isCFI)?.[1] ?? x))})`
export const joinIndir = lift((...xs) => xs.join('!'))
const tokenizer = str => {
const tokens = []
let state, escape, value = ''
const push = x => (tokens.push(x), state = null, value = '')
const cat = x => (value += x, escape = false)
for (const char of Array.from(str.trim()).concat('')) {
if (char === '^' && !escape) {
escape = true
continue
}
if (state === '!') push(['!'])
else if (state === ',') push([','])
else if (state === '/' || state === ':') {
if (isNumber.test(char)) {
cat(char)
continue
} else push([state, parseInt(value)])
} else if (state === '~') {
if (isNumber.test(char) || char === '.') {
cat(char)
continue
} else push(['~', parseFloat(value)])
} else if (state === '@') {
if (char === ':') {
push(['@', parseFloat(value)])
state = '@'
continue
}
if (isNumber.test(char) || char === '.') {
cat(char)
continue
} else push(['@', parseFloat(value)])
} else if (state === '[') {
if (char === ';' && !escape) {
push(['[', value])
state = ';'
} else if (char === ',' && !escape) {
push(['[', value])
state = '['
} else if (char === ']' && !escape) push(['[', value])
else cat(char)
continue
} else if (state?.startsWith(';')) {
if (char === '=' && !escape) {
state = `;${value}`
value = ''
} else if (char === ';' && !escape) {
push([state, value])
state = ';'
} else if (char === ']' && !escape) push([state, value])
else cat(char)
continue
}
if (char === '/' || char === ':' || char === '~' || char === '@'
|| char === '[' || char === '!' || char === ',') state = char
}
return tokens
}
const findTokens = (tokens, x) => findIndices(tokens, ([t]) => t === x)
const parser = tokens => {
const parts = []
let state
for (const [type, val] of tokens) {
if (type === '/') parts.push({ index: val })
else {
const last = parts[parts.length - 1]
if (type === ':') last.offset = val
else if (type === '~') last.temporal = val
else if (type === '@') last.spatial = (last.spatial ?? []).concat(val)
else if (type === ';s') last.side = val
else if (type === '[') {
if (state === '/' && val) last.id = val
else {
last.text = (last.text ?? []).concat(val)
continue
}
}
}
state = type
}
return parts
}
// split at step indirections, then parse each part
const parserIndir = tokens =>
splitAt(tokens, findTokens(tokens, '!')).map(parser)
export const parse = cfi => {
const tokens = tokenizer(unwrap(cfi))
const commas = findTokens(tokens, ',')
if (!commas.length) return parserIndir(tokens)
const [parent, start, end] = splitAt(tokens, commas).map(parserIndir)
return { parent, start, end }
}
const partToString = ({ index, id, offset, temporal, spatial, text, side }) => {
const param = side ? `;s=${side}` : ''
return `/${index}`
+ (id ? `[${escapeCFI(id)}${param}]` : '')
// "CFI expressions [..] SHOULD include an explicit character offset"
+ (offset != null && index % 2 ? `:${offset}` : '')
+ (temporal ? `~${temporal}` : '')
+ (spatial ? `@${spatial.join(':')}` : '')
+ (text || (!id && side) ? '['
+ (text?.map(escapeCFI)?.join(',') ?? '')
+ param + ']' : '')
}
const toInnerString = parsed => parsed.parent
? [parsed.parent, parsed.start, parsed.end].map(toInnerString).join(',')
: parsed.map(parts => parts.map(partToString).join('')).join('!')
const toString = parsed => wrap(toInnerString(parsed))
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)
}
}
// copy non-local paths from `from`
const parent = from.slice(0, -1).concat([localParent])
return toString({ parent, start: [localStart], end: [localEnd] })
}
export const compare = (a, b) => {
if (typeof a === 'string') a = parse(a)
if (typeof b === 'string') b = parse(b)
if (a.start || b.start) return compare(collapse(a), collapse(b))
|| compare(collapse(a, true), collapse(b, true))
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const p = a[i] ?? [], q = b[i] ?? []
const maxIndex = Math.max(p.length, q.length) - 1
for (let i = 0; i <= maxIndex; i++) {
const x = p[i], y = q[i]
if (!x) return -1
if (!y) return 1
if (x.index > y.index) return 1
if (x.index < y.index) return -1
if (i === maxIndex) {
// TODO: compare temporal & spatial offsets
if (x.offset > y.offset) return 1
if (x.offset < y.offset) return -1
}
}
}
return 0
}
const isTextNode = ({ nodeType }) => nodeType === 3 || nodeType === 4
const isElementNode = ({ nodeType }) => nodeType === 1
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
}
// child nodes are organized such that the result is always
// [element, text, element, text, ..., element],
// regardless of the actual structure in the document;
// so multiple text nodes need to be combined, and nonexistent ones counted;
// see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec
const indexChildNodes = (node, 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 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 nodeToParts = (node, offset, filter) => {
let { id, parentNode } = node
while (filter && parentNode
&& parentNode !== node.ownerDocument.documentElement
&& filter(parentNode) === NodeFilter.FILTER_SKIP)
parentNode = parentNode.parentNode
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 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])
}
export const toRange = (doc, parts, filter) => {
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 range = doc.createRange()
if (start.before) range.setStartBefore(start.node)
else if (start.after) range.setStartAfter(start.node)
else range.setStart(start.node, start.offset)
if (end.before) range.setEndBefore(end.node)
else if (end.after) range.setEndAfter(end.node)
else range.setEnd(end.node, end.offset)
return range
}
// faster way of getting CFIs for sorted elements in a single parent
export const fromElements = elements => {
const results = []
const { parentNode } = elements[0]
const parts = nodeToParts(parentNode)
for (const [index, node] of indexChildNodes(parentNode).entries()) {
const el = elements[results.length]
if (node === el)
results.push(toString([parts.concat({ id: el.id, index })]))
}
return results
}
export const toElement = (doc, parts) =>
partsToNode(doc.documentElement, collapse(parts)).node
// turn indices into standard CFIs when you don't have an actual package document
export const fake = {
fromIndex: index => 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 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))
}

360
static/vendor/foliate-js/fb2.js vendored Normal file
View File

@@ -0,0 +1,360 @@
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',
}
const MIME = {
XML: 'application/xml',
XHTML: 'application/xhtml+xml',
}
const STYLE = {
'strong': ['strong', 'self'],
'emphasis': ['em', 'self'],
'style': ['span', 'self'],
'a': 'anchor',
'strikethrough': ['s', 'self'],
'sub': ['sub', 'self'],
'sup': ['sup', 'self'],
'code': ['code', 'self'],
'image': 'image',
}
const TABLE = {
'tr': ['tr', {
'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',
}
const SECTION = {
'title': ['header', {
'p': ['h1', STYLE],
'empty-line': ['br'],
}],
'epigraph': ['blockquote', 'self'],
'image': 'image',
'annotation': ['aside'],
'section': ['section', 'self'],
'p': ['p', STYLE],
'poem': ['blockquote', POEM],
'subtitle': ['h2', STYLE],
'cite': ['blockquote', 'self'],
'empty-line': ['br'],
'table': ['table', TABLE],
'text-author': ['p', STYLE],
}
POEM['epigraph'].push(SECTION)
const BODY = {
'image': 'image',
'title': ['section', {
'p': ['h1', STYLE],
'empty-line': ['br'],
}],
'epigraph': ['section', SECTION],
'section': ['section', SECTION],
}
class FB2Converter {
constructor(fb2) {
this.fb2 = fb2
this.doc = document.implementation.createDocument(NS.XHTML, 'html')
// use this instead of `getElementById` to allow images like
// `<image l:href="#img1.jpg" id="img1.jpg" />`
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 [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 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
}
}
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([`
@namespace epub "http://www.idpf.org/2007/ops";
body > img, section > img {
display: block;
margin: auto;
}
.title h1 {
text-align: center;
}
body > section > .title, body.notesBodyType > .title {
margin: 3em 0;
}
body.notesBodyType > section .title h1 {
text-align: start;
}
body.notesBodyType > section .title {
margin: 1em 0;
}
p {
text-indent: 1em;
margin: 0;
}
:not(p) + p, p:first-child {
text-indent: 0;
}
.poem p {
text-indent: 0;
margin: 1em 0;
}
.text-author, .date {
text-align: end;
}
.text-author:before {
content: "—";
}
table {
border-collapse: collapse;
}
td, th {
padding: .25em;
}
a[epub|type~="noteref"] {
font-size: .75em;
vertical-align: super;
}
body:not(.notesBodyType) > .title, body:not(.notesBodyType) > .epigraph {
margin: 3em 0;
}
`], { type: 'text/css' }))
const template = html => `<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><link href="${style}" rel="stylesheet" type="text/css"/></head>
<body>${html}</body>
</html>`
// name of custom ID attribute for TOC items
const dataID = 'data-foliate-id'
export const makeFB2 = async blob => {
const book = {}
const doc = await parseXML(blob)
const converter = new FB2Converter(doc)
const $ = x => doc.querySelector(x)
const $$ = x => [...doc.querySelectorAll(x)]
const getPerson = el => {
const nick = getElementText(el.querySelector('nickname'))
if (nick) return nick
const first = getElementText(el.querySelector('first-name'))
const middle = getElementText(el.querySelector('middle-name'))
const last = getElementText(el.querySelector('last-name'))
const name = [first, middle, last].filter(x => x).join(' ')
const sortAs = last
? [last, [first, middle].filter(x => x).join(' ')].join(', ')
: null
return { name, sortAs }
}
const getDate = el => el?.getAttribute('value') ?? getElementText(el)
const annotation = $('title-info annotation')
book.metadata = {
title: getElementText($('title-info book-title')),
identifier: getElementText($('document-info id')),
language: getElementText($('title-info lang')),
author: $$('title-info author').map(getPerson),
translator: $$('title-info translator').map(getPerson),
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]
})
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 }
})
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.isExternal = uri => /^\w+:/i.test(uri)
book.destroy = () => {
for (const url of urls) URL.revokeObjectURL(url)
}
return book
}

319
static/vendor/foliate-js/fixed-layout.js vendored Normal file
View File

@@ -0,0 +1,319 @@
const parseViewport = str => str
?.split(/[,;\s]/) // NOTE: technically, only the comma is valid
?.filter(x => x)
?.map(x => x.split('=').map(x => x.trim()))
const getViewport = (doc, viewport) => {
// use `viewBox` for SVG
if (doc.documentElement.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)
// 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 }
// 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()
const sheet = new CSSStyleSheet()
this.#root.adoptedStyleSheets = [sheet]
sheet.replaceSync(`:host {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
}`)
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
}
}
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 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)
}
}
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()
}
}
#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
}
}
#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
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' }
}
}
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)
}
}
customElements.define('foliate-fxl', FixedLayout)

103
static/vendor/foliate-js/footnotes.js vendored Normal file
View File

@@ -0,0 +1,103 @@
const getTypes = el => new Set(el?.getAttributeNS?.('http://www.idpf.org/2007/ops', 'type')?.split(' '))
const getRoles = el => new Set(el?.getAttribute?.('role')?.split(' '))
const isSuper = el => {
if (el.matches('sup')) return true
const { verticalAlign } = getComputedStyle(el)
return verticalAlign === 'super'
|| verticalAlign === 'top'
|| verticalAlign === 'text-top'
|| /^\d/.test(verticalAlign)
}
const refTypes = ['biblioref', 'glossref', 'noteref']
const refRoles = ['doc-biblioref', 'doc-glossref', 'doc-noteref']
const isFootnoteReference = a => {
const types = getTypes(a)
const roles = getRoles(a)
return {
yes: refRoles.some(r => roles.has(r)) || refTypes.some(t => types.has(t)),
maybe: () => !types.has('backlink') && !roles.has('doc-backlink')
&& (isSuper(a) || a.children.length === 1 && isSuper(a.children[0])
|| isSuper(a.parentElement)),
}
}
const getReferencedType = el => {
const types = getTypes(el)
const roles = getRoles(el)
return roles.has('doc-biblioentry') || types.has('biblioentry') ? 'biblioentry'
: roles.has('definition') || types.has('glossdef') ? 'definition'
: roles.has('doc-endnote') || types.has('endnote') || types.has('rearnote') ? 'endnote'
: roles.has('doc-footnote') || types.has('footnote') ? 'footnote'
: roles.has('note') || types.has('note') ? 'note' : null
}
const isInline = 'a, span, sup, sub, em, strong, i, b, small, big'
const extractFootnote = (doc, anchor) => {
let el = anchor(doc)
const target = el
while (el.matches(isInline)) {
const parent = el.parentElement
if (!parent) break
el = parent
}
if (el === doc.body) {
const sibling = target.nextElementSibling
if (sibling && !sibling.matches(isInline)) return sibling
throw new Error('Failed to extract footnote')
}
return el
}
export class FootnoteHandler extends EventTarget {
detectFootnotes = true
#showFragment(book, { index, anchor }, href) {
const view = document.createElement('foliate-view')
return new Promise((resolve, reject) => {
view.addEventListener('load', e => {
try {
const { doc } = e.detail
const el = anchor(doc)
const type = getReferencedType(el)
const hidden = el?.matches?.('aside') && type === 'footnote'
if (el) {
const range = el.startContainer ? el : doc.createRange()
if (!el.startContainer) {
if (el.matches('li, aside')) range.selectNodeContents(el)
else range.selectNode(el)
}
const frag = range.extractContents()
doc.body.replaceChildren()
doc.body.appendChild(frag)
}
const detail = { view, href, type, hidden, target: el }
this.dispatchEvent(new CustomEvent('render', { detail }))
resolve()
} catch (e) {
reject(e)
}
})
view.open(book)
.then(() => this.dispatchEvent(new CustomEvent('before-render', { detail: { view } })))
.then(() => view.goTo(index))
.catch(reject)
})
}
handle(book, e) {
const { a, href } = e.detail
const { yes, maybe } = isFootnoteReference(a)
if (yes) {
e.preventDefault()
return Promise.resolve(book.resolveHref(href)).then(target =>
this.#showFragment(book, target, href))
}
else if (this.detectFootnotes && maybe()) {
e.preventDefault()
return Promise.resolve(book.resolveHref(href)).then(({ index, anchor }) => {
const target = { index, anchor: doc => extractFootnote(doc, anchor) }
return this.#showFragment(book, target, href)
})
}
}
}

1238
static/vendor/foliate-js/mobi.js vendored Normal file

File diff suppressed because it is too large Load Diff

289
static/vendor/foliate-js/opds.js vendored Normal file
View File

@@ -0,0 +1,289 @@
const NS = {
ATOM: 'http://www.w3.org/2005/Atom',
OPDS: 'http://opds-spec.org/2010/catalog',
THR: 'http://purl.org/syndication/thread/1.0',
DC: 'http://purl.org/dc/elements/1.1/',
DCTERMS: 'http://purl.org/dc/terms/',
}
const MIME = {
ATOM: 'application/atom+xml',
OPDS2: 'application/opds+json',
}
export const REL = {
ACQ: 'http://opds-spec.org/acquisition',
FACET: 'http://opds-spec.org/facet',
GROUP: 'http://opds-spec.org/group',
COVER: [
'http://opds-spec.org/image',
'http://opds-spec.org/cover', // ManyBooks legacy, not in spec
'x-stanza-cover-image', // Lexcycle Stanza legacy
],
THUMBNAIL: [
'http://opds-spec.org/image/thumbnail',
'http://opds-spec.org/thumbnail', // ManyBooks legacy, not in spec
'x-stanza-cover-image-thumbnail', // Lexcycle Stanza legacy
],
}
export const SYMBOL = {
SUMMARY: Symbol('summary'),
CONTENT: Symbol('content'),
}
const FACET_GROUP = Symbol('facetGroup')
const groupByArray = (arr, f) => {
const map = new Map()
if (arr) for (const el of arr) {
const keys = f(el)
for (const key of [keys].flat()) {
const group = map.get(key)
if (group) group.push(el)
else map.set(key, [el])
}
}
return map
}
// https://www.rfc-editor.org/rfc/rfc7231#section-3.1.1
const parseMediaType = str => {
if (!str) return null
const [mediaType, ...ps] = str.split(/ *; */)
return {
mediaType: mediaType.toLowerCase(),
parameters: Object.fromEntries(ps.map(p => {
const [name, val] = p.split('=')
return [name.toLowerCase(), val?.replace(/(^"|"$)/g, '')]
})),
}
}
export const isOPDSCatalog = str => {
const parsed = parseMediaType(str)
if (!parsed) return false
const { mediaType, parameters } = parsed
if (mediaType === MIME.OPDS2) return true
return mediaType === MIME.ATOM && parameters.profile?.toLowerCase() === 'opds-catalog'
}
// ignore the namespace if it doesn't appear in document at all
const useNS = (doc, ns) =>
doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns) ? ns : null
const filterNS = ns => ns
? name => el => el.namespaceURI === ns && el.localName === name
: name => el => el.localName === name
const getContent = el => {
if (!el) return
const type = el.getAttribute('type') ?? 'text'
const value = type === 'xhtml' ? el.innerHTML
: type === 'html' ? el.textContent
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
: el.textContent
return { value, type }
}
const getTextContent = el => {
const content = getContent(el)
if (content?.type === 'text') return content?.value
}
const getSummary = (a, b) => getTextContent(a) ?? getTextContent(b)
const getPrice = link => {
const price = link.getElementsByTagNameNS(NS.OPDS, 'price')[0]
return price ? {
currency: price.getAttribute('currencycode'),
value: price.textContent,
} : null
}
const getIndirectAcquisition = el => {
const ia = el.getElementsByTagNameNS(NS.OPDS, 'indirectAcquisition')[0]
if (!ia) return []
return [{ type: ia.getAttribute('type') }, ...getIndirectAcquisition(ia)]
}
const getLink = link => {
const obj = {
rel: link.getAttribute('rel')?.split(/ +/),
href: link.getAttribute('href'),
type: link.getAttribute('type'),
title: link.getAttribute('title'),
properties: {
price: getPrice(link),
indirectAcquisition: getIndirectAcquisition(link),
numberOfItems: link.getAttributeNS(NS.THR, 'count'),
},
[FACET_GROUP]: link.getAttributeNS(NS.OPDS, 'facetGroup'),
}
if (link.getAttributeNS(NS.OPDS, 'activeFacet') === 'true')
obj.rel = [obj.rel ?? []].flat().concat('self')
return obj
}
const getPerson = person => {
const NS = person.namespaceURI
const uri = person.getElementsByTagNameNS(NS, 'uri')[0]?.textContent
return {
name: person.getElementsByTagNameNS(NS, 'name')[0]?.textContent ?? '',
links: uri ? [{ href: uri }] : [],
}
}
export const getPublication = entry => {
const filter = filterNS(useNS(entry.ownerDocument, NS.ATOM))
const children = Array.from(entry.children)
const filterDCEL = filterNS(NS.DC)
const filterDCTERMS = filterNS(NS.DCTERMS)
const filterDC = x => {
const a = filterDCEL(x), b = filterDCTERMS(x)
return y => a(y) || b(y)
}
const links = children.filter(filter('link')).map(getLink)
const linksByRel = groupByArray(links, link => link.rel)
return {
metadata: {
title: children.find(filter('title'))?.textContent ?? '',
author: children.filter(filter('author')).map(getPerson),
contributor: children.filter(filter('contributor')).map(getPerson),
publisher: children.find(filterDC('publisher'))?.textContent,
published: (children.find(filterDCTERMS('issued'))
?? children.find(filterDC('date')))?.textContent,
language: children.find(filterDC('language'))?.textContent,
identifier: children.find(filterDC('identifier'))?.textContent,
subject: children.filter(filter('category')).map(category => ({
name: category.getAttribute('label'),
code: category.getAttribute('term'),
scheme: category.getAttribute('scheme'),
})),
rights: children.find(filter('rights'))?.textContent ?? '',
[SYMBOL.CONTENT]: getContent(children.find(filter('content'))
?? children.find(filter('summary'))),
},
links,
images: REL.COVER.concat(REL.THUMBNAIL)
.map(R => linksByRel.get(R)?.[0]).filter(x => x),
}
}
export const getFeed = doc => {
const ns = useNS(doc, NS.ATOM)
const filter = filterNS(ns)
const children = Array.from(doc.documentElement.children)
const entries = children.filter(filter('entry'))
const links = children.filter(filter('link')).map(getLink)
const linksByRel = groupByArray(links, link => link.rel)
const groupedItems = new Map([[null, []]])
const groupLinkMap = new Map()
for (const entry of entries) {
const children = Array.from(entry.children)
const links = children.filter(filter('link')).map(getLink)
const linksByRel = groupByArray(links, link => link.rel)
const isPub = [...linksByRel.keys()]
.some(rel => rel?.startsWith(REL.ACQ) || rel === 'preview')
const groupLinks = linksByRel.get(REL.GROUP) ?? linksByRel.get('collection')
const groupLink = groupLinks?.length
? groupLinks.find(link => groupedItems.has(link.href)) ?? groupLinks[0] : null
if (groupLink && !groupLinkMap.has(groupLink.href))
groupLinkMap.set(groupLink.href, groupLink)
const item = isPub
? getPublication(entry)
: Object.assign(links.find(link => isOPDSCatalog(link.type)) ?? links[0] ?? {}, {
title: children.find(filter('title'))?.textContent,
[SYMBOL.SUMMARY]: getSummary(children.find(filter('summary')),
children.find(filter('content'))),
})
const arr = groupedItems.get(groupLink?.href ?? null)
if (arr) arr.push(item)
else groupedItems.set(groupLink.href, [item])
}
const [items, ...groups] = Array.from(groupedItems, ([key, items]) => {
const itemsKey = items[0]?.metadata ? 'publications' : 'navigation'
if (key == null) return { [itemsKey]: items }
const link = groupLinkMap.get(key)
return {
metadata: {
title: link.title,
numberOfItems: link.properties.numberOfItems,
},
links: [{ rel: 'self', href: link.href, type: link.type }],
[itemsKey]: items,
}
})
return {
metadata: {
title: children.find(filter('title'))?.textContent,
subtitle: children.find(filter('subtitle'))?.textContent,
},
links,
...items,
groups,
facets: Array.from(
groupByArray(linksByRel.get(REL.FACET) ?? [], link => link[FACET_GROUP]),
([facet, links]) => ({ metadata: { title: facet }, links })),
}
}
export const getSearch = async link => {
const { replace, getVariables } = await import('./uri-template.js')
return {
metadata: {
title: link.title,
},
search: map => replace(link.href, map.get(null)),
params: Array.from(getVariables(link.href), name => ({ name })),
}
}
export const getOpenSearch = doc => {
const defaultNS = doc.documentElement.namespaceURI
const filter = filterNS(defaultNS)
const children = Array.from(doc.documentElement.children)
const $$urls = children.filter(filter('Url'))
const $url = $$urls.find(url => isOPDSCatalog(url.getAttribute('type'))) ?? $$urls[0]
if (!$url) throw new Error('document must contain at least one Url element')
const regex = /{(?:([^}]+?):)?(.+?)(\?)?}/g
const defaultMap = new Map([
['count', '100'],
['startIndex', $url.getAttribute('indexOffset') ?? '0'],
['startPage', $url.getAttribute('pageOffset') ?? '0'],
['language', '*'],
['inputEncoding', 'UTF-8'],
['outputEncoding', 'UTF-8'],
])
const template = $url.getAttribute('template')
return {
metadata: {
title: (children.find(filter('LongName')) ?? children.find(filter('ShortName')))?.textContent,
description: children.find(filter('Description'))?.textContent,
},
search: map => template.replace(regex, (_, prefix, param) => {
const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null
const ns = namespace === defaultNS ? null : namespace
const val = map.get(ns)?.get(param)
return encodeURIComponent(val ? val : (!ns ? defaultMap.get(param) ?? '' : ''))
}),
params: Array.from(template.matchAll(regex), ([, prefix, param, optional]) => {
const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null
const ns = namespace === defaultNS ? null : namespace
return {
ns, name: param,
required: !optional,
value: ns && ns !== defaultNS ? '' : defaultMap.get(param) ?? '',
}
}),
}
}

175
static/vendor/foliate-js/overlayer.js vendored Normal file
View File

@@ -0,0 +1,175 @@
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',
})
}
get element() {
return this.#svg
}
add(key, range, draw, options) {
if (this.#map.has(key)) this.remove(key)
if (typeof range === 'function') range = range(this.#svg.getRootNode())
const rects = range.getClientRects()
const element = draw(rects, options)
this.#svg.append(element)
this.#map.set(key, { range, draw, options, element, rects })
}
remove(key) {
if (!this.#map.has(key)) return
this.#svg.removeChild(this.#map.get(key).element)
this.#map.delete(key)
}
redraw() {
for (const obj of this.#map.values()) {
const { range, draw, options, element } = obj
this.#svg.removeChild(element)
const rects = range.getClientRects()
const el = draw(rects, options)
this.#svg.append(el)
obj.element = el
obj.rects = rects
}
}
hitTest({ x, y }) {
const arr = Array.from(this.#map.entries())
// loop in reverse to hit more recently added items first
for (let i = arr.length - 1; i >= 0; i--) {
const [key, obj] = arr[i]
for (const { left, top, right, bottom } of obj.rects)
if (top <= y && left <= x && bottom > y && right > x)
return [key, obj.range]
}
return []
}
static underline(rects, options = {}) {
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
}
}

1130
static/vendor/foliate-js/paginator.js vendored Normal file

File diff suppressed because it is too large Load Diff

180
static/vendor/foliate-js/pdf.js vendored Normal file
View File

@@ -0,0 +1,180 @@
const pdfjsPath = path => new URL(`vendor/pdfjs/${path}`, import.meta.url).toString()
import './vendor/pdfjs/pdf.mjs'
const pdfjsLib = globalThis.pdfjsLib
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath('pdf.worker.mjs')
const fetchText = async url => await (await fetch(url)).text()
// https://raw.githubusercontent.com/mozilla/pdf.js/refs/tags/v5.5.207/web/text_layer_builder.css
const textLayerBuilderCSS = await fetchText(pdfjsPath('text_layer_builder.css'))
// https://raw.githubusercontent.com/mozilla/pdf.js/refs/tags/v5.5.207/web/annotation_layer_builder.css
const annotationLayerBuilderCSS = await fetchText(pdfjsPath('annotation_layer_builder.css'))
const render = async (page, doc, zoom) => {
const scale = zoom * devicePixelRatio
doc.documentElement.style.transform = `scale(${1 / devicePixelRatio})`
doc.documentElement.style.transformOrigin = 'top left'
doc.documentElement.style.setProperty('--scale-factor', scale)
const viewport = page.getViewport({ scale })
// the canvas must be in the `PDFDocument`'s `ownerDocument`
// (`globalThis.document` by default); that's where the fonts are loaded
const canvas = document.createElement('canvas')
canvas.height = viewport.height
canvas.width = viewport.width
const canvasContext = canvas.getContext('2d')
await page.render({ canvasContext, viewport }).promise
doc.querySelector('#canvas').replaceChildren(doc.adoptNode(canvas))
const container = doc.querySelector('.textLayer')
const textLayer = new pdfjsLib.TextLayer({
textContentSource: await page.streamTextContent(),
container, viewport,
})
await textLayer.render()
// hide "offscreen" canvases appended to docuemnt when rendering text layer
// https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/pdf_viewer.css#L51-L58
for (const canvas of document.querySelectorAll('.hiddenCanvasElement'))
Object.assign(canvas.style, {
position: 'absolute',
top: '0',
left: '0',
width: '0',
height: '0',
display: 'none',
})
// fix text selection
// https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/text_layer_builder.js#L105-L107
const endOfContent = document.createElement('div')
endOfContent.className = 'endOfContent'
container.append(endOfContent)
// TODO: this only works in Firefox; see https://github.com/mozilla/pdf.js/pull/17923
container.onpointerdown = () => container.classList.add('selecting')
container.onpointerup = () => container.classList.remove('selecting')
const div = doc.querySelector('.annotationLayer')
const linkService = {
goToDestination: () => {},
getDestinationHash: dest => JSON.stringify(dest),
addLinkAttributes: (link, url) => link.href = url,
}
await new pdfjsLib.AnnotationLayer({ page, viewport, div, linkService })
.render({ annotations: await page.getAnnotations() })
}
const renderPage = async (page, getImageBlob) => {
const viewport = page.getViewport({ scale: 1 })
if (getImageBlob) {
const canvas = document.createElement('canvas')
canvas.height = viewport.height
canvas.width = viewport.width
const canvasContext = canvas.getContext('2d')
await page.render({ canvasContext, viewport }).promise
return new Promise(resolve => canvas.toBlob(resolve))
}
const src = URL.createObjectURL(new Blob([`
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=${viewport.width}, height=${viewport.height}">
<style>
html, body {
margin: 0;
padding: 0;
}
/*
https://github.com/mozilla/pdf.js/commit/bd05b255fabfc313b194bfe9a17ccded4d90fb5a
*/
:root {
--user-unit: 1;
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
--scale-round-x: 1px;
--scale-round-y: 1px;
}
${textLayerBuilderCSS}
${annotationLayerBuilderCSS}
</style>
<div id="canvas"></div>
<div class="textLayer"></div>
<div class="annotationLayer"></div>
`], { type: 'text/html' }))
const onZoom = ({ doc, scale }) => render(page, doc, scale)
return { src, onZoom }
}
const makeTOCItem = item => ({
label: item.title,
href: JSON.stringify(item.dest),
subitems: item.items.length ? item.items.map(makeTOCItem) : null,
})
export const makePDF = async file => {
const transport = new pdfjsLib.PDFDataRangeTransport(file.size, [])
transport.requestDataRange = (begin, end) => {
file.slice(begin, end).arrayBuffer().then(chunk => {
transport.onDataRange(begin, chunk)
})
}
const pdf = await pdfjsLib.getDocument({
range: transport,
cMapUrl: pdfjsPath('cmaps/'),
standardFontDataUrl: pdfjsPath('standard_fonts/'),
isEvalSupported: false,
}).promise
const book = { rendition: { layout: 'pre-paginated' } }
const { metadata, info } = await pdf.getMetadata() ?? {}
// TODO: for better results, parse `metadata.getRaw()`
book.metadata = {
title: metadata?.get('dc:title') ?? info?.Title,
author: metadata?.get('dc:creator') ?? info?.Author,
contributor: metadata?.get('dc:contributor'),
description: metadata?.get('dc:description') ?? info?.Subject,
language: metadata?.get('dc:language'),
publisher: metadata?.get('dc:publisher'),
subject: metadata?.get('dc:subject'),
identifier: metadata?.get('dc:identifier'),
source: metadata?.get('dc:source'),
rights: metadata?.get('dc:rights'),
}
const outline = await pdf.getOutline()
book.toc = outline?.map(makeTOCItem)
const cache = new Map()
book.sections = Array.from({ length: pdf.numPages }).map((_, i) => ({
id: i,
load: async () => {
const cached = cache.get(i)
if (cached) return cached
const url = await renderPage(await pdf.getPage(i + 1))
cache.set(i, url)
return url
},
size: 1000,
}))
book.isExternal = uri => /^\w+:/i.test(uri)
book.resolveHref = async href => {
const parsed = JSON.parse(href)
const dest = typeof parsed === 'string'
? await pdf.getDestination(parsed) : parsed
const index = await pdf.getPageIndex(dest[0])
return { index }
}
book.splitTOCHref = async href => {
const parsed = JSON.parse(href)
const dest = typeof parsed === 'string'
? await pdf.getDestination(parsed) : parsed
const index = await pdf.getPageIndex(dest[0])
return [index, null]
}
book.getTOCFragment = doc => doc.documentElement
book.getCover = async () => renderPage(await pdf.getPage(1), true)
book.destroy = () => pdf.destroy()
return book
}

113
static/vendor/foliate-js/progress.js vendored Normal file
View File

@@ -0,0 +1,113 @@
// assign a unique ID for each TOC item
const assignIDs = toc => {
let id = 0
const assignID = item => {
item.id = id++
if (item.subitems) for (const subitem of item.subitems) assignID(subitem)
}
for (const item of toc) assignID(item)
return toc
}
const flatten = items => items
.map(item => item.subitems?.length
? [item, flatten(item.subitems)].flat()
: item)
.flat()
export class TOCProgress {
async init({ toc, ids, splitHref, getFragment }) {
assignIDs(toc)
const items = flatten(toc)
const grouped = new Map()
for (const [i, item] of items.entries()) {
const [id, fragment] = await splitHref(item?.href) ?? []
const value = { fragment, item }
if (grouped.has(id)) grouped.get(id).items.push(value)
else grouped.set(id, { prev: items[i - 1], items: [value] })
}
const map = new Map()
for (const [i, id] of ids.entries()) {
if (grouped.has(id)) map.set(id, grouped.get(id))
else map.set(id, map.get(ids[i - 1]))
}
this.ids = ids
this.map = map
this.getFragment = getFragment
}
getProgress(index, range) {
if (!this.ids) return
const id = this.ids[index]
const obj = this.map.get(id)
if (!obj) return null
const { prev, items } = obj
if (!items) return prev
if (!range || items.length === 1 && !items[0].fragment) return items[0].item
const doc = range.startContainer.getRootNode()
for (const [i, { fragment }] of items.entries()) {
const el = this.getFragment(doc, fragment)
if (!el) continue
if (range.comparePoint(el, 0) > 0)
return (items[i - 1]?.item ?? prev)
}
return items[items.length - 1].item
}
}
export class SectionProgress {
constructor(sections, sizePerLoc, sizePerTimeUnit) {
this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0)
this.sizePerLoc = sizePerLoc
this.sizePerTimeUnit = sizePerTimeUnit
this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0)
this.sectionFractions = this.#getSectionFractions()
}
#getSectionFractions() {
const { sizeTotal } = this
const results = [0]
let sum = 0
for (const size of this.sizes) results.push((sum += size) / sizeTotal)
return results
}
// get progress given index of and fractions within a section
getProgress(index, fractionInSection, pageFraction = 0) {
const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this
const sizeInSection = sizes[index] ?? 0
const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0)
const size = sizeBefore + fractionInSection * sizeInSection
const nextSize = size + pageFraction * sizeInSection
const remainingTotal = sizeTotal - size
const remainingSection = (1 - fractionInSection) * sizeInSection
return {
fraction: nextSize / sizeTotal,
section: {
current: index,
total: sizes.length,
},
location: {
current: Math.floor(size / sizePerLoc),
next: Math.floor(nextSize / sizePerLoc),
total: Math.ceil(sizeTotal / sizePerLoc),
},
time: {
section: remainingSection / sizePerTimeUnit,
total: remainingTotal / sizePerTimeUnit,
},
}
}
// the inverse of `getProgress`
// get index of and fraction in section based on total fraction
getSection(fraction) {
if (fraction <= 0) return [0, 0]
if (fraction >= 1) return [this.sizes.length - 1, 1]
fraction = fraction + Number.EPSILON
const { sizeTotal } = this
let index = this.sectionFractions.findIndex(x => x > fraction) - 1
if (index < 0) return [0, 0]
while (!this.sizes[index]) index++
const fractionInSection = (fraction - this.sectionFractions[index])
/ (this.sizes[index] / sizeTotal)
return [index, fractionInSection]
}
}

86
static/vendor/foliate-js/quote-image.js vendored Normal file
View File

@@ -0,0 +1,86 @@
const SVG_NS = 'http://www.w3.org/2000/svg'
// bisect
const fit = (el, a = 1, b = 50) => {
const c = Math.floor(a + (b - a) / 2)
el.style.fontSize = `${c}px`
if (b - a === 1) return
if (el.scrollHeight > el.clientHeight
|| el.scrollWidth > el.clientWidth) fit(el, a, c)
else fit(el, c, b)
}
const width = 540
const height = 540
const pixelRatio = 2
const html = `<style>
:host {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
}
</style>
<main style="width: ${width}px; height: ${height}px; overflow: hidden; display: flex; flex-direction: column; justify-content: center; text-align: center; background: #fff; color: #000; font: 16pt serif">
<style>
.ellipsis {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
}
</style>
<div style="font-size: min(4em, 8rem); line-height: 1; margin-bottom: -.5em">“</div>
<div id="text" style="margin: 1em; text-wrap: balance"></div>
<div style="margin: 0 1em">
<div style="font-size: min(.8em, 1.25rem); font-family: sans-serif">
<div style="display: block; font-weight: bold; margin-bottom: .25em">
<span id="author" class="ellipsis"></span>
</div>
<div style="display: block; text-wrap: balance">
<cite id="title" class="ellipsis"></cite>
</div>
</div>
</div>
<div style="height: 1em">&nbsp;</div>
</main>`
// TODO: lang, vertical writing
customElements.define('foliate-quoteimage', class extends HTMLElement {
#root = this.attachShadow({ mode: 'closed' })
constructor() {
super()
this.#root.innerHTML = html
}
async getBlob({ title, author, text }) {
this.#root.querySelector('#title').textContent = title
this.#root.querySelector('#author').textContent = author
this.#root.querySelector('#text').innerText = text
fit(this.#root.querySelector('main'))
const img = document.createElement('img')
return new Promise(resolve => {
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = pixelRatio * width
canvas.height = pixelRatio * height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
canvas.toBlob(resolve)
}
const doc = document.implementation.createDocument(SVG_NS, 'svg')
doc.documentElement.setAttribute('viewBox', `0 0 ${width} ${height}`)
const obj = doc.createElementNS(SVG_NS, 'foreignObject')
obj.setAttribute('width', width)
obj.setAttribute('height', height)
obj.append(doc.importNode(this.#root.querySelector('main'), true))
doc.documentElement.append(obj)
img.src = 'data:image/svg+xml;charset=utf-8,'
+ new XMLSerializer().serializeToString(doc)
})
}
})

130
static/vendor/foliate-js/search.js vendored Normal file
View File

@@ -0,0 +1,130 @@
// length for context in excerpts
const CONTEXT_LENGTH = 50
const normalizeWhitespace = str => str.replace(/\s+/g, ' ')
const makeExcerpt = (strs, { startIndex, startOffset, endIndex, endOffset }) => {
const start = strs[startIndex]
const end = strs[endIndex]
const match = start === end
? start.slice(startOffset, endOffset)
: start.slice(startOffset)
+ strs.slice(start + 1, end).join('')
+ end.slice(0, endOffset)
const trimmedStart = normalizeWhitespace(start.slice(0, startOffset)).trimStart()
const trimmedEnd = normalizeWhitespace(end.slice(endOffset)).trimEnd()
const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…'
const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…'
const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}`
const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}`
return { pre, match, post }
}
const simpleSearch = function* (strs, query, options = {}) {
const { locales = 'en', sensitivity } = options
const matchCase = sensitivity === 'variant'
const haystack = strs.join('')
const lowerHaystack = matchCase ? haystack : haystack.toLocaleLowerCase(locales)
const needle = matchCase ? query : query.toLocaleLowerCase(locales)
const needleLength = needle.length
let index = -1
let strIndex = -1
let sum = 0
do {
index = lowerHaystack.indexOf(needle, index + 1)
if (index > -1) {
while (sum <= index) sum += strs[++strIndex].length
const startIndex = strIndex
const startOffset = index - (sum - strs[strIndex].length)
const end = index + needleLength
while (sum <= end) sum += strs[++strIndex].length
const endIndex = strIndex
const endOffset = end - (sum - strs[strIndex].length)
const range = { startIndex, startOffset, endIndex, endOffset }
yield { range, excerpt: makeExcerpt(strs, range) }
}
} while (index > -1)
}
const segmenterSearch = function* (strs, query, options = {}) {
const { locales = 'en', granularity = 'word', sensitivity = 'base' } = options
let segmenter, collator
try {
segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity })
collator = new Intl.Collator(locales, { sensitivity })
} catch (e) {
console.warn(e)
segmenter = new Intl.Segmenter('en', { usage: 'search', granularity })
collator = new Intl.Collator('en', { sensitivity })
}
const queryLength = Array.from(segmenter.segment(query)).length
const substrArr = []
let strIndex = 0
let segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
main: while (strIndex < strs.length) {
while (substrArr.length < queryLength) {
const { done, value } = segments.next()
if (done) {
// the current string is exhausted
// move on to the next string
strIndex++
if (strIndex < strs.length) {
segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
continue
} else break main
}
const { index, segment } = value
// ignore formatting characters
if (!/[^\p{Format}]/u.test(segment)) continue
// normalize whitespace
if (/\s/u.test(segment)) {
if (!/\s/u.test(substrArr[substrArr.length - 1]?.segment))
substrArr.push({ strIndex, index, segment: ' ' })
continue
}
value.strIndex = strIndex
substrArr.push(value)
}
const substr = substrArr.map(x => x.segment).join('')
if (collator.compare(query, substr) === 0) {
const endIndex = strIndex
const lastSeg = substrArr[substrArr.length - 1]
const endOffset = lastSeg.index + lastSeg.segment.length
const startIndex = substrArr[0].strIndex
const startOffset = substrArr[0].index
const range = { startIndex, startOffset, endIndex, endOffset }
yield { range, excerpt: makeExcerpt(strs, range) }
}
substrArr.shift()
}
}
export const search = (strs, query, options) => {
const { granularity = 'grapheme', sensitivity = 'base' } = options
if (!Intl?.Segmenter || granularity === 'grapheme'
&& (sensitivity === 'variant' || sensitivity === 'accent'))
return simpleSearch(strs, query, options)
return segmenterSearch(strs, query, options)
}
export const searchMatcher = (textWalker, opts) => {
const { defaultLocale, matchCase, matchDiacritics, matchWholeWords, acceptNode } = opts
return function* (doc, query) {
const iter = textWalker(doc, function* (strs, makeRange) {
for (const result of search(strs, query, {
locales: doc.body.lang || doc.documentElement.lang || defaultLocale || 'en',
granularity: matchWholeWords ? 'word' : 'grapheme',
sensitivity: matchDiacritics && matchCase ? 'variant'
: matchDiacritics && !matchCase ? 'accent'
: !matchDiacritics && matchCase ? 'case'
: 'base',
})) {
const { startIndex, startOffset, endIndex, endOffset } = result.range
result.range = makeRange(startIndex, startOffset, endIndex, endOffset)
yield result
}
}, acceptNode)
for (const result of iter) yield result
}
}

43
static/vendor/foliate-js/text-walker.js vendored Normal file
View File

@@ -0,0 +1,43 @@
const walkRange = (range, walker) => {
const nodes = []
for (let node = walker.currentNode; node; node = walker.nextNode()) {
const compare = range.comparePoint(node, 0)
if (compare === 0) nodes.push(node)
else if (compare > 0) break
}
return nodes
}
const walkDocument = (_, walker) => {
const nodes = []
for (let node = walker.nextNode(); node; node = walker.nextNode())
nodes.push(node)
return nodes
}
const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
| NodeFilter.SHOW_CDATA_SECTION
const acceptNode = node => {
if (node.nodeType === 1) {
const name = node.tagName.toLowerCase()
if (name === 'script' || name === 'style') return NodeFilter.FILTER_REJECT
return NodeFilter.FILTER_SKIP
}
return NodeFilter.FILTER_ACCEPT
}
export const textWalker = function* (x, func, filterFunc) {
const root = x.commonAncestorContainer ?? x.body ?? x
const walker = document.createTreeWalker(root, filter, { acceptNode: filterFunc || acceptNode })
const walk = x.commonAncestorContainer ? walkRange : walkDocument
const nodes = walk(x, walker)
const strs = nodes.map(node => node.nodeValue ?? '')
const makeRange = (startIndex, startOffset, endIndex, endOffset) => {
const range = document.createRange()
range.setStart(nodes[startIndex], startOffset)
range.setEnd(nodes[endIndex], endOffset)
return range
}
for (const match of func(strs, makeRange)) yield match
}

278
static/vendor/foliate-js/tts.js vendored Normal file
View File

@@ -0,0 +1,278 @@
const NS = {
XML: 'http://www.w3.org/XML/1998/namespace',
SSML: 'http://www.w3.org/2001/10/synthesis',
}
const blockTags = new Set([
'article', 'aside', 'audio', 'blockquote', 'caption',
'details', 'dialog', 'div', 'dl', 'dt', 'dd',
'figure', 'footer', 'form', 'figcaption',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li',
'main', 'math', 'nav', 'ol', 'p', 'pre', 'section', 'tr',
])
const getLang = el => {
const x = el.lang || el?.getAttributeNS?.(NS.XML, 'lang')
return x ? x : el.parentElement ? getLang(el.parentElement) : null
}
const getAlphabet = el => {
const x = el?.getAttributeNS?.(NS.XML, 'lang')
return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null
}
const getSegmenter = (lang = 'en', granularity = 'word') => {
const segmenter = new Intl.Segmenter(lang, { granularity })
const granularityIsWord = granularity === 'word'
return function* (strs, makeRange) {
const str = strs.join('')
let name = 0
let strIndex = -1
let sum = 0
for (const { index, segment, isWordLike } of segmenter.segment(str)) {
if (granularityIsWord && !isWordLike) continue
while (sum <= index) sum += strs[++strIndex].length
const startIndex = strIndex
const startOffset = index - (sum - strs[strIndex].length)
const end = index + segment.length - 1
if (end < str.length) while (sum <= end) sum += strs[++strIndex].length
const endIndex = strIndex
const endOffset = end - (sum - strs[strIndex].length) + 1
yield [(name++).toString(),
makeRange(startIndex, startOffset, endIndex, endOffset)]
}
}
}
const fragmentToSSML = (fragment, inherited) => {
const ssml = document.implementation.createDocument(NS.SSML, 'speak')
const { lang } = inherited
if (lang) ssml.documentElement.setAttributeNS(NS.XML, 'lang', lang)
const convert = (node, parent, inheritedAlphabet) => {
if (!node) return
if (node.nodeType === 3) return ssml.createTextNode(node.textContent)
if (node.nodeType === 4) return ssml.createCDATASection(node.textContent)
if (node.nodeType !== 1 && node.nodeType !== 11) return
let el
const nodeName = node.nodeName.toLowerCase()
if (nodeName === 'foliate-mark') {
el = ssml.createElementNS(NS.SSML, 'mark')
el.setAttribute('name', node.dataset.name)
}
else if (nodeName === 'br')
el = ssml.createElementNS(NS.SSML, 'break')
else if (nodeName === 'em' || nodeName === 'strong')
el = ssml.createElementNS(NS.SSML, 'emphasis')
const lang = node.lang || node.getAttributeNS?.(NS.XML, 'lang')
if (lang) {
if (!el) el = ssml.createElementNS(NS.SSML, 'lang')
el.setAttributeNS(NS.XML, 'lang', lang)
}
const alphabet = node.getAttributeNS?.(NS.SSML, 'alphabet') || inheritedAlphabet
if (!el) {
const ph = node.getAttributeNS?.(NS.SSML, 'ph')
if (ph) {
el = ssml.createElementNS(NS.SSML, 'phoneme')
if (alphabet) el.setAttribute('alphabet', alphabet)
el.setAttribute('ph', ph)
}
}
if (!el) el = parent
let child = node.firstChild
while (child) {
const childEl = convert(child, el, alphabet)
if (childEl && el !== childEl) el.append(childEl)
child = child.nextSibling
}
return el
}
convert(fragment, ssml.documentElement, inherited.alphabet)
return ssml
}
const getFragmentWithMarks = (range, textWalker, granularity) => {
const lang = getLang(range.commonAncestorContainer)
const alphabet = getAlphabet(range.commonAncestorContainer)
const segmenter = getSegmenter(lang, granularity)
const fragment = range.cloneContents()
// we need ranges on both the original document (for highlighting)
// and the document fragment (for inserting marks)
// so unfortunately need to do it twice, as you can't copy the ranges
const entries = [...textWalker(range, segmenter)]
const fragmentEntries = [...textWalker(fragment, segmenter)]
for (const [name, range] of fragmentEntries) {
const mark = document.createElement('foliate-mark')
mark.dataset.name = name
range.insertNode(mark)
}
const ssml = fragmentToSSML(fragment, { lang, alphabet })
return { entries, ssml }
}
const rangeIsEmpty = range => !range.toString().trim()
function* getBlocks(doc) {
let last
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const name = node.tagName.toLowerCase()
if (blockTags.has(name)) {
if (last) {
last.setEndBefore(node)
if (!rangeIsEmpty(last)) yield last
}
last = doc.createRange()
last.setStart(node, 0)
}
}
if (!last) {
last = doc.createRange()
last.setStart(doc.body.firstChild ?? doc.body, 0)
}
last.setEndAfter(doc.body.lastChild ?? doc.body)
if (!rangeIsEmpty(last)) yield last
}
class ListIterator {
#arr = []
#iter
#index = -1
#f
constructor(iter, f = x => x) {
this.#iter = iter
this.#f = f
}
current() {
if (this.#arr[this.#index]) return this.#f(this.#arr[this.#index])
}
first() {
const newIndex = 0
if (this.#arr[newIndex]) {
this.#index = newIndex
return this.#f(this.#arr[newIndex])
}
}
prev() {
const newIndex = this.#index - 1
if (this.#arr[newIndex]) {
this.#index = newIndex
return this.#f(this.#arr[newIndex])
}
}
next() {
const newIndex = this.#index + 1
if (this.#arr[newIndex]) {
this.#index = newIndex
return this.#f(this.#arr[newIndex])
}
while (true) {
const { done, value } = this.#iter.next()
if (done) break
this.#arr.push(value)
if (this.#arr[newIndex]) {
this.#index = newIndex
return this.#f(this.#arr[newIndex])
}
}
}
find(f) {
const index = this.#arr.findIndex(x => f(x))
if (index > -1) {
this.#index = index
return this.#f(this.#arr[index])
}
while (true) {
const { done, value } = this.#iter.next()
if (done) break
this.#arr.push(value)
if (f(value)) {
this.#index = this.#arr.length - 1
return this.#f(value)
}
}
}
}
export class TTS {
#list
#ranges
#lastMark
#serializer = new XMLSerializer()
constructor(doc, textWalker, highlight, granularity) {
this.doc = doc
this.highlight = highlight
this.#list = new ListIterator(getBlocks(doc), range => {
const { entries, ssml } = getFragmentWithMarks(range, textWalker, granularity)
this.#ranges = new Map(entries)
return [ssml, range]
})
}
#getMarkElement(doc, mark) {
if (!mark) return null
return doc.querySelector(`mark[name="${CSS.escape(mark)}"`)
}
#speak(doc, getNode) {
if (!doc) return
if (!getNode) return this.#serializer.serializeToString(doc)
const ssml = document.implementation.createDocument(NS.SSML, 'speak')
ssml.documentElement.replaceWith(ssml.importNode(doc.documentElement, true))
let node = getNode(ssml)?.previousSibling
while (node) {
const next = node.previousSibling ?? node.parentNode?.previousSibling
node.parentNode.removeChild(node)
node = next
}
return this.#serializer.serializeToString(ssml)
}
start() {
this.#lastMark = null
const [doc] = this.#list.first() ?? []
if (!doc) return this.next()
return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark))
}
resume() {
const [doc] = this.#list.current() ?? []
if (!doc) return this.next()
return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark))
}
prev(paused) {
this.#lastMark = null
const [doc, range] = this.#list.prev() ?? []
if (paused && range) this.highlight(range.cloneRange())
return this.#speak(doc)
}
next(paused) {
this.#lastMark = null
const [doc, range] = this.#list.next() ?? []
if (paused && range) this.highlight(range.cloneRange())
return this.#speak(doc)
}
from(range) {
this.#lastMark = null
const [doc] = this.#list.find(range_ =>
range.compareBoundaryPoints(Range.END_TO_START, range_) <= 0)
let mark
for (const [name, range_] of this.#ranges.entries())
if (range.compareBoundaryPoints(Range.START_TO_START, range_) <= 0) {
mark = name
break
}
return this.#speak(doc, ssml => this.#getMarkElement(ssml, mark))
}
setMark(mark) {
const range = this.#ranges.get(mark)
if (range) {
this.#lastMark = mark
this.highlight(range.cloneRange())
}
}
}

42
static/vendor/foliate-js/ui/menu.js vendored Normal file
View File

@@ -0,0 +1,42 @@
const createMenuItemRadioGroup = (label, arr, onclick) => {
const group = document.createElement('ul')
group.setAttribute('role', 'group')
group.setAttribute('aria-label', label)
const map = new Map()
const select = value => {
onclick(value)
const item = map.get(value)
for (const child of group.children)
child.setAttribute('aria-checked', child === item ? 'true' : 'false')
}
for (const [label, value] of arr) {
const item = document.createElement('li')
item.setAttribute('role', 'menuitemradio')
item.innerText = label
item.onclick = () => select(value)
map.set(value, item)
group.append(item)
}
return { element: group, select }
}
export const createMenu = arr => {
const groups = {}
const element = document.createElement('ul')
element.setAttribute('role', 'menu')
const hide = () => element.classList.remove('show')
const hideAnd = f => (...args) => (hide(), f(...args))
for (const { name, label, type, items, onclick } of arr) {
const widget = type === 'radio'
? createMenuItemRadioGroup(label, items, hideAnd(onclick))
: null
if (name) groups[name] = widget
element.append(widget.element)
}
// TODO: keyboard events
window.addEventListener('blur', () => hide())
window.addEventListener('click', e => {
if (!element.parentNode.contains(e.target)) hide()
})
return { element, groups }
}

169
static/vendor/foliate-js/ui/tree.js vendored Normal file
View File

@@ -0,0 +1,169 @@
const createSVGElement = tag =>
document.createElementNS('http://www.w3.org/2000/svg', tag)
const createExpanderIcon = () => {
const svg = createSVGElement('svg')
svg.setAttribute('viewBox', '0 0 13 10')
svg.setAttribute('width', '13')
svg.setAttribute('height', '13')
const polygon = createSVGElement('polygon')
polygon.setAttribute('points', '2 1, 12 1, 7 9')
svg.append(polygon)
return svg
}
const createTOCItemElement = (list, map, onclick) => {
let count = 0
const makeID = () => `toc-element-${count++}`
const createItem = ({ label, href, subitems }, depth = 0) => {
const a = document.createElement(href ? 'a' : 'span')
a.innerText = label
a.setAttribute('role', 'treeitem')
a.tabIndex = -1
a.style.paddingInlineStart = `${(depth + 1) * 24}px`
list.push(a)
if (href) {
if (!map.has(href)) map.set(href, a)
a.href = href
a.onclick = event => {
event.preventDefault()
onclick(href)
}
} else a.onclick = event => a.firstElementChild?.onclick(event)
const li = document.createElement('li')
li.setAttribute('role', 'none')
li.append(a)
if (subitems?.length) {
a.setAttribute('aria-expanded', 'false')
const expander = createExpanderIcon()
expander.onclick = event => {
event.preventDefault()
event.stopPropagation()
const expanded = a.getAttribute('aria-expanded')
a.setAttribute('aria-expanded', expanded === 'true' ? 'false' : 'true')
}
a.prepend(expander)
const ol = document.createElement('ol')
ol.id = makeID()
ol.setAttribute('role', 'group')
a.setAttribute('aria-owns', ol.id)
ol.replaceChildren(...subitems.map(item => createItem(item, depth + 1)))
li.append(ol)
}
return li
}
return createItem
}
// https://www.w3.org/TR/wai-aria-practices-1.2/examples/treeview/treeview-navigation.html
export const createTOCView = (toc, onclick) => {
const $toc = document.createElement('ol')
$toc.setAttribute('role', 'tree')
const list = []
const map = new Map()
const createItem = createTOCItemElement(list, map, onclick)
$toc.replaceChildren(...toc.map(item => createItem(item)))
const isTreeItem = item => item?.getAttribute('role') === 'treeitem'
const getParents = function* (el) {
for (let parent = el.parentNode; parent !== $toc; parent = parent.parentNode) {
const item = parent.previousElementSibling
if (isTreeItem(item)) yield item
}
}
let currentItem, currentVisibleParent
$toc.addEventListener('focusout', () => {
if (!currentItem) return
// reset parent focus from last time
if (currentVisibleParent) currentVisibleParent.tabIndex = -1
// if current item is visible, let it have the focus
if (currentItem.offsetParent) {
currentItem.tabIndex = 0
return
}
// current item is hidden; give focus to the nearest visible parent
for (const item of getParents(currentItem)) {
if (item.offsetParent) {
item.tabIndex = 0
currentVisibleParent = item
break
}
}
})
const setCurrentHref = href => {
if (currentItem) {
currentItem.removeAttribute('aria-current')
currentItem.tabIndex = -1
}
const el = map.get(href)
if (!el) {
currentItem = list[0]
currentItem.tabIndex = 0
return
}
for (const item of getParents(el))
item.setAttribute('aria-expanded', 'true')
el.setAttribute('aria-current', 'page')
el.tabIndex = 0
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
currentItem = el
}
const acceptNode = node => isTreeItem(node) && node.offsetParent
? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
const iter = document.createTreeWalker($toc, 1, { acceptNode })
const getIter = current => (iter.currentNode = current, iter)
for (const el of list) el.addEventListener('keydown', event => {
let stop = false
const { currentTarget, key } = event
switch (key) {
case ' ':
case 'Enter':
currentTarget.click()
stop = true
break
case 'ArrowDown':
getIter(currentTarget).nextNode()?.focus()
stop = true
break
case 'ArrowUp':
getIter(currentTarget).previousNode()?.focus()
stop = true
break
case 'ArrowLeft':
if (currentTarget.getAttribute('aria-expanded') === 'true')
currentTarget.setAttribute('aria-expanded', 'false')
else getParents(currentTarget).next()?.value?.focus()
stop = true
break
case 'ArrowRight':
if (currentTarget.getAttribute('aria-expanded') === 'true')
getIter(currentTarget).nextNode()?.focus()
else if (currentTarget.getAttribute('aria-owns'))
currentTarget.setAttribute('aria-expanded', 'true')
stop = true
break
case 'Home':
list[0].focus()
stop = true
break
case 'End': {
const last = list[list.length - 1]
if (last.offsetParent) last.focus()
else getIter(last).previousNode()?.focus()
stop = true
break
}
}
if (stop) {
event.preventDefault()
event.stopPropagation()
}
})
return { element: $toc, setCurrentHref }
}

View File

@@ -0,0 +1,52 @@
// URI Template: https://datatracker.ietf.org/doc/html/rfc6570
const regex = /{([+#./;?&])?([^}]+?)}/g
const varspecRegex = /(.+?)(\*|:[1-9]\d{0,3})?$/
const table = {
undefined: { first: '', sep: ',' },
'+': { first: '', sep: ',', allowReserved: true },
'.': { first: '.', sep: '.' },
'/': { first: '/', sep: '/' },
';': { first: ';', sep: ';', named: true, ifemp: '' },
'?': { first: '?', sep: '&', named: true, ifemp: '=' },
'&': { first: '&', sep: '&', named: true, ifemp: '=' },
'#': { first: '&', sep: '&', allowReserved: true },
}
// 2.4.1 Prefix Values, "Note that this numbering is in characters, not octets"
const prefix = (maxLength, str) => {
let result = ''
for (const char of str) {
const newResult = char
if (newResult.length > maxLength) return result
else result = newResult
}
return result
}
export const replace = (str, map) => str.replace(regex, (_, operator, variableList) => {
const { first, sep, named, ifemp, allowReserved } = table[operator]
// TODO: this isn't spec compliant
const encode = allowReserved ? encodeURI : encodeURIComponent
const values = variableList.split(',').map(varspec => {
const match = varspec.match(varspecRegex)
if (!match) return
const [, name, modifier] = match
let value = map.get(name)
if (modifier?.startsWith(':')) {
const maxLength = parseInt(modifier.slice(1))
value = prefix(maxLength, value)
}
return [name, value ? encode(value) : null]
})
if (!values.filter(([, value]) => value).length) return ''
return first + values
.map(([name, value]) => value
? (named ? name + (value ? '=' + value : ifemp) : value) : '')
.filter(x => x).join(sep)
})
export const getVariables = str => new Set(Array.from(str.matchAll(regex),
([,, variableList]) => variableList.split(',')
.map(varspec => varspec.match(varspecRegex)?.[1])).flat())

View File

@@ -0,0 +1 @@
var r=Uint8Array,n=Uint16Array,a=Int32Array,e=new r([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),i=new r([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),t=new r([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),f=function(r,e){for(var i=new n(31),t=0;t<31;++t)i[t]=e+=1<<r[t-1];var f=new a(i[30]);for(t=1;t<30;++t)for(var o=i[t];o<i[t+1];++o)f[o]=o-i[t]<<5|t;return{b:i,r:f}},o=f(e,2),v=o.b,l=o.r;v[28]=258,l[258]=28;for(var u=f(i,0).b,c=new n(32768),d=0;d<32768;++d){var w=(43690&d)>>1|(21845&d)<<1;w=(61680&(w=(52428&w)>>2|(13107&w)<<2))>>4|(3855&w)<<4,c[d]=((65280&w)>>8|(255&w)<<8)>>1}var b=function(r,a,e){for(var i=r.length,t=0,f=new n(a);t<i;++t)r[t]&&++f[r[t]-1];var o,v=new n(a);for(t=1;t<a;++t)v[t]=v[t-1]+f[t-1]<<1;if(e){o=new n(1<<a);var l=15-a;for(t=0;t<i;++t)if(r[t])for(var u=t<<4|r[t],d=a-r[t],w=v[r[t]-1]++<<d,b=w|(1<<d)-1;w<=b;++w)o[c[w]>>l]=u}else for(o=new n(i),t=0;t<i;++t)r[t]&&(o[t]=c[v[r[t]-1]++]>>15-r[t]);return o},s=new r(288);for(d=0;d<144;++d)s[d]=8;for(d=144;d<256;++d)s[d]=9;for(d=256;d<280;++d)s[d]=7;for(d=280;d<288;++d)s[d]=8;var h=new r(32);for(d=0;d<32;++d)h[d]=5;var y=b(s,9,1),g=b(h,5,1),p=function(r){for(var n=r[0],a=1;a<r.length;++a)r[a]>n&&(n=r[a]);return n},k=function(r,n,a){var e=n/8|0;return(r[e]|r[e+1]<<8)>>(7&n)&a},m=function(r,n){var a=n/8|0;return(r[a]|r[a+1]<<8|r[a+2]<<16)>>(7&n)},x=function(r){return(r+7)/8|0},T=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],E=function(r,n,a){var e=new Error(n||T[r]);if(e.code=r,Error.captureStackTrace&&Error.captureStackTrace(e,E),!a)throw e;return e},z=function(n,a,f,o){var l=n.length,c=o?o.length:0;if(!l||a.f&&!a.l)return f||new r(0);var d=!f,w=d||2!=a.i,s=a.i;d&&(f=new r(3*l));var h=function(n){var a=f.length;if(n>a){var e=new r(Math.max(2*a,n));e.set(f),f=e}},T=a.f||0,z=a.p||0,A=a.b||0,U=a.l,D=a.d,F=a.m,M=a.n,S=8*l;do{if(!U){T=k(n,z,1);var I=k(n,z+1,3);if(z+=3,!I){var O=n[(P=x(z)+4)-4]|n[P-3]<<8,j=P+O;if(j>l){s&&E(0);break}w&&h(A+O),f.set(n.subarray(P,j),A),a.b=A+=O,a.p=z=8*j,a.f=T;continue}if(1==I)U=y,D=g,F=9,M=5;else if(2==I){var q=k(n,z,31)+257,B=k(n,z+10,15)+4,C=q+k(n,z+5,31)+1;z+=14;for(var G=new r(C),H=new r(19),J=0;J<B;++J)H[t[J]]=k(n,z+3*J,7);z+=3*B;var K=p(H),L=(1<<K)-1,N=b(H,K,1);for(J=0;J<C;){var P,Q=N[k(n,z,L)];if(z+=15&Q,(P=Q>>4)<16)G[J++]=P;else{var R=0,V=0;for(16==P?(V=3+k(n,z,3),z+=2,R=G[J-1]):17==P?(V=3+k(n,z,7),z+=3):18==P&&(V=11+k(n,z,127),z+=7);V--;)G[J++]=R}}var W=G.subarray(0,q),X=G.subarray(q);F=p(W),M=p(X),U=b(W,F,1),D=b(X,M,1)}else E(1);if(z>S){s&&E(0);break}}w&&h(A+131072);for(var Y=(1<<F)-1,Z=(1<<M)-1,$=z;;$=z){var _=(R=U[m(n,z)&Y])>>4;if((z+=15&R)>S){s&&E(0);break}if(R||E(2),_<256)f[A++]=_;else{if(256==_){$=z,U=null;break}var rr=_-254;if(_>264){var nr=e[J=_-257];rr=k(n,z,(1<<nr)-1)+v[J],z+=nr}var ar=D[m(n,z)&Z],er=ar>>4;ar||E(3),z+=15&ar;X=u[er];if(er>3){nr=i[er];X+=m(n,z)&(1<<nr)-1,z+=nr}if(z>S){s&&E(0);break}w&&h(A+131072);var ir=A+rr;if(A<X){var tr=c-X,fr=Math.min(X,ir);for(tr+A<0&&E(3);A<fr;++A)f[A]=o[tr+A]}for(;A<ir;++A)f[A]=f[A-X]}}a.l=U,a.p=$,a.b=A,a.f=T,U&&(T=1,a.m=F,a.d=D,a.n=M)}while(!T);return A!=f.length&&d?function(n,a,e){return(null==e||e>n.length)&&(e=n.length),new r(n.subarray(a,e))}(f,0,A):f.subarray(0,A)},A=new r(0);function U(r,n){return z(r.subarray((a=r,e=n&&n.dictionary,(8!=(15&a[0])||a[0]>>4>7||(a[0]<<8|a[1])%31)&&E(6,"invalid zlib data"),(a[1]>>5&1)==+!e&&E(6,"invalid zlib data: "+(32&a[1]?"need":"unexpected")+" dictionary"),2+(a[1]>>3&4)),-4),{i:2},n&&n.out,n&&n.dictionary);var a,e}var D="undefined"!=typeof TextDecoder&&new TextDecoder;try{D.decode(A,{stream:!0})}catch(r){}export{U as unzlibSync};

File diff suppressed because one or more lines are too long

597
static/vendor/foliate-js/view.js vendored Normal file
View File

@@ -0,0 +1,597 @@
import * as CFI from './epubcfi.js'
import { TOCProgress, SectionProgress } from './progress.js'
import { Overlayer } from './overlayer.js'
import { textWalker } from './text-walker.js'
const SEARCH_PREFIX = 'foliate-search:'
const isZip = async file => {
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
}
const isPDF = async file => {
const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer())
return arr[0] === 0x25
&& arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46
&& arr[4] === 0x2d
}
const isCBZ = ({ name, type }) =>
type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz')
const isFB2 = ({ name, type }) =>
type === 'application/x-fictionbook+xml' || name.endsWith('.fb2')
const isFBZ = ({ name, type }) =>
type === 'application/x-zip-compressed-fb2'
|| name.endsWith('.fb2.zip') || name.endsWith('.fbz')
const makeZipLoader = async file => {
const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } =
await import('./vendor/zip.js')
configure({ useWebWorkers: false })
const reader = new ZipReader(new BlobReader(file))
const entries = await reader.getEntries()
const map = new Map(entries.map(entry => [entry.filename, entry]))
const load = f => (name, ...args) =>
map.has(name) ? f(map.get(name), ...args) : null
const loadText = load(entry => entry.getData(new TextWriter()))
const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type)))
const getSize = name => map.get(name)?.uncompressedSize ?? 0
return { entries, loadText, loadBlob, getSize }
}
const getFileEntries = async entry => entry.isFile ? entry
: (await Promise.all(Array.from(
await new Promise((resolve, reject) => entry.createReader()
.readEntries(entries => resolve(entries), error => reject(error))),
getFileEntries))).flat()
const makeDirectoryLoader = async entry => {
const entries = await getFileEntries(entry)
const files = await Promise.all(
entries.map(entry => new Promise((resolve, reject) =>
entry.file(file => resolve([file, entry.fullPath]),
error => reject(error)))))
const map = new Map(files.map(([file, path]) =>
[path.replace(entry.fullPath + '/', ''), file]))
const decoder = new TextDecoder()
const decode = x => x ? decoder.decode(x) : null
const getBuffer = name => map.get(name)?.arrayBuffer() ?? null
const loadText = async name => decode(await getBuffer(name))
const loadBlob = name => map.get(name)
const getSize = name => map.get(name)?.size ?? 0
return { loadText, loadBlob, getSize }
}
export class ResponseError extends Error {}
export class NotFoundError extends Error {}
export class UnsupportedTypeError extends Error {}
const fetchFile = async url => {
const res = await fetch(url)
if (!res.ok) throw new ResponseError(
`${res.status} ${res.statusText}`, { cause: res })
return new File([await res.blob()], new URL(res.url).pathname)
}
export const makeBook = async file => {
if (typeof file === 'string') file = await fetchFile(file)
let book
if (file.isDirectory) {
const loader = await makeDirectoryLoader(file)
const { EPUB } = await import('./epub.js')
book = await new EPUB(loader).init()
}
else if (!file.size) throw new NotFoundError('File not found')
else if (await isZip(file)) {
const loader = await makeZipLoader(file)
if (isCBZ(file)) {
const { makeComicBook } = await import('./comic-book.js')
book = makeComicBook(loader, file)
}
else if (isFBZ(file)) {
const { makeFB2 } = await import('./fb2.js')
const { entries } = loader
const entry = entries.find(entry => entry.filename.endsWith('.fb2'))
const blob = await loader.loadBlob((entry ?? entries[0]).filename)
book = await makeFB2(blob)
}
else {
const { EPUB } = await import('./epub.js')
book = await new EPUB(loader).init()
}
}
else if (await isPDF(file)) {
const { makePDF } = await import('./pdf.js')
book = await makePDF(file)
}
else {
const { isMOBI, MOBI } = await import('./mobi.js')
if (await isMOBI(file)) {
const fflate = await import('./vendor/fflate.js')
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file)
}
else if (isFB2(file)) {
const { makeFB2 } = await import('./fb2.js')
book = await makeFB2(file)
}
}
if (!book) throw new UnsupportedTypeError('File type not supported')
return book
}
class CursorAutohider {
#timeout
#el
#check
#state
constructor(el, check, state = {}) {
this.#el = el
this.#check = check
this.#state = state
if (this.#state.hidden) this.hide()
this.#el.addEventListener('mousemove', ({ screenX, screenY }) => {
// check if it actually moved
if (screenX === this.#state.x && screenY === this.#state.y) return
this.#state.x = screenX, this.#state.y = screenY
this.show()
if (this.#timeout) clearTimeout(this.#timeout)
if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000)
}, false)
}
cloneFor(el) {
return new CursorAutohider(el, this.#check, this.#state)
}
hide() {
this.#el.style.cursor = 'none'
this.#state.hidden = true
}
show() {
this.#el.style.removeProperty('cursor')
this.#state.hidden = false
}
}
class History extends EventTarget {
#arr = []
#index = -1
pushState(x) {
const last = this.#arr[this.#index]
if (last === x || last?.fraction && last.fraction === x.fraction) return
this.#arr[++this.#index] = x
this.#arr.length = this.#index + 1
this.dispatchEvent(new Event('index-change'))
}
replaceState(x) {
const index = this.#index
this.#arr[index] = x
}
back() {
const index = this.#index
if (index <= 0) return
const detail = { state: this.#arr[index - 1] }
this.#index = index - 1
this.dispatchEvent(new CustomEvent('popstate', { detail }))
this.dispatchEvent(new Event('index-change'))
}
forward() {
const index = this.#index
if (index >= this.#arr.length - 1) return
const detail = { state: this.#arr[index + 1] }
this.#index = index + 1
this.dispatchEvent(new CustomEvent('popstate', { detail }))
this.dispatchEvent(new Event('index-change'))
}
get canGoBack() {
return this.#index > 0
}
get canGoForward() {
return this.#index < this.#arr.length - 1
}
clear() {
this.#arr = []
this.#index = -1
}
}
const languageInfo = lang => {
if (!lang) return {}
try {
const canonical = Intl.getCanonicalLocales(lang)[0]
const locale = new Intl.Locale(canonical)
const isCJK = ['zh', 'ja', 'kr'].includes(locale.language)
const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction
return { canonical, locale, isCJK, direction }
} catch (e) {
console.warn(e)
return {}
}
}
export class View extends HTMLElement {
#root = this.attachShadow({ mode: 'closed' })
#sectionProgress
#tocProgress
#pageProgress
#searchResults = new Map()
#searchDraw
#searchDrawOptions
#cursorAutohider = new CursorAutohider(this, () =>
this.hasAttribute('autohide-cursor'))
isFixedLayout = false
lastLocation
history = new History()
constructor() {
super()
this.history.addEventListener('popstate', ({ detail }) => {
const resolved = this.resolveNavigation(detail.state)
this.renderer.goTo(resolved)
})
}
async open(book) {
if (typeof book === 'string'
|| typeof book.arrayBuffer === 'function'
|| book.isDirectory) book = await makeBook(book)
this.book = book
this.language = languageInfo(book.metadata?.language)
if (book.splitTOCHref && book.getTOCFragment) {
const ids = book.sections.map(s => s.id)
this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600)
const splitHref = book.splitTOCHref.bind(book)
const getFragment = book.getTOCFragment.bind(book)
this.#tocProgress = new TOCProgress()
await this.#tocProgress.init({
toc: book.toc ?? [], ids, splitHref, getFragment })
this.#pageProgress = new TOCProgress()
await this.#pageProgress.init({
toc: book.pageList ?? [], ids, splitHref, getFragment })
}
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
if (this.isFixedLayout) {
await import('./fixed-layout.js')
this.renderer = document.createElement('foliate-fxl')
} else {
await import('./paginator.js')
this.renderer = document.createElement('foliate-paginator')
}
this.renderer.setAttribute('exportparts', 'head,foot,filter')
this.renderer.addEventListener('load', e => this.#onLoad(e.detail))
this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail))
this.renderer.addEventListener('create-overlayer', e =>
e.detail.attach(this.#createOverlayer(e.detail)))
this.renderer.open(book)
this.#root.append(this.renderer)
if (book.sections.some(section => section.mediaOverlay)) {
const activeClass = book.media.activeClass
const playbackActiveClass = book.media.playbackActiveClass
this.mediaOverlay = book.getMediaOverlay()
let lastActive
this.mediaOverlay.addEventListener('highlight', e => {
const resolved = this.resolveNavigation(e.detail.text)
this.renderer.goTo(resolved)
.then(() => {
const { doc } = this.renderer.getContents()
.find(x => x.index = resolved.index)
const el = resolved.anchor(doc)
el.classList.add(activeClass)
if (playbackActiveClass) el.ownerDocument
.documentElement.classList.add(playbackActiveClass)
lastActive = new WeakRef(el)
})
})
this.mediaOverlay.addEventListener('unhighlight', () => {
const el = lastActive?.deref()
if (el) {
el.classList.remove(activeClass)
if (playbackActiveClass) el.ownerDocument
.documentElement.classList.remove(playbackActiveClass)
}
})
}
}
close() {
this.renderer?.destroy()
this.renderer?.remove()
this.#sectionProgress = null
this.#tocProgress = null
this.#pageProgress = null
this.#searchResults = new Map()
this.lastLocation = null
this.history.clear()
this.tts = null
this.mediaOverlay = null
}
goToTextStart() {
return this.goTo(this.book.landmarks
?.find(m => m.type.includes('bodymatter') || m.type.includes('text'))
?.href ?? this.book.sections.findIndex(s => s.linear !== 'no'))
}
async init({ lastLocation, showTextStart }) {
const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null
if (resolved) {
await this.renderer.goTo(resolved)
this.history.pushState(lastLocation)
}
else if (showTextStart) await this.goToTextStart()
else {
this.history.pushState(0)
await this.next()
}
}
#emit(name, detail, cancelable) {
return this.dispatchEvent(new CustomEvent(name, { detail, cancelable }))
}
#onRelocate({ reason, range, index, fraction, size }) {
const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {}
const tocItem = this.#tocProgress?.getProgress(index, range)
const pageItem = this.#pageProgress?.getProgress(index, range)
const cfi = this.getCFI(index, range)
this.lastLocation = { ...progress, tocItem, pageItem, cfi, range }
if (reason === 'snap' || reason === 'page' || reason === 'scroll')
this.history.replaceState(cfi)
this.#emit('relocate', this.lastLocation)
}
#onLoad({ doc, index }) {
// set language and dir if not already set
doc.documentElement.lang ||= this.language.canonical ?? ''
if (!this.language.isCJK)
doc.documentElement.dir ||= this.language.direction ?? ''
this.#handleLinks(doc, index)
this.#cursorAutohider.cloneFor(doc.documentElement)
this.#emit('load', { doc, index })
}
#handleLinks(doc, index) {
const { book } = this
const section = book.sections[index]
doc.addEventListener('click', e => {
const a = e.target.closest('a[href]')
if (!a) return
e.preventDefault()
const href_ = a.getAttribute('href')
const href = section?.resolveHref?.(href_) ?? href_
if (book?.isExternal?.(href))
Promise.resolve(this.#emit('external-link', { a, href_ }, true))
.then(x => x ? globalThis.open(href_, '_blank') : null)
.catch(e => console.error(e))
else Promise.resolve(this.#emit('link', { a, href }, true))
.then(x => x ? this.goTo(href) : null)
.catch(e => console.error(e))
})
}
async addAnnotation(annotation, remove) {
const { value } = annotation
if (value.startsWith(SEARCH_PREFIX)) {
const cfi = value.replace(SEARCH_PREFIX, '')
const { index, anchor } = await this.resolveNavigation(cfi)
const obj = this.#getOverlayer(index)
if (obj) {
const { overlayer, doc } = obj
if (remove) {
overlayer.remove(value)
return
}
const range = doc ? anchor(doc) : anchor
overlayer.add(value, range, this.#searchDraw, this.#searchDrawOptions)
}
return
}
const { index, anchor } = await this.resolveNavigation(value)
const obj = this.#getOverlayer(index)
if (obj) {
const { overlayer, doc } = obj
overlayer.remove(value)
if (!remove) {
const range = doc ? anchor(doc) : anchor
const draw = (func, opts) => overlayer.add(value, range, func, opts)
this.#emit('draw-annotation', { draw, annotation, doc, range })
}
}
const label = this.#tocProgress.getProgress(index)?.label ?? ''
return { index, label }
}
deleteAnnotation(annotation) {
return this.addAnnotation(annotation, true)
}
#getOverlayer(index) {
return this.renderer.getContents()
.find(x => x.index === index && x.overlayer)
}
#createOverlayer({ doc, index }) {
const overlayer = new Overlayer()
doc.addEventListener('click', e => {
const [value, range] = overlayer.hitTest(e)
if (value && !value.startsWith(SEARCH_PREFIX)) {
this.#emit('show-annotation', { value, index, range })
}
}, false)
const list = this.#searchResults.get(index)
if (list) for (const item of list) this.addAnnotation(item)
this.#emit('create-overlay', { index })
return overlayer
}
async showAnnotation(annotation) {
const { value } = annotation
const resolved = await this.goTo(value)
if (resolved) {
const { index, anchor } = resolved
const { doc } = this.#getOverlayer(index)
const range = anchor(doc)
this.#emit('show-annotation', { value, index, range })
}
}
getCFI(index, range) {
const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)
if (!range) return baseCFI
return CFI.joinIndir(baseCFI, CFI.fromRange(range))
}
resolveCFI(cfi) {
if (this.book.resolveCFI)
return this.book.resolveCFI(cfi)
else {
const parts = CFI.parse(cfi)
const index = CFI.fake.toIndex((parts.parent ?? parts).shift())
const anchor = doc => CFI.toRange(doc, parts)
return { index, anchor }
}
}
resolveNavigation(target) {
try {
if (typeof target === 'number') return { index: target }
if (typeof target.fraction === 'number') {
const [index, anchor] = this.#sectionProgress.getSection(target.fraction)
return { index, anchor }
}
if (CFI.isCFI.test(target)) return this.resolveCFI(target)
return this.book.resolveHref(target)
} catch (e) {
console.error(e)
console.error(`Could not resolve target ${target}`)
}
}
async goTo(target) {
const resolved = this.resolveNavigation(target)
try {
await this.renderer.goTo(resolved)
this.history.pushState(target)
return resolved
} catch(e) {
console.error(e)
console.error(`Could not go to ${target}`)
}
}
async goToFraction(frac) {
const [index, anchor] = this.#sectionProgress.getSection(frac)
await this.renderer.goTo({ index, anchor })
this.history.pushState({ fraction: frac })
}
async select(target) {
try {
const obj = await this.resolveNavigation(target)
await this.renderer.goTo({ ...obj, select: true })
this.history.pushState(target)
} catch(e) {
console.error(e)
console.error(`Could not go to ${target}`)
}
}
deselect() {
for (const { doc } of this.renderer.getContents())
doc.defaultView.getSelection().removeAllRanges()
}
getSectionFractions() {
return (this.#sectionProgress?.sectionFractions ?? [])
.map(x => x + Number.EPSILON)
}
getProgressOf(index, range) {
const tocItem = this.#tocProgress?.getProgress(index, range)
const pageItem = this.#pageProgress?.getProgress(index, range)
return { tocItem, pageItem }
}
async getTOCItemOf(target) {
try {
const { index, anchor } = await this.resolveNavigation(target)
const doc = await this.book.sections[index].createDocument()
const frag = anchor(doc)
const isRange = frag instanceof Range
const range = isRange ? frag : doc.createRange()
if (!isRange) range.selectNodeContents(frag)
return this.#tocProgress.getProgress(index, range)
} catch(e) {
console.error(e)
console.error(`Could not get ${target}`)
}
}
async prev(distance) {
await this.renderer.prev(distance)
}
async next(distance) {
await this.renderer.next(distance)
}
goLeft() {
return this.book.dir === 'rtl' ? this.next() : this.prev()
}
goRight() {
return this.book.dir === 'rtl' ? this.prev() : this.next()
}
async * #searchSection(matcher, query, index) {
const doc = await this.book.sections[index].createDocument()
for (const { range, excerpt } of matcher(doc, query))
yield { cfi: this.getCFI(index, range), excerpt }
}
async * #searchBook(matcher, query) {
const { sections } = this.book
for (const [index, { createDocument }] of sections.entries()) {
if (!createDocument) continue
const doc = await createDocument()
const subitems = Array.from(matcher(doc, query), ({ range, excerpt }) =>
({ cfi: this.getCFI(index, range), excerpt }))
const progress = (index + 1) / sections.length
yield { progress }
if (subitems.length) yield { index, subitems }
}
}
async * search(opts) {
this.clearSearch()
this.#searchDraw = opts.draw ?? Overlayer.outline
this.#searchDrawOptions = opts.drawOptions
const { searchMatcher } = await import('./search.js')
const { query, index } = opts
const matcher = searchMatcher(textWalker,
{ defaultLocale: this.language, ...opts })
const iter = index != null
? this.#searchSection(matcher, query, index)
: this.#searchBook(matcher, query)
const list = []
this.#searchResults.set(index, list)
for await (const result of iter) {
if (result.subitems){
const list = result.subitems
.map(({ cfi }) => ({ value: SEARCH_PREFIX + cfi }))
this.#searchResults.set(result.index, list)
for (const item of list) this.addAnnotation(item)
yield {
label: this.#tocProgress.getProgress(result.index)?.label ?? '',
subitems: result.subitems,
}
}
else {
if (result.cfi) {
const item = { value: SEARCH_PREFIX + result.cfi }
list.push(item)
this.addAnnotation(item)
}
yield result
}
}
yield 'done'
}
clearSearch() {
for (const list of this.#searchResults.values())
for (const item of list) this.deleteAnnotation(item)
this.#searchResults.clear()
}
async initTTS(granularity = 'word', highlight) {
const doc = this.renderer.getContents()[0].doc
if (this.tts && this.tts.doc === doc) return
const { TTS } = await import('./tts.js')
this.tts = new TTS(doc, textWalker, highlight || (range =>
this.renderer.scrollToAnchor(range, true)), granularity)
}
startMediaOverlay() {
const { index } = this.renderer.getContents()[0]
return this.mediaOverlay.start(index)
}
}
customElements.define('foliate-view', View)