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:
21
static/vendor/foliate-js/LICENSE
vendored
Normal file
21
static/vendor/foliate-js/LICENSE
vendored
Normal 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
1
static/vendor/foliate-js/VERSION
vendored
Normal 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
45
static/vendor/foliate-js/comic-book.js
vendored
Normal 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
241
static/vendor/foliate-js/dict.js
vendored
Normal 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
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
349
static/vendor/foliate-js/epubcfi.js
vendored
Normal 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
360
static/vendor/foliate-js/fb2.js
vendored
Normal 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
319
static/vendor/foliate-js/fixed-layout.js
vendored
Normal 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
103
static/vendor/foliate-js/footnotes.js
vendored
Normal 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
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
289
static/vendor/foliate-js/opds.js
vendored
Normal 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('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
: 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
175
static/vendor/foliate-js/overlayer.js
vendored
Normal 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
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
180
static/vendor/foliate-js/pdf.js
vendored
Normal 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
113
static/vendor/foliate-js/progress.js
vendored
Normal 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
86
static/vendor/foliate-js/quote-image.js
vendored
Normal 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"> </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
130
static/vendor/foliate-js/search.js
vendored
Normal 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
43
static/vendor/foliate-js/text-walker.js
vendored
Normal 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
278
static/vendor/foliate-js/tts.js
vendored
Normal 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
42
static/vendor/foliate-js/ui/menu.js
vendored
Normal 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
169
static/vendor/foliate-js/ui/tree.js
vendored
Normal 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 }
|
||||
}
|
||||
52
static/vendor/foliate-js/uri-template.js
vendored
Normal file
52
static/vendor/foliate-js/uri-template.js
vendored
Normal 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())
|
||||
1
static/vendor/foliate-js/vendor/fflate.js
vendored
Normal file
1
static/vendor/foliate-js/vendor/fflate.js
vendored
Normal 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};
|
||||
1
static/vendor/foliate-js/vendor/zip.js
vendored
Normal file
1
static/vendor/foliate-js/vendor/zip.js
vendored
Normal file
File diff suppressed because one or more lines are too long
597
static/vendor/foliate-js/view.js
vendored
Normal file
597
static/vendor/foliate-js/view.js
vendored
Normal 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)
|
||||
Reference in New Issue
Block a user