mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
Add files via upload
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
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([`<img src="${src}">`], { 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']
|
||||||
|
const files = entries
|
||||||
|
.map(entry => entry.filename)
|
||||||
|
.filter(name => exts.some(ext => name.endsWith(ext)))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
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
|
||||||
|
return book
|
||||||
|
}
|
||||||
@@ -0,0 +1,722 @@
|
|||||||
|
import * as CFI from './epubcfi.js'
|
||||||
|
|
||||||
|
const NS = {
|
||||||
|
CONTAINER: 'urn:oasis:names:tc:opendocument:xmlns:container',
|
||||||
|
XHTML: 'http://www.w3.org/1999/xhtml',
|
||||||
|
OPF: 'http://www.idpf.org/2007/opf',
|
||||||
|
EPUB: 'http://www.idpf.org/2007/ops',
|
||||||
|
DC: 'http://purl.org/dc/elements/1.1/',
|
||||||
|
DCTERMS: 'http://purl.org/dc/terms/',
|
||||||
|
ENC: 'http://www.w3.org/2001/04/xmlenc#',
|
||||||
|
NCX: 'http://www.daisy.org/z3986/2005/ncx/',
|
||||||
|
XLINK: 'http://www.w3.org/1999/xlink',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
XML: 'application/xml',
|
||||||
|
NCX: 'application/x-dtbncx+xml',
|
||||||
|
XHTML: 'application/xhtml+xml',
|
||||||
|
HTML: 'text/html',
|
||||||
|
CSS: 'text/css',
|
||||||
|
SVG: 'image/svg+xml',
|
||||||
|
JS: /\/(x-)?(javascript|ecmascript)/,
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert to camel case
|
||||||
|
const camel = x => x.toLowerCase().replace(/[-:](.)/g, (_, g) => g.toUpperCase())
|
||||||
|
|
||||||
|
// remove leading, trailing, and excess internal whitespace
|
||||||
|
const whitespacePreLine = str => str ? str.trim().replace(/\s{2,}/g, ' ') : ''
|
||||||
|
|
||||||
|
const filterAttribute = (attr, value, isList) => isList
|
||||||
|
? el => el.getAttribute(attr)?.split(/\s/)?.includes(value)
|
||||||
|
: typeof value === 'function'
|
||||||
|
? el => value(el.getAttribute(attr))
|
||||||
|
: el => el.getAttribute(attr) === value
|
||||||
|
|
||||||
|
const getAttributes = (...xs) => el =>
|
||||||
|
el ? Object.fromEntries(xs.map(x => [camel(x), el.getAttribute(x)])) : null
|
||||||
|
|
||||||
|
const getElementText = el => whitespacePreLine(el?.textContent)
|
||||||
|
|
||||||
|
const childGetter = (doc, ns) => {
|
||||||
|
// ignore the namespace if it doesn't appear in document at all
|
||||||
|
const useNS = doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns)
|
||||||
|
const f = useNS
|
||||||
|
? (el, name) => el => el.namespaceURI === ns && el.localName === name
|
||||||
|
: (el, name) => el => el.localName === name
|
||||||
|
return {
|
||||||
|
$: (el, name) => [...el.children].find(f(el, name)),
|
||||||
|
$$: (el, name) => [...el.children].filter(f(el, name)),
|
||||||
|
$$$: useNS
|
||||||
|
? (el, name) => [...el.getElementsByTagNameNS(ns, name)]
|
||||||
|
: (el, name) => [...el.getElementsByTagName(ns, name)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveURL = (url, relativeTo) => {
|
||||||
|
try {
|
||||||
|
if (relativeTo.includes(':')) return new URL(url, relativeTo)
|
||||||
|
// the base needs to be a valid URL, so set a base URL and then remove it
|
||||||
|
const root = 'whatever:///'
|
||||||
|
return decodeURI(new URL(url, root + relativeTo).href.replace(root, ''))
|
||||||
|
} catch(e) {
|
||||||
|
console.warn(e)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExternal = uri => /^(?!blob)\w+:/i.test(uri)
|
||||||
|
|
||||||
|
// like `path.relative()` in Node.js
|
||||||
|
const pathRelative = (from, to) => {
|
||||||
|
if (!from) return to
|
||||||
|
const as = from.replace(/\/$/, '').split('/')
|
||||||
|
const bs = to.replace(/\/$/, '').split('/')
|
||||||
|
const i = (as.length > bs.length ? as : bs).findIndex((_, i) => as[i] !== bs[i])
|
||||||
|
return i < 0 ? '' : Array(as.length - i).fill('..').concat(bs.slice(i)).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathDirname = str => str.slice(0, str.lastIndexOf('/') + 1)
|
||||||
|
|
||||||
|
// replace asynchronously and sequentially
|
||||||
|
// same techinque as https://stackoverflow.com/a/48032528
|
||||||
|
const replaceSeries = async (str, regex, f) => {
|
||||||
|
const matches = []
|
||||||
|
str.replace(regex, (...args) => (matches.push(args), null))
|
||||||
|
const results = []
|
||||||
|
for (const args of matches) results.push(await f(...args))
|
||||||
|
return str.replace(regex, () => results.shift())
|
||||||
|
}
|
||||||
|
|
||||||
|
const regexEscape = str => str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
|
||||||
|
|
||||||
|
const LANGS = { attrs: ['dir', 'xml:lang'] }
|
||||||
|
const ALTS = { name: 'alternate-script', many: true, ...LANGS, props: ['file-as'] }
|
||||||
|
const CONTRIB = {
|
||||||
|
many: true, ...LANGS,
|
||||||
|
props: [{ name: 'role', many: true, attrs: ['scheme'] }, 'file-as', ALTS],
|
||||||
|
}
|
||||||
|
const METADATA = [
|
||||||
|
{
|
||||||
|
name: 'title', many: true, ...LANGS,
|
||||||
|
props: ['title-type', 'display-seq', 'file-as', ALTS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'identifier', many: true,
|
||||||
|
props: [{ name: 'identifier-type', attrs: ['scheme'] }],
|
||||||
|
},
|
||||||
|
{ name: 'language', many: true },
|
||||||
|
{ name: 'creator', ...CONTRIB },
|
||||||
|
{ name: 'contributor', ...CONTRIB },
|
||||||
|
{ name: 'publisher', ...LANGS, props: ['file-as', ALTS] },
|
||||||
|
{ name: 'description', ...LANGS, props: [ALTS] },
|
||||||
|
{ name: 'rights', ...LANGS, props: [ALTS] },
|
||||||
|
{ name: 'date' },
|
||||||
|
{ name: 'dcterms:modified', type: 'meta' },
|
||||||
|
{ name: 'subject', many: true, ...LANGS, props: ['term', 'authority', ALTS] },
|
||||||
|
{
|
||||||
|
name: 'belongs-to-collection', type: 'meta', many: true, ...LANGS,
|
||||||
|
props: [
|
||||||
|
'collection-type', 'group-position', 'dcterms:identifier', 'file-as',
|
||||||
|
ALTS, { name: 'belongs-to-collection', recursive: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// NOTE: this only gets properties defined with the `refines` attribute,
|
||||||
|
// which is used in EPUB 3.0, deprecated in 3.1, then restored in 3.2;
|
||||||
|
// no support for `opf:` attributes of 2.0 and 3.1
|
||||||
|
const getMetadata = opf => {
|
||||||
|
const { $, $$ } = childGetter(opf, NS.OPF)
|
||||||
|
const $metadata = $(opf.documentElement, 'metadata')
|
||||||
|
const els = Array.from($metadata.children)
|
||||||
|
const getValue = (obj, el) => {
|
||||||
|
if (!el) return null
|
||||||
|
const { props = [], attrs = [] } = obj
|
||||||
|
const value = getElementText(el)
|
||||||
|
if (!props.length && !attrs.length) return value
|
||||||
|
const id = el.getAttribute('id')
|
||||||
|
const refines = id ? els.filter(filterAttribute('refines', '#' + id)) : []
|
||||||
|
return Object.fromEntries([['value', value]]
|
||||||
|
.concat(props.map(prop => {
|
||||||
|
const { many, recursive } = prop
|
||||||
|
const name = typeof prop === 'string' ? prop : prop.name
|
||||||
|
const filter = filterAttribute('property', name)
|
||||||
|
const subobj = recursive ? obj : prop
|
||||||
|
return [camel(name), many
|
||||||
|
? refines.filter(filter).map(el => getValue(subobj, el))
|
||||||
|
: getValue(subobj, refines.find(filter))]
|
||||||
|
}))
|
||||||
|
.concat(attrs.map(attr => [camel(attr), el.getAttribute(attr)])))
|
||||||
|
}
|
||||||
|
const arr = els.filter(filterAttribute('refines', null))
|
||||||
|
const metadata = Object.fromEntries(METADATA.map(obj => {
|
||||||
|
const { type, name, many } = obj
|
||||||
|
const filter = type === 'meta'
|
||||||
|
? el => el.namespaceURI === NS.OPF && el.getAttribute('property') === name
|
||||||
|
: el => el.namespaceURI === NS.DC && el.localName === name
|
||||||
|
return [camel(name), many ? arr.filter(filter).map(el => getValue(obj, el))
|
||||||
|
: getValue(obj, arr.find(filter))]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const prefix = 'rendition:'
|
||||||
|
const rendition = Object.fromEntries($$($metadata, 'meta')
|
||||||
|
.filter(filterAttribute('property', x => x?.startsWith(prefix)))
|
||||||
|
.map(el => [el.getAttribute('property').replace(prefix, ''),
|
||||||
|
getElementText(el)]))
|
||||||
|
return { metadata, rendition }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseNav = (doc, resolve = f => f) => {
|
||||||
|
const { $, $$, $$$ } = childGetter(doc, NS.XHTML)
|
||||||
|
const resolveHref = href => href ? decodeURI(resolve(href)) : null
|
||||||
|
const parseLI = getType => $li => {
|
||||||
|
const $a = $($li, 'a') ?? $($li, 'span')
|
||||||
|
const $ol = $($li, 'ol')
|
||||||
|
const href = resolveHref($a?.getAttribute('href'))
|
||||||
|
const label = getElementText($a) || $a?.getAttribute('title')
|
||||||
|
// TODO: get and concat alt/title texts in content
|
||||||
|
const result = { label, href, subitems: parseOL($ol) }
|
||||||
|
if (getType) result.type = $a?.getAttributeNS(NS.EPUB, 'type')?.split(/\s/)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const parseOL = ($ol, getType) => $ol ? $$($ol, 'li').map(parseLI(getType)) : null
|
||||||
|
const parseNav = ($nav, getType) => parseOL($($nav, 'ol'), getType)
|
||||||
|
|
||||||
|
const $$nav = $$$(doc, 'nav')
|
||||||
|
let toc = null, pageList = null, landmarks = null, others = []
|
||||||
|
for (const $nav of $$nav) {
|
||||||
|
const type = $nav.getAttributeNS(NS.EPUB, 'type')?.split(/\s/) ?? []
|
||||||
|
if (type.includes('toc')) toc ??= parseNav($nav)
|
||||||
|
else if (type.includes('page-list')) pageList ??= parseNav($nav)
|
||||||
|
else if (type.includes('landmarks')) landmarks ??= parseNav($nav, true)
|
||||||
|
else others.push({
|
||||||
|
label: getElementText($nav.firstElementChild), type,
|
||||||
|
list: parseNav($nav),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { toc, pageList, landmarks, others }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseNCX = (doc, resolve = f => f) => {
|
||||||
|
const { $, $$ } = childGetter(doc, NS.NCX)
|
||||||
|
const resolveHref = href => href ? decodeURI(resolve(href)) : null
|
||||||
|
const parseItem = el => {
|
||||||
|
const $label = $(el, 'navLabel')
|
||||||
|
const $content = $(el, 'content')
|
||||||
|
const label = getElementText($label)
|
||||||
|
const href = resolveHref($content.getAttribute('src'))
|
||||||
|
if (el.localName === 'navPoint') {
|
||||||
|
const els = $$(el, 'navPoint')
|
||||||
|
return { label, href, subitems: els.length ? els.map(parseItem) : null }
|
||||||
|
}
|
||||||
|
return { label, href }
|
||||||
|
}
|
||||||
|
const parseList = (el, itemName) => $$(el, itemName).map(parseItem)
|
||||||
|
const getSingle = (container, itemName) => {
|
||||||
|
const $container = $(doc.documentElement, container)
|
||||||
|
return $container ? parseList($container, itemName) : null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
toc: getSingle('navMap', 'navPoint'),
|
||||||
|
pageList: getSingle('pageList', 'pageTarget'),
|
||||||
|
others: $$(doc.documentElement, 'navList').map(el => ({
|
||||||
|
label: getElementText($(el, 'navLabel')),
|
||||||
|
list: parseList(el, 'navTarget'),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUUID = /([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})/
|
||||||
|
|
||||||
|
const getUUID = opf => {
|
||||||
|
for (const el of opf.getElementsByTagNameNS(NS.DC, 'identifier')) {
|
||||||
|
const [id] = getElementText(el).split(':').slice(-1)
|
||||||
|
if (isUUID.test(id)) return id
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIdentifier = opf => getElementText(
|
||||||
|
opf.getElementById(opf.documentElement.getAttribute('unique-identifier'))
|
||||||
|
?? opf.getElementsByTagNameNS(NS.DC, 'identifier')[0])
|
||||||
|
|
||||||
|
// https://www.w3.org/publishing/epub32/epub-ocf.html#sec-resource-obfuscation
|
||||||
|
const deobfuscate = async (key, length, blob) => {
|
||||||
|
const array = new Uint8Array(await blob.slice(0, length).arrayBuffer())
|
||||||
|
length = Math.min(length, array.length)
|
||||||
|
for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length]
|
||||||
|
return new Blob([array, blob.slice(length)], { type: blob.type })
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebCryptoSHA1 = async str => {
|
||||||
|
const data = new TextEncoder().encode(str)
|
||||||
|
const buffer = await globalThis.crypto.subtle.digest('SHA-1', data)
|
||||||
|
return new Uint8Array(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deobfuscators = (sha1 = WebCryptoSHA1) => ({
|
||||||
|
'http://www.idpf.org/2008/embedding': {
|
||||||
|
key: opf => sha1(getIdentifier(opf)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replaceAll(/[\u0020\u0009\u000d\u000a]/g, '')),
|
||||||
|
decode: (key, blob) => deobfuscate(key, 1040, blob),
|
||||||
|
},
|
||||||
|
'http://ns.adobe.com/pdf/enc#RC': {
|
||||||
|
key: opf => {
|
||||||
|
const uuid = getUUID(opf).replaceAll('-', '')
|
||||||
|
return Uint8Array.from({ length: 16 }, (_, i) =>
|
||||||
|
parseInt(uuid.slice(i * 2, i * 2 + 2), 16))
|
||||||
|
},
|
||||||
|
decode: (key, blob) => deobfuscate(key, 1024, blob),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
class Encryption {
|
||||||
|
#uris = new Map()
|
||||||
|
#decoders = new Map()
|
||||||
|
#algorithms
|
||||||
|
constructor(algorithms) {
|
||||||
|
this.#algorithms = algorithms
|
||||||
|
}
|
||||||
|
async init(encryption, opf) {
|
||||||
|
if (!encryption) return
|
||||||
|
const data = Array.from(
|
||||||
|
encryption.getElementsByTagNameNS(NS.ENC, 'EncryptedData'), el => ({
|
||||||
|
algorithm: el.getElementsByTagNameNS(NS.ENC, 'EncryptionMethod')[0]
|
||||||
|
?.getAttribute('Algorithm'),
|
||||||
|
uri: el.getElementsByTagNameNS(NS.ENC, 'CipherReference')[0]
|
||||||
|
?.getAttribute('URI'),
|
||||||
|
}))
|
||||||
|
for (const { algorithm, uri } of data) {
|
||||||
|
if (!this.#decoders.has(algorithm)) {
|
||||||
|
const algo = this.#algorithms[algorithm]
|
||||||
|
if (!algo) {
|
||||||
|
console.warn('Unknown encryption algorithm')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const key = await algo.key(opf)
|
||||||
|
this.#decoders.set(algorithm, blob => algo.decode(key, blob))
|
||||||
|
}
|
||||||
|
this.#uris.set(uri, algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getDecoder(uri) {
|
||||||
|
return this.#decoders.get(this.#uris.get(uri)) ?? (x => x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Resources {
|
||||||
|
constructor({ opf, resolveHref }) {
|
||||||
|
this.opf = opf
|
||||||
|
const { $, $$, $$$ } = childGetter(opf, NS.OPF)
|
||||||
|
|
||||||
|
const $manifest = $(opf.documentElement, 'manifest')
|
||||||
|
const $spine = $(opf.documentElement, 'spine')
|
||||||
|
const $$itemref = $$($spine, 'itemref')
|
||||||
|
|
||||||
|
this.manifest = $$($manifest, 'item')
|
||||||
|
.map(getAttributes('href', 'id', 'media-type', 'properties'))
|
||||||
|
.map(item => {
|
||||||
|
item.href = resolveHref(item.href)
|
||||||
|
item.properties = item.properties?.split(/\s/)
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
this.spine = $$itemref
|
||||||
|
.map(getAttributes('idref', 'id', 'linear', 'properties'))
|
||||||
|
.map(item => (item.properties = item.properties?.split(/\s/), item))
|
||||||
|
this.pageProgressionDirection = $spine
|
||||||
|
.getAttribute('page-progression-direction')
|
||||||
|
|
||||||
|
this.navPath = this.getItemByProperty('nav')?.href
|
||||||
|
this.ncxPath = (this.getItemByID($spine.getAttribute('toc'))
|
||||||
|
?? this.manifest.find(item => item.mediaType === MIME.NCX))?.href
|
||||||
|
|
||||||
|
const $guide = $(opf.documentElement, 'guide')
|
||||||
|
if ($guide) this.guide = $$($guide, 'reference')
|
||||||
|
.map(getAttributes('type', 'title', 'href'))
|
||||||
|
.map(({ type, title, href }) => ({
|
||||||
|
label: title,
|
||||||
|
type: type.split(/\s/),
|
||||||
|
href: resolveHref(href),
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.cover = this.getItemByProperty('cover-image')
|
||||||
|
// EPUB 2 compat
|
||||||
|
?? this.getItemByID($$$(opf, 'meta')
|
||||||
|
.find(filterAttribute('name', 'cover'))
|
||||||
|
?.getAttribute('content'))
|
||||||
|
?? this.getItemByHref(this.guide
|
||||||
|
?.find(ref => ref.type.includes('cover'))?.href)
|
||||||
|
|
||||||
|
this.cfis = CFI.fromElements($$itemref)
|
||||||
|
}
|
||||||
|
getItemByID(id) {
|
||||||
|
return this.manifest.find(item => item.id === id)
|
||||||
|
}
|
||||||
|
getItemByHref(href) {
|
||||||
|
return this.manifest.find(item => item.href === href)
|
||||||
|
}
|
||||||
|
getItemByProperty(prop) {
|
||||||
|
return this.manifest.find(item => item.properties?.includes(prop))
|
||||||
|
}
|
||||||
|
resolveCFI(cfi) {
|
||||||
|
const parts = CFI.parse(cfi)
|
||||||
|
const top = (parts.parent ?? parts).shift()
|
||||||
|
let $itemref = CFI.toElement(this.opf, top)
|
||||||
|
// make sure it's an idref; if not, try again without the ID assertion
|
||||||
|
// mainly because Epub.js used to generate wrong ID assertions
|
||||||
|
// https://github.com/futurepress/epub.js/issues/1236
|
||||||
|
if ($itemref && $itemref.nodeName !== 'idref') {
|
||||||
|
top.at(-1).id = null
|
||||||
|
$itemref = CFI.toElement(this.opf, top)
|
||||||
|
}
|
||||||
|
const idref = $itemref?.getAttribute('idref')
|
||||||
|
const index = this.spine.findIndex(item => item.idref === idref)
|
||||||
|
const anchor = doc => CFI.toRange(doc, parts)
|
||||||
|
return { index, anchor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Loader {
|
||||||
|
#cache = new Map()
|
||||||
|
#children = new Map()
|
||||||
|
#refCount = new Map()
|
||||||
|
allowScript = false
|
||||||
|
constructor({ loadText, loadBlob, resources }) {
|
||||||
|
this.loadText = loadText
|
||||||
|
this.loadBlob = loadBlob
|
||||||
|
this.manifest = resources.manifest
|
||||||
|
this.assets = resources.manifest
|
||||||
|
// needed only when replacing in (X)HTML w/o parsing (see below)
|
||||||
|
//.filter(({ mediaType }) => ![MIME.XHTML, MIME.HTML].includes(mediaType))
|
||||||
|
}
|
||||||
|
createURL(href, data, type, parent) {
|
||||||
|
if (!data) return ''
|
||||||
|
const url = URL.createObjectURL(new Blob([data], { type }))
|
||||||
|
this.#cache.set(href, url)
|
||||||
|
this.#refCount.set(href, 1)
|
||||||
|
if (parent) {
|
||||||
|
const childList = this.#children.get(parent)
|
||||||
|
if (childList) childList.push(href)
|
||||||
|
else this.#children.set(parent, [href])
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
ref(href, parent) {
|
||||||
|
const childList = this.#children.get(parent)
|
||||||
|
if (!childList?.includes(href)) {
|
||||||
|
this.#refCount.set(href, this.#refCount.get(href) + 1)
|
||||||
|
//console.log(`referencing ${href}, now ${this.#refCount.get(href)}`)
|
||||||
|
if (childList) childList.push(href)
|
||||||
|
else this.#children.set(parent, [href])
|
||||||
|
}
|
||||||
|
return this.#cache.get(href)
|
||||||
|
}
|
||||||
|
unref(href) {
|
||||||
|
if (!this.#refCount.has(href)) return
|
||||||
|
const count = this.#refCount.get(href) - 1
|
||||||
|
//console.log(`unreferencing ${href}, now ${count}`)
|
||||||
|
if (count < 1) {
|
||||||
|
//console.log(`unloading ${href}`)
|
||||||
|
URL.revokeObjectURL(this.#cache.get(href))
|
||||||
|
this.#cache.delete(href)
|
||||||
|
this.#refCount.delete(href)
|
||||||
|
// unref children
|
||||||
|
const childList = this.#children.get(href)
|
||||||
|
if (childList) while (childList.length) this.unref(childList.pop())
|
||||||
|
this.#children.delete(href)
|
||||||
|
} else this.#refCount.set(href, count)
|
||||||
|
}
|
||||||
|
// load manifest item, recursively loading all resources as needed
|
||||||
|
async loadItem(item, parents = []) {
|
||||||
|
if (!item) return null
|
||||||
|
const { href, mediaType } = item
|
||||||
|
|
||||||
|
const isScript = MIME.JS.test(item.mediaType)
|
||||||
|
if (isScript && !this.allowScript) return null
|
||||||
|
|
||||||
|
const parent = parents.at(-1)
|
||||||
|
if (this.#cache.has(href)) return this.ref(href, parent)
|
||||||
|
|
||||||
|
const shouldReplace =
|
||||||
|
(isScript || [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(mediaType))
|
||||||
|
// prevent circular references
|
||||||
|
&& parents.every(p => p !== href)
|
||||||
|
if (shouldReplace) return this.loadReplaced(item, parents)
|
||||||
|
return this.createURL(href, await this.loadBlob(href), mediaType, parent)
|
||||||
|
}
|
||||||
|
async loadHref(href, base, parents = []) {
|
||||||
|
if (isExternal(href)) return href
|
||||||
|
const path = resolveURL(href, base)
|
||||||
|
const item = this.manifest.find(item => item.href === path)
|
||||||
|
if (!item) return href
|
||||||
|
return this.loadItem(item, parents.concat(base))
|
||||||
|
}
|
||||||
|
async loadReplaced(item, parents = []) {
|
||||||
|
const { href, mediaType } = item
|
||||||
|
const parent = parents.at(-1)
|
||||||
|
const str = await this.loadText(href)
|
||||||
|
if (!str) return null
|
||||||
|
|
||||||
|
// note that one can also just use `replaceString` for everything:
|
||||||
|
// ```
|
||||||
|
// const replaced = await this.replaceString(str, href, parents)
|
||||||
|
// return this.createURL(href, replaced, mediaType, parent)
|
||||||
|
// ```
|
||||||
|
// which is basically what Epub.js does, which is simpler, but will
|
||||||
|
// break things like iframes (because you don't want to replace links)
|
||||||
|
// or text that just happen to be paths
|
||||||
|
|
||||||
|
// parse and replace in HTML
|
||||||
|
if ([MIME.XHTML, MIME.HTML, MIME.SVG].includes(mediaType)) {
|
||||||
|
let doc = new DOMParser().parseFromString(str, mediaType)
|
||||||
|
// change to HTML if it's not valid XHTML
|
||||||
|
if (mediaType === MIME.XHTML && doc.querySelector('parsererror')) {
|
||||||
|
item.mediaType = MIME.HTML
|
||||||
|
doc = new DOMParser().parseFromString(str, item.mediaType)
|
||||||
|
}
|
||||||
|
// replace hrefs in XML processing instructions
|
||||||
|
// this is mainly for SVGs that use xml-stylesheet
|
||||||
|
if ([MIME.XHTML, MIME.SVG].includes(item.mediaType)) {
|
||||||
|
let child = doc.firstChild
|
||||||
|
while (child instanceof ProcessingInstruction) {
|
||||||
|
if (child.data) {
|
||||||
|
const replacedData = await replaceSeries(child.data,
|
||||||
|
/(?:^|\s*)(href\s*=\s*['"])([^'"]*)(['"])/i,
|
||||||
|
(_, p1, p2, p3) => this.loadHref(p2, href, parents)
|
||||||
|
.then(p2 => `${p1}${p2}${p3}`))
|
||||||
|
child.replaceWith(doc.createProcessingInstruction(
|
||||||
|
child.target, replacedData))
|
||||||
|
}
|
||||||
|
child = child.nextSibling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// replace hrefs (excluding anchors)
|
||||||
|
// TODO: srcset?
|
||||||
|
const replace = async (el, attr) => el.setAttribute(attr,
|
||||||
|
await this.loadHref(el.getAttribute(attr), href, parents))
|
||||||
|
for (const el of doc.querySelectorAll('link[href]')) await replace(el, 'href')
|
||||||
|
for (const el of doc.querySelectorAll('[src]')) await replace(el, 'src')
|
||||||
|
for (const el of doc.querySelectorAll('[poster]')) await replace(el, 'poster')
|
||||||
|
for (const el of doc.querySelectorAll('[*|href]:not([href]'))
|
||||||
|
el.setAttributeNS(NS.XLINK, 'href', await this.loadHref(
|
||||||
|
el.getAttributeNS(NS.XLINK, 'href'), href, parents))
|
||||||
|
// replace inline styles
|
||||||
|
for (const el of doc.querySelectorAll('style'))
|
||||||
|
if (el.textContent) el.textContent =
|
||||||
|
await this.replaceCSS(el.textContent, href, parents)
|
||||||
|
for (const el of doc.querySelectorAll('[style]'))
|
||||||
|
el.setAttribute('style',
|
||||||
|
await this.replaceCSS(el.getAttribute('style'), href, parents))
|
||||||
|
// TODO: replace inline scripts? probably not worth the trouble
|
||||||
|
const result = new XMLSerializer().serializeToString(doc)
|
||||||
|
return this.createURL(href, result, item.mediaType, parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = mediaType === MIME.CSS
|
||||||
|
? await this.replaceCSS(str, href, parents)
|
||||||
|
: await this.replaceString(str, href, parents)
|
||||||
|
return this.createURL(href, result, mediaType, parent)
|
||||||
|
}
|
||||||
|
async replaceCSS(str, href, parents = []) {
|
||||||
|
const replacedUrls = await replaceSeries(str,
|
||||||
|
/url\(\s*["']?([^'"\n]*?)\s*["']?\s*\)/gi,
|
||||||
|
(_, url) => this.loadHref(url, href, parents)
|
||||||
|
.then(url => `url("${url}")`))
|
||||||
|
// apart from `url()`, strings can be used for `@import` (but why?!)
|
||||||
|
const replacedImports = await replaceSeries(replacedUrls,
|
||||||
|
/@import\s*["']([^"'\n]*?)["']/gi,
|
||||||
|
(_, url) => this.loadHref(url, href, parents)
|
||||||
|
.then(url => `@import "${url}"`))
|
||||||
|
return replacedImports.replaceAll('-epub-', '')
|
||||||
|
}
|
||||||
|
// find & replace all possible relative paths for all assets without parsing
|
||||||
|
replaceString(str, href, parents = []) {
|
||||||
|
const assetMap = new Map()
|
||||||
|
const urls = this.assets.map(asset => {
|
||||||
|
// do not replace references to the file itself
|
||||||
|
if (asset.href === href) return
|
||||||
|
// href was decoded and resolved when parsing the manifest
|
||||||
|
const relative = pathRelative(pathDirname(href), asset.href)
|
||||||
|
const relativeEnc = encodeURI(relative)
|
||||||
|
const rootRelative = '/' + asset.href
|
||||||
|
const rootRelativeEnc = encodeURI(rootRelative)
|
||||||
|
const set = new Set([relative, relativeEnc, rootRelative, rootRelativeEnc])
|
||||||
|
for (const url of set) assetMap.set(url, asset)
|
||||||
|
return Array.from(set)
|
||||||
|
}).flat().filter(x => x)
|
||||||
|
if (!urls.length) return str
|
||||||
|
const regex = new RegExp(urls.map(regexEscape).join('|'), 'g')
|
||||||
|
return replaceSeries(str, regex, async match =>
|
||||||
|
this.loadItem(assetMap.get(match.replace(/^\//, '')),
|
||||||
|
parents.concat(href)))
|
||||||
|
}
|
||||||
|
unloadItem(item) {
|
||||||
|
this.unref(item?.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHTMLFragment = (doc, id) => doc.getElementById(id)
|
||||||
|
?? doc.querySelector(`[name="${CSS.escape(id)}"]`)
|
||||||
|
|
||||||
|
export class EPUB {
|
||||||
|
parser = new DOMParser()
|
||||||
|
#encryption
|
||||||
|
constructor({ loadText, loadBlob, getSize, sha1 }) {
|
||||||
|
this.loadText = loadText
|
||||||
|
this.loadBlob = loadBlob
|
||||||
|
this.getSize = getSize
|
||||||
|
this.#encryption = new Encryption(deobfuscators(sha1))
|
||||||
|
}
|
||||||
|
#parseXML(str) {
|
||||||
|
return str ? this.parser.parseFromString(str, MIME.XML) : null
|
||||||
|
}
|
||||||
|
async #loadXML(uri) {
|
||||||
|
return this.#parseXML(await this.loadText(uri))
|
||||||
|
}
|
||||||
|
async init() {
|
||||||
|
const $container = await this.#loadXML('META-INF/container.xml')
|
||||||
|
if (!$container) throw new Error('Failed to load container file')
|
||||||
|
|
||||||
|
const opfs = Array.from(
|
||||||
|
$container.getElementsByTagNameNS(NS.CONTAINER, 'rootfile'),
|
||||||
|
getAttributes('full-path', 'media-type'))
|
||||||
|
.filter(file => file.mediaType === 'application/oebps-package+xml')
|
||||||
|
|
||||||
|
if (!opfs.length) throw new Error('No package document defined in container')
|
||||||
|
const opfPath = opfs[0].fullPath
|
||||||
|
const opf = await this.#loadXML(opfPath)
|
||||||
|
if (!opf) throw new Error('Failed to load package document')
|
||||||
|
|
||||||
|
const $encryption = await this.#loadXML('META-INF/encryption.xml')
|
||||||
|
await this.#encryption.init($encryption, opf)
|
||||||
|
|
||||||
|
this.resources = new Resources({
|
||||||
|
opf,
|
||||||
|
resolveHref: url => resolveURL(url, opfPath),
|
||||||
|
})
|
||||||
|
const loader = new Loader({
|
||||||
|
loadText: this.loadText,
|
||||||
|
loadBlob: uri => this.loadBlob(uri)
|
||||||
|
.then(this.#encryption.getDecoder(uri)),
|
||||||
|
resources: this.resources,
|
||||||
|
})
|
||||||
|
this.sections = this.resources.spine.map((spineItem, index) => {
|
||||||
|
const { idref, linear, properties = [] } = spineItem
|
||||||
|
const item = this.resources.getItemByID(idref)
|
||||||
|
if (!item) {
|
||||||
|
console.warn(`Could not find item with ID "${idref}" in manifest`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: this.resources.getItemByID(idref)?.href,
|
||||||
|
load: () => loader.loadItem(item),
|
||||||
|
createDocument: () => this.loadDocument(item),
|
||||||
|
size: this.getSize(item.href),
|
||||||
|
cfi: this.resources.cfis[index],
|
||||||
|
linear,
|
||||||
|
forceLeft: properties.includes('page-spread-left'),
|
||||||
|
forceRight: properties.includes('page-spread-right'),
|
||||||
|
forceCenter: properties.includes('page-spread-center'),
|
||||||
|
resolveHref: href => resolveURL(href, item.href),
|
||||||
|
}
|
||||||
|
}).filter(s => s)
|
||||||
|
|
||||||
|
const { navPath, ncxPath } = this.resources
|
||||||
|
if (navPath) try {
|
||||||
|
const resolve = url => resolveURL(url, navPath)
|
||||||
|
const nav = parseNav(await this.#loadXML(navPath), resolve)
|
||||||
|
this.toc = nav.toc
|
||||||
|
this.pageList = nav.pageList
|
||||||
|
this.landmarks = nav.landmarks
|
||||||
|
} catch(e) {
|
||||||
|
console.warn(e)
|
||||||
|
}
|
||||||
|
if (!this.toc && ncxPath) try {
|
||||||
|
const resolve = url => resolveURL(url, ncxPath)
|
||||||
|
const ncx = parseNCX(await this.#loadXML(ncxPath), resolve)
|
||||||
|
this.toc = ncx.toc
|
||||||
|
this.pageList = ncx.pageList
|
||||||
|
} catch(e) {
|
||||||
|
console.warn(e)
|
||||||
|
}
|
||||||
|
this.landmarks ??= this.resources.guide
|
||||||
|
|
||||||
|
const { metadata, rendition } = getMetadata(opf)
|
||||||
|
this.rendition = rendition
|
||||||
|
this.dir = this.resources.pageProgressionDirection
|
||||||
|
|
||||||
|
this.rawMetadata = metadata // useful for debugging, i guess
|
||||||
|
const title = metadata?.title?.[0]
|
||||||
|
this.metadata = {
|
||||||
|
title: title?.value,
|
||||||
|
sortAs: title?.fileAs,
|
||||||
|
language: metadata?.language,
|
||||||
|
identifier: getIdentifier(opf),
|
||||||
|
description: metadata?.description?.value,
|
||||||
|
publisher: metadata?.publisher?.value,
|
||||||
|
published: metadata?.date,
|
||||||
|
modified: metadata?.dctermsModified,
|
||||||
|
subject: metadata?.subject
|
||||||
|
?.filter(({ value, code }) => value || code)
|
||||||
|
?.map(({ value, code, scheme }) => ({ name: value, code, scheme })),
|
||||||
|
rights: metadata?.rights?.value,
|
||||||
|
}
|
||||||
|
const relators = {
|
||||||
|
art: 'artist',
|
||||||
|
aut: 'author',
|
||||||
|
bkp: 'producer',
|
||||||
|
clr: 'colorist',
|
||||||
|
edt: 'editor',
|
||||||
|
ill: 'illustrator',
|
||||||
|
trl: 'translator',
|
||||||
|
pbl: 'publisher',
|
||||||
|
}
|
||||||
|
const mapContributor = defaultKey => obj => {
|
||||||
|
const keys = [...new Set(obj.role?.map(({ value, scheme }) =>
|
||||||
|
(!scheme || scheme === 'marc:relators' ? relators[value] : null)
|
||||||
|
?? defaultKey))]
|
||||||
|
const value = { name: obj.value, sortAs: obj.fileAs }
|
||||||
|
return [keys?.length ? keys : [defaultKey], value]
|
||||||
|
}
|
||||||
|
metadata?.creator?.map(mapContributor('author'))
|
||||||
|
?.concat(metadata?.contributor?.map?.(mapContributor('contributor')))
|
||||||
|
?.forEach(([keys, value]) => keys.forEach(key => {
|
||||||
|
if (this.metadata[key]) this.metadata[key].push(value)
|
||||||
|
else this.metadata[key] = [value]
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.getCover = () => {
|
||||||
|
const href = this.resources?.cover?.href
|
||||||
|
return href ? this.loadBlob(href) : null
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
async loadDocument(item) {
|
||||||
|
const str = await this.loadText(item.href)
|
||||||
|
return this.parser.parseFromString(str, item.mediaType)
|
||||||
|
}
|
||||||
|
resolveCFI(cfi) {
|
||||||
|
return this.resources.resolveCFI(cfi)
|
||||||
|
}
|
||||||
|
resolveHref(href) {
|
||||||
|
const [path, hash] = href.split('#')
|
||||||
|
const item = this.resources.getItemByHref(decodeURI(path))
|
||||||
|
if (!item) return null
|
||||||
|
const index = this.resources.spine.findIndex(({ idref }) => idref === item.id)
|
||||||
|
const anchor = hash ? doc => getHTMLFragment(doc, hash) : () => 0
|
||||||
|
return { index, anchor }
|
||||||
|
}
|
||||||
|
splitTOCHref(href) {
|
||||||
|
return href?.split('#') ?? []
|
||||||
|
}
|
||||||
|
getTOCFragment(doc, id) {
|
||||||
|
return doc.getElementById(id)
|
||||||
|
?? doc.querySelector(`[name="${CSS.escape(id)}"]`)
|
||||||
|
}
|
||||||
|
isExternal(uri) {
|
||||||
|
return isExternal(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
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))
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
const nodes = Array.from(node.childNodes)
|
||||||
|
// "content other than element and character data is ignored"
|
||||||
|
.filter(node => isTextNode(node) || isElementNode(node))
|
||||||
|
.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 getNodeByIndex = (node, index) => node ? indexChildNodes(node)[index] : null
|
||||||
|
|
||||||
|
const partsToNode = (node, parts) => {
|
||||||
|
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 = getNodeByIndex(node, index)
|
||||||
|
// 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
|
||||||
|
if (n === node[node.length - 1]) return { node: n, offset: length - 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeToParts = (node, offset) => {
|
||||||
|
const { parentNode, id } = node
|
||||||
|
const indexed = indexChildNodes(parentNode)
|
||||||
|
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).concat(part) : [part]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fromRange = range => {
|
||||||
|
const { startContainer, startOffset, endContainer, endOffset } = range
|
||||||
|
const start = nodeToParts(startContainer, startOffset)
|
||||||
|
if (range.collapsed) return toString([start])
|
||||||
|
const end = nodeToParts(endContainer, endOffset)
|
||||||
|
return buildRange([start], [end])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toRange = (doc, parts) => {
|
||||||
|
const startParts = collapse(parts)
|
||||||
|
const endParts = collapse(parts, true)
|
||||||
|
|
||||||
|
const root = doc.documentElement
|
||||||
|
const start = partsToNode(root, startParts[0])
|
||||||
|
const end = partsToNode(root, endParts[0])
|
||||||
|
|
||||||
|
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 => `/6/${(index + 1) * 2}`,
|
||||||
|
toIndex: parts => parts?.at(-1).index / 2 - 1,
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
const trim = str => str?.trim()?.replace(/\s{2,}/g, ' ')
|
||||||
|
const getElementText = el => trim(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', ['align']],
|
||||||
|
'th': ['th', ['colspan', 'rowspan', 'align', 'valign']],
|
||||||
|
'td': ['td', ['colspan', 'rowspan', 'align', 'valign']],
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
|
||||||
|
const getImageSrc = el => {
|
||||||
|
const href = el.getAttributeNS(NS.XLINK, 'href')
|
||||||
|
const [, id] = href.split('#')
|
||||||
|
const bin = el.getRootNode().getElementById(id)
|
||||||
|
return bin
|
||||||
|
? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}`
|
||||||
|
: href
|
||||||
|
}
|
||||||
|
|
||||||
|
class FB2Converter {
|
||||||
|
constructor(fb2) {
|
||||||
|
this.fb2 = fb2
|
||||||
|
this.doc = document.implementation.createDocument(NS.XHTML, 'html')
|
||||||
|
}
|
||||||
|
image(node) {
|
||||||
|
const el = this.doc.createElement('img')
|
||||||
|
el.alt = node.getAttribute('alt')
|
||||||
|
el.title = node.getAttribute('title')
|
||||||
|
el.setAttribute('src', 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 !== 1) return this.doc.createTextNode(node.textContent)
|
||||||
|
|
||||||
|
const d = def?.[node.nodeName]
|
||||||
|
if (!d) return null
|
||||||
|
if (typeof d === 'string') return this[d](node)
|
||||||
|
|
||||||
|
const [name, opts] = 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(opts)) for (const attr of opts)
|
||||||
|
el.setAttribute(attr, node.getAttribute(attr))
|
||||||
|
|
||||||
|
// process child elements recursively
|
||||||
|
const childDef = opts === 'self' ? def : Array.isArray(opts) ? null : 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)
|
||||||
|
// FIXME: `Document.xmlEncoding` is deprecated
|
||||||
|
if (doc.xmlEncoding && doc.xmlEncoding !== 'utf-8') {
|
||||||
|
const str = new TextDecoder(doc.xmlEncoding).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 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
body > section > .title, body.notesBodyType > .title {
|
||||||
|
margin: 3em 0;
|
||||||
|
}
|
||||||
|
body.notesBodyType > section .title {
|
||||||
|
text-align: left;
|
||||||
|
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),
|
||||||
|
producer: $$('document-info author').map(getPerson)
|
||||||
|
.concat($$('document-info program-used').map(getElementText)),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
book.getCover = () => fetch(getImageSrc($('coverpage image')))
|
||||||
|
.then(res => res.blob())
|
||||||
|
|
||||||
|
// 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 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)
|
||||||
|
const title = trim(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}"]`)
|
||||||
|
|
||||||
|
return book
|
||||||
|
}
|
||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
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.nodeName === '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) 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Container {
|
||||||
|
#element = document.createElement('div')
|
||||||
|
defaultViewport
|
||||||
|
#portrait = false
|
||||||
|
#left
|
||||||
|
#right
|
||||||
|
#side
|
||||||
|
constructor() {
|
||||||
|
Object.assign(this.#element.style, {
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
})
|
||||||
|
new ResizeObserver(() => this.render()).observe(this.#element)
|
||||||
|
}
|
||||||
|
get element() {
|
||||||
|
return this.#element
|
||||||
|
}
|
||||||
|
get side() {
|
||||||
|
return this.#side
|
||||||
|
}
|
||||||
|
async #createFrame(src) {
|
||||||
|
const element = document.createElement('div')
|
||||||
|
const iframe = document.createElement('iframe')
|
||||||
|
element.append(iframe)
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
border: '0',
|
||||||
|
display: 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
iframe.setAttribute('scrolling', 'no')
|
||||||
|
iframe.classList.add('filter')
|
||||||
|
this.#element.append(element)
|
||||||
|
if (!src) return { blank: true, element, iframe }
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const onload = () => {
|
||||||
|
iframe.removeEventListener('load', onload)
|
||||||
|
this.onLoad?.(iframe)
|
||||||
|
const doc = iframe.contentDocument
|
||||||
|
const { width, height } = getViewport(doc, this.defaultViewport)
|
||||||
|
resolve({
|
||||||
|
element, iframe,
|
||||||
|
width: parseFloat(width),
|
||||||
|
height: parseFloat(height),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
iframe.addEventListener('load', onload)
|
||||||
|
iframe.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
render(side = this.#side) {
|
||||||
|
if (!side) return
|
||||||
|
const left = this.#left
|
||||||
|
const right = this.#right
|
||||||
|
const target = side === 'left' ? left : right
|
||||||
|
const { width, height } = this.#element.getBoundingClientRect()
|
||||||
|
const portrait = height > width
|
||||||
|
this.#portrait = portrait
|
||||||
|
const blankWidth = left.width ?? right.width
|
||||||
|
const blankHeight = left.height ?? right.height
|
||||||
|
|
||||||
|
const scale = portrait
|
||||||
|
? 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))
|
||||||
|
|
||||||
|
const transform = frame => {
|
||||||
|
const { element, iframe, width, height } = frame
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
display: 'block',
|
||||||
|
})
|
||||||
|
Object.assign(element.style, {
|
||||||
|
width: `${(width ?? blankWidth) * scale}px`,
|
||||||
|
height: `${(height ?? blankHeight) * scale}px`,
|
||||||
|
display: 'block',
|
||||||
|
})
|
||||||
|
if (portrait && frame !== target) {
|
||||||
|
element.style.display = 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transform(left, 'left')
|
||||||
|
transform(right, 'right')
|
||||||
|
}
|
||||||
|
async showSpread({ left, right, center, side }) {
|
||||||
|
this.#element.replaceChildren()
|
||||||
|
this.#left = null
|
||||||
|
this.#right = null
|
||||||
|
if (center) {
|
||||||
|
// TODO
|
||||||
|
} else {
|
||||||
|
this.#left = await this.#createFrame(left)
|
||||||
|
this.#right = await this.#createFrame(right)
|
||||||
|
this.#side = side
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
goLeft() {
|
||||||
|
if (this.#left?.blank) return true
|
||||||
|
if (this.#portrait && this.#left?.element?.style?.display === 'none') {
|
||||||
|
this.#right.element.style.display = 'none'
|
||||||
|
this.#left.element.style.display = 'block'
|
||||||
|
this.#side = 'left'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
goRight() {
|
||||||
|
if (this.#right?.blank) return true
|
||||||
|
if (this.#portrait && this.#right?.element?.style?.display === 'none') {
|
||||||
|
this.#left.element.style.display = 'none'
|
||||||
|
this.#right.element.style.display = 'block'
|
||||||
|
this.#side = 'right'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FixedLayout {
|
||||||
|
#spreads
|
||||||
|
#index = -1
|
||||||
|
#container = new Container()
|
||||||
|
constructor({ book, onLoad, onRelocated }) {
|
||||||
|
this.book = book
|
||||||
|
this.#container.defaultViewport = book.rendition?.viewport
|
||||||
|
this.onLoad = onLoad
|
||||||
|
this.onRelocated = onRelocated
|
||||||
|
|
||||||
|
const rtl = book.dir === 'rtl'
|
||||||
|
const ltr = !rtl
|
||||||
|
this.rtl = rtl
|
||||||
|
this.#spreads = book.sections.reduce((arr, section) => {
|
||||||
|
const last = arr[arr.length - 1]
|
||||||
|
const { linear, forceCenter, forceLeft, forceRight } = section
|
||||||
|
if (linear === 'no') return arr
|
||||||
|
const newSpread = () => {
|
||||||
|
const spread = {}
|
||||||
|
arr.push(spread)
|
||||||
|
return spread
|
||||||
|
}
|
||||||
|
if (forceCenter) newSpread().center = section
|
||||||
|
else if (forceLeft) {
|
||||||
|
const spread = last.center || last.left || ltr ? newSpread() : last
|
||||||
|
spread.left = section
|
||||||
|
}
|
||||||
|
else if (forceRight) {
|
||||||
|
const spread = last.center || last.right || rtl ? newSpread() : last
|
||||||
|
spread.right = section
|
||||||
|
}
|
||||||
|
else if (ltr) {
|
||||||
|
if (last.center || last.right) newSpread().left = section
|
||||||
|
else if (last.left) last.right = section
|
||||||
|
else last.left = section
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (last.center || last.left) newSpread().right = section
|
||||||
|
else if (last.right) last.left = section
|
||||||
|
else last .right = section
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}, [{}])
|
||||||
|
}
|
||||||
|
get element() {
|
||||||
|
return this.#container.element
|
||||||
|
}
|
||||||
|
get index() {
|
||||||
|
const spread = this.#spreads[this.#index]
|
||||||
|
const section = spread?.center ?? (this.#container.side === 'left'
|
||||||
|
? spread.left ?? spread.right : spread.right ?? spread.left)
|
||||||
|
return this.book.sections.indexOf(section)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if (index < 0 || index > this.#spreads.length - 1) return
|
||||||
|
if (index === this.#index) {
|
||||||
|
this.#container.render(side)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.#index = index
|
||||||
|
const spread = this.#spreads[index]
|
||||||
|
if (spread.center) {
|
||||||
|
const center = await spread.center?.load?.()
|
||||||
|
await this.#container.showSpread({ center, side })
|
||||||
|
} else {
|
||||||
|
const left = await spread.left?.load?.()
|
||||||
|
const right = await spread.right?.load?.()
|
||||||
|
await this.#container.showSpread({ left, right, side })
|
||||||
|
}
|
||||||
|
this.onRelocated?.(null, this.index, 0, 1)
|
||||||
|
}
|
||||||
|
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.#container.goLeft() : this.#container.goRight()
|
||||||
|
if (s) this.onRelocated?.(null, this.index, 0, 1)
|
||||||
|
else return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left')
|
||||||
|
}
|
||||||
|
async prev() {
|
||||||
|
const s = this.rtl ? this.#container.goRight() : this.#container.goLeft()
|
||||||
|
if (s) this.onRelocated?.(null, this.index, 0, 1)
|
||||||
|
else return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right')
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
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',
|
||||||
|
})
|
||||||
|
const darkMode = matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const setBlendMode = () => this.#svg.style.mixBlendMode =
|
||||||
|
darkMode.matches ? 'normal' : 'multiply'
|
||||||
|
darkMode.addEventListener('change', setBlendMode)
|
||||||
|
setBlendMode()
|
||||||
|
}
|
||||||
|
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 = {}) {
|
||||||
|
// TODO: in vertical-rl, the bōsen (sideline) should be on the right
|
||||||
|
const { color = 'red', width: strokeWidth = 2 } = options
|
||||||
|
const g = createSVGElement('g')
|
||||||
|
g.setAttribute('fill', color)
|
||||||
|
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 highlight(rects, options = {}) {
|
||||||
|
const { color = 'red' } = options
|
||||||
|
const g = createSVGElement('g')
|
||||||
|
g.setAttribute('fill', color)
|
||||||
|
g.setAttribute('fill-opacity', .3)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+645
@@ -0,0 +1,645 @@
|
|||||||
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
const debounce = (f, wait, immediate) => {
|
||||||
|
let timeout
|
||||||
|
return (...args) => {
|
||||||
|
const later = () => {
|
||||||
|
timeout = null
|
||||||
|
if (!immediate) f(...args)
|
||||||
|
}
|
||||||
|
const callNow = immediate && !timeout
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(later, wait)
|
||||||
|
if (callNow) f(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapsed range doesn't return client rects sometimes (or always?)
|
||||||
|
// try make get a non-collapsed range or element
|
||||||
|
const uncollapse = range => {
|
||||||
|
if (!range?.collapsed) return range
|
||||||
|
const { endOffset, endContainer } = range
|
||||||
|
if (endContainer.nodeType === 1) return endContainer
|
||||||
|
if (endOffset + 1 < endContainer.length) range.setEnd(endContainer, endOffset + 1)
|
||||||
|
else if (endOffset > 1) range.setStart(endContainer, endOffset - 1)
|
||||||
|
else return endContainer.parentNode
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeRange = (doc, node, start, end = start) => {
|
||||||
|
const range = doc.createRange()
|
||||||
|
range.setStart(node, start)
|
||||||
|
range.setEnd(node, end)
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
|
||||||
|
// use binary search to find an offset value in a text node
|
||||||
|
const bisectNode = (doc, node, cb, start = 0, end = node.nodeValue.length) => {
|
||||||
|
if (end - start === 1) {
|
||||||
|
const result = cb(makeRange(doc, node, start), makeRange(doc, node, end))
|
||||||
|
return result < 0 ? start : end
|
||||||
|
}
|
||||||
|
const mid = Math.floor(start + (end - start) / 2)
|
||||||
|
const result = cb(makeRange(doc, node, start, mid), makeRange(doc, node, mid, end))
|
||||||
|
return result < 0 ? bisectNode(doc, node, cb, start, mid)
|
||||||
|
: result > 0 ? bisectNode(doc, node, cb, mid, end) : mid
|
||||||
|
}
|
||||||
|
|
||||||
|
const { SHOW_ELEMENT, SHOW_TEXT, SHOW_CDATA_SECTION,
|
||||||
|
FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter
|
||||||
|
|
||||||
|
const filter = SHOW_ELEMENT | SHOW_TEXT | SHOW_CDATA_SECTION
|
||||||
|
|
||||||
|
const getVisibleRange = (doc, start, end, mapRect) => {
|
||||||
|
// first get all visible nodes
|
||||||
|
const acceptNode = node => {
|
||||||
|
const name = node.localName?.toLowerCase()
|
||||||
|
// ignore all scripts, styles, and their children
|
||||||
|
if (name === 'script' || name === 'style') return FILTER_REJECT
|
||||||
|
if (node.nodeType === 1) {
|
||||||
|
const { left, right } = mapRect(node.getBoundingClientRect())
|
||||||
|
// no need to check child nodes if it's completely out of view
|
||||||
|
if (right < start || left > end) return FILTER_REJECT
|
||||||
|
// elements must be completely in view to be considered visible
|
||||||
|
// because you can't specify offsets for elements
|
||||||
|
if (left >= start && right <= end) return FILTER_ACCEPT
|
||||||
|
// TODO: it should probably allow elements that do not contain text
|
||||||
|
// because they can exceed the whole viewport in both directions
|
||||||
|
// especially in scrolled mode
|
||||||
|
} else {
|
||||||
|
// ignore empty text nodes
|
||||||
|
if (!node.nodeValue?.trim()) return FILTER_SKIP
|
||||||
|
// create range to get rect
|
||||||
|
const range = doc.createRange()
|
||||||
|
range.selectNodeContents(node)
|
||||||
|
const { left, right } = mapRect(range.getBoundingClientRect())
|
||||||
|
// it's visible if any part of it is in view
|
||||||
|
if (right >= start && left <= end) return FILTER_ACCEPT
|
||||||
|
}
|
||||||
|
return FILTER_SKIP
|
||||||
|
}
|
||||||
|
const walker = doc.createTreeWalker(doc.body, filter, { acceptNode })
|
||||||
|
const nodes = []
|
||||||
|
for (let node = walker.nextNode(); node; node = walker.nextNode())
|
||||||
|
nodes.push(node)
|
||||||
|
|
||||||
|
// we're only interested in the first and last visible nodes
|
||||||
|
const from = nodes[0] ?? doc.body
|
||||||
|
const to = nodes[nodes.length - 1] ?? from
|
||||||
|
|
||||||
|
// find the offset at which visibility changes
|
||||||
|
const startOffset = from.nodeType === 1 ? 0
|
||||||
|
: bisectNode(doc, from, (a, b) => {
|
||||||
|
const p = mapRect(a.getBoundingClientRect())
|
||||||
|
const q = mapRect(b.getBoundingClientRect())
|
||||||
|
if (p.right < start && q.left > start) return 0
|
||||||
|
return q.left > start ? -1 : 1
|
||||||
|
})
|
||||||
|
const endOffset = to.nodeType === 1 ? 0
|
||||||
|
: bisectNode(doc, to, (a, b) => {
|
||||||
|
const p = mapRect(a.getBoundingClientRect())
|
||||||
|
const q = mapRect(b.getBoundingClientRect())
|
||||||
|
if (p.right < end && q.left > end) return 0
|
||||||
|
return q.left > end ? -1 : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const range = doc.createRange()
|
||||||
|
range.setStart(from, startOffset)
|
||||||
|
range.setEnd(to, endOffset)
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDirection = doc => {
|
||||||
|
const { defaultView } = doc
|
||||||
|
const { writingMode, direction } = defaultView.getComputedStyle(doc.body)
|
||||||
|
const vertical = writingMode === 'vertical-rl'
|
||||||
|
|| writingMode === 'vertical-lr'
|
||||||
|
const rtl = doc.body.dir === 'rtl'
|
||||||
|
|| direction === 'rtl'
|
||||||
|
|| doc.documentElement.dir === 'rtl'
|
||||||
|
return { vertical, rtl }
|
||||||
|
}
|
||||||
|
|
||||||
|
class View {
|
||||||
|
#element = document.createElement('div')
|
||||||
|
#iframe = document.createElement('iframe')
|
||||||
|
#contentRange = document.createRange()
|
||||||
|
#overlayers = {}
|
||||||
|
#vertical = false
|
||||||
|
#rtl = false
|
||||||
|
#column = true
|
||||||
|
#size
|
||||||
|
#layout = {}
|
||||||
|
constructor({ container }) {
|
||||||
|
this.container = container
|
||||||
|
this.#iframe.classList.add('filter')
|
||||||
|
this.#element.append(this.#iframe)
|
||||||
|
Object.assign(this.#element.style, {
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
})
|
||||||
|
Object.assign(this.#iframe.style, {
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '0',
|
||||||
|
display: 'none',
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
})
|
||||||
|
// `allow-scripts` is needed for events because of WebKit bug
|
||||||
|
// https://bugs.webkit.org/show_bug.cgi?id=218086
|
||||||
|
this.#iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
|
||||||
|
this.#iframe.setAttribute('scrolling', 'no')
|
||||||
|
}
|
||||||
|
get element() {
|
||||||
|
return this.#element
|
||||||
|
}
|
||||||
|
get document() {
|
||||||
|
return this.#iframe.contentDocument
|
||||||
|
}
|
||||||
|
async load(src, afterLoad, beforeRender) {
|
||||||
|
if (typeof src !== 'string') throw new Error(`${src} is not string`)
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.#iframe.addEventListener('load', () => {
|
||||||
|
const doc = this.document
|
||||||
|
afterLoad?.(doc)
|
||||||
|
|
||||||
|
const { vertical, rtl } = getDirection(doc)
|
||||||
|
this.#vertical = vertical
|
||||||
|
this.#rtl = rtl
|
||||||
|
|
||||||
|
this.#contentRange.selectNodeContents(doc.body)
|
||||||
|
this.render(beforeRender?.({ vertical, rtl }))
|
||||||
|
new ResizeObserver(() => this.expand()).observe(doc.body)
|
||||||
|
|
||||||
|
resolve()
|
||||||
|
}, { once: true })
|
||||||
|
this.#iframe.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
render(layout) {
|
||||||
|
this.#column = layout.flow !== 'scrolled'
|
||||||
|
this.#layout = layout
|
||||||
|
if (this.#column) this.columnize(layout)
|
||||||
|
else this.scrolled(layout)
|
||||||
|
}
|
||||||
|
scrolled({ gap, columnWidth }) {
|
||||||
|
const vertical = this.#vertical
|
||||||
|
this.#iframe.style.display = 'block'
|
||||||
|
const doc = this.document
|
||||||
|
Object.assign(doc.documentElement.style, {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: vertical ? `${gap}px 0` : `0 ${gap}px`,
|
||||||
|
columnWidth: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
width: 'auto',
|
||||||
|
})
|
||||||
|
Object.assign(doc.body.style, {
|
||||||
|
[vertical ? 'maxHeight' : 'maxWidth']: `${columnWidth}px`,
|
||||||
|
margin: 'auto',
|
||||||
|
})
|
||||||
|
this.setImageSize()
|
||||||
|
this.expand()
|
||||||
|
}
|
||||||
|
columnize({ width, height, margin, gap, columnWidth }) {
|
||||||
|
const vertical = this.#vertical
|
||||||
|
this.#size = vertical ? height : width
|
||||||
|
this.#iframe.style.display = 'block'
|
||||||
|
|
||||||
|
const doc = this.document
|
||||||
|
const gapPadding = `${gap / 2}px`
|
||||||
|
const marginPadding = `${margin}px`
|
||||||
|
Object.assign(doc.documentElement.style, {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
columnWidth: `${columnWidth}px`,
|
||||||
|
columnGap: `${gap}px`,
|
||||||
|
columnFill: 'auto',
|
||||||
|
...(vertical
|
||||||
|
? { width: `${width}px` }
|
||||||
|
: { height: `${height}px` }),
|
||||||
|
padding: (vertical
|
||||||
|
? [gapPadding, marginPadding]
|
||||||
|
: [marginPadding, gapPadding]).join(' '),
|
||||||
|
overflow: 'hidden',
|
||||||
|
// force wrap long words
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
// reset some potentially problematic props
|
||||||
|
position: 'static', border: '0', margin: '0',
|
||||||
|
maxHeight: 'none', maxWidth: 'none',
|
||||||
|
minHeight: 'none', minWidth: 'none',
|
||||||
|
})
|
||||||
|
Object.assign(doc.body.style, {
|
||||||
|
maxHeight: 'none',
|
||||||
|
maxWidth: 'none',
|
||||||
|
margin: '0',
|
||||||
|
})
|
||||||
|
this.setImageSize()
|
||||||
|
this.expand()
|
||||||
|
}
|
||||||
|
setImageSize() {
|
||||||
|
const { width, height, margin } = this.#layout
|
||||||
|
const vertical = this.#vertical
|
||||||
|
const doc = this.document
|
||||||
|
for (const el of doc.body.querySelectorAll('img, svg, video')) {
|
||||||
|
// preserve max size if they are already set
|
||||||
|
const { maxHeight, maxWidth } = doc.defaultView.getComputedStyle(el)
|
||||||
|
Object.assign(el.style, {
|
||||||
|
maxHeight: vertical
|
||||||
|
? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%')
|
||||||
|
: `${height - margin * 2}px`,
|
||||||
|
maxWidth: vertical
|
||||||
|
? `${width - margin * 2}px`
|
||||||
|
: (maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%'),
|
||||||
|
objectFit: 'contain',
|
||||||
|
pageBreakInside: 'avoid',
|
||||||
|
breakInside: 'avoid',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expand() {
|
||||||
|
if (this.#column) {
|
||||||
|
const side = this.#vertical ? 'height' : 'width'
|
||||||
|
const otherSide = this.#vertical ? 'width' : 'height'
|
||||||
|
const contentSize = this.#contentRange.getBoundingClientRect()[side]
|
||||||
|
const pageCount = Math.ceil(contentSize / this.#size)
|
||||||
|
const expandedSize = pageCount * this.#size
|
||||||
|
this.#element.style.padding = '0'
|
||||||
|
this.#iframe.style[side] = `${expandedSize}px`
|
||||||
|
this.#element.style[side] = `${expandedSize}px`
|
||||||
|
this.#iframe.style[otherSide] = '100%'
|
||||||
|
this.#element.style[otherSide] = '100%'
|
||||||
|
for (const overlayer of Object.values(this.#overlayers)) {
|
||||||
|
overlayer.element.style.margin = '0'
|
||||||
|
overlayer.element.style[side] = `${expandedSize}px`
|
||||||
|
overlayer.redraw()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const side = this.#vertical ? 'width' : 'height'
|
||||||
|
const otherSide = this.#vertical ? 'height' : 'width'
|
||||||
|
const doc = this.document
|
||||||
|
const contentSize = doc?.documentElement?.getBoundingClientRect()?.[side]
|
||||||
|
const expandedSize = contentSize
|
||||||
|
const { margin } = this.#layout
|
||||||
|
const padding = this.#vertical ? `0 ${margin}px` : `${margin}px 0`
|
||||||
|
this.#element.style.padding = padding
|
||||||
|
this.#iframe.style[side] = `${expandedSize}px`
|
||||||
|
this.#element.style[side] = `${expandedSize}px`
|
||||||
|
this.#iframe.style[otherSide] = '100%'
|
||||||
|
this.#element.style[otherSide] = '100%'
|
||||||
|
for (const overlayer of Object.values(this.#overlayers)) {
|
||||||
|
overlayer.element.style.margin = padding
|
||||||
|
overlayer.element.style[side] = `${expandedSize}px`
|
||||||
|
overlayer.redraw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set overlayers(overlayers) {
|
||||||
|
this.#overlayers = overlayers
|
||||||
|
for (const overlayer of Object.values(overlayers))
|
||||||
|
this.#element.append(overlayer.element)
|
||||||
|
}
|
||||||
|
get overlayers() {
|
||||||
|
return this.#overlayers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: everything here assumes the so-called "negative scroll type" for RTL
|
||||||
|
export class Paginator {
|
||||||
|
#element = document.createElement('div')
|
||||||
|
#view
|
||||||
|
#vertical = false
|
||||||
|
#rtl = false
|
||||||
|
#index = -1
|
||||||
|
#anchor = 0 // anchor view to a fraction (0-1), Range, or Element
|
||||||
|
#locked = false // while true, prevent any further navigation
|
||||||
|
#styleMap = new WeakMap()
|
||||||
|
layout = {
|
||||||
|
margin: 48,
|
||||||
|
gap: 40,
|
||||||
|
maxColumnWidth: 700,
|
||||||
|
}
|
||||||
|
constructor({ book, onLoad, onRelocated, createOverlayers }) {
|
||||||
|
this.sections = book.sections
|
||||||
|
this.onLoad = onLoad
|
||||||
|
this.onRelocated = onRelocated
|
||||||
|
this.createOverlayers = createOverlayers
|
||||||
|
Object.assign(this.#element.style, {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
})
|
||||||
|
new ResizeObserver(() => this.render()).observe(this.#element)
|
||||||
|
this.#element.addEventListener('scroll', debounce(() => {
|
||||||
|
if (this.scrolled) this.#afterScroll('scroll')
|
||||||
|
}, 250))
|
||||||
|
}
|
||||||
|
get element() {
|
||||||
|
return this.#element
|
||||||
|
}
|
||||||
|
#createView() {
|
||||||
|
if (this.#view) this.#element.removeChild(this.#view.element)
|
||||||
|
this.#view = new View({ container: this.#element })
|
||||||
|
this.#element.append(this.#view.element)
|
||||||
|
return this.#view
|
||||||
|
}
|
||||||
|
#beforeRender({ vertical, rtl }) {
|
||||||
|
this.#vertical = vertical
|
||||||
|
this.#rtl = rtl
|
||||||
|
const { flow, margin, gap, maxColumnWidth } = this.layout
|
||||||
|
if (flow === 'scrolled') {
|
||||||
|
// FIXME: vertical-rl only, not -lr
|
||||||
|
this.#element.setAttribute('dir', vertical ? 'rtl' : 'ltr')
|
||||||
|
Object.assign(this.#element.style, {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
margin: '0',
|
||||||
|
overflow: 'scroll',
|
||||||
|
})
|
||||||
|
const columnWidth = this.layout.maxColumnWidth
|
||||||
|
return { flow, margin, gap, columnWidth }
|
||||||
|
}
|
||||||
|
const { width, height } = this.#element.getBoundingClientRect()
|
||||||
|
const size = vertical ? height : width
|
||||||
|
const divisor = Math.ceil(size / maxColumnWidth)
|
||||||
|
const columnWidth = (size / divisor) - gap
|
||||||
|
this.#element.setAttribute('dir', rtl ? 'rtl' : 'ltr')
|
||||||
|
Object.assign(this.#element.style, {
|
||||||
|
width: vertical ? '100%' : `calc(100% - ${gap}px)`,
|
||||||
|
height: vertical ? `calc(100% - ${margin}px)` : '100%',
|
||||||
|
marginLeft: vertical ? '0' : `${gap / 2}px`,
|
||||||
|
marginTop: vertical ? `${margin / 2}px` : '0',
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
return { height, width, margin, gap, columnWidth }
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
if (!this.#view) return
|
||||||
|
this.#view.render(this.#beforeRender({
|
||||||
|
vertical: this.#vertical,
|
||||||
|
rtl: this.#rtl,
|
||||||
|
}))
|
||||||
|
this.#scrollToAnchor()
|
||||||
|
}
|
||||||
|
get scrolled() {
|
||||||
|
return this.layout.flow === 'scrolled'
|
||||||
|
}
|
||||||
|
get scrollProp() {
|
||||||
|
const { scrolled } = this
|
||||||
|
return this.#vertical ? (scrolled ? 'scrollLeft' : 'scrollTop')
|
||||||
|
: scrolled ? 'scrollTop' : 'scrollLeft'
|
||||||
|
}
|
||||||
|
get sideProp() {
|
||||||
|
const { scrolled } = this
|
||||||
|
return this.#vertical ? (scrolled ? 'width' : 'height')
|
||||||
|
: scrolled ? 'height' : 'width'
|
||||||
|
}
|
||||||
|
get size() {
|
||||||
|
return this.#element.getBoundingClientRect()[this.sideProp]
|
||||||
|
}
|
||||||
|
get viewSize() {
|
||||||
|
return this.#view.element.getBoundingClientRect()[this.sideProp]
|
||||||
|
}
|
||||||
|
get start() {
|
||||||
|
return Math.abs(this.#element[this.scrollProp])
|
||||||
|
}
|
||||||
|
get end() {
|
||||||
|
return this.start + this.size
|
||||||
|
}
|
||||||
|
get page() {
|
||||||
|
return Math.floor(((this.start + this.end) / 2) / this.size)
|
||||||
|
}
|
||||||
|
get pages() {
|
||||||
|
return this.viewSize / this.size
|
||||||
|
}
|
||||||
|
// allows one to process rects as if they were LTR and horizontal
|
||||||
|
#getRectMapper() {
|
||||||
|
if (this.scrolled) {
|
||||||
|
const size = this.viewSize
|
||||||
|
const margin = this.layout.margin
|
||||||
|
return this.#vertical
|
||||||
|
? ({ left, right }) =>
|
||||||
|
({ left: size - right - margin, right: size - left - margin })
|
||||||
|
: ({ top, bottom }) => ({ left: top + margin, right: bottom + margin })
|
||||||
|
}
|
||||||
|
const pxSize = this.pages * this.size
|
||||||
|
return this.#rtl
|
||||||
|
? ({ left, right }) =>
|
||||||
|
({ left: pxSize - right, right: pxSize - left })
|
||||||
|
: this.#vertical
|
||||||
|
? ({ top, bottom }) => ({ left: top, right: bottom })
|
||||||
|
: f => f
|
||||||
|
}
|
||||||
|
async #scrollToRect(rect, reason) {
|
||||||
|
if (this.scrolled) {
|
||||||
|
const offset = this.#getRectMapper()(rect).left
|
||||||
|
return this.#scrollTo(offset, reason)
|
||||||
|
}
|
||||||
|
const offset = this.#getRectMapper()(rect).left
|
||||||
|
+ this.layout.margin / 2
|
||||||
|
return this.#scrollToPage(Math.floor(offset / this.size), reason)
|
||||||
|
}
|
||||||
|
async #scrollTo(offset, reason) {
|
||||||
|
const element = this.#element
|
||||||
|
const { scrollProp } = this
|
||||||
|
if (element[scrollProp] === offset) {
|
||||||
|
this.#afterScroll(reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// FIXME: vertical-rl only, not -lr
|
||||||
|
if (this.scrolled && this.#vertical) offset = -offset
|
||||||
|
element[scrollProp] = offset
|
||||||
|
this.#afterScroll(reason)
|
||||||
|
/*return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const onScroll = () => {
|
||||||
|
if (element[scrollProp] - offset > 2) return
|
||||||
|
element.removeEventListener('scroll', onScroll)
|
||||||
|
resolve()
|
||||||
|
this.#afterScroll(reason)
|
||||||
|
}
|
||||||
|
element.addEventListener('scroll', onScroll)
|
||||||
|
if (this.scrolled) {
|
||||||
|
const coord = scrollProp === 'scrollLeft' ? 'left' : 'top'
|
||||||
|
element.scrollTo({ [coord]: offset, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
element[scrollProp] = offset
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})*/
|
||||||
|
}
|
||||||
|
async #scrollToPage(page, reason) {
|
||||||
|
const offset = this.size * (this.#rtl ? -page : page)
|
||||||
|
return this.#scrollTo(offset, reason)
|
||||||
|
}
|
||||||
|
async #scrollToAnchor(select) {
|
||||||
|
const rect = uncollapse(this.#anchor).getBoundingClientRect?.()
|
||||||
|
// if anchor is an element or a range
|
||||||
|
if (rect) {
|
||||||
|
await this.#scrollToRect(rect, 'anchor')
|
||||||
|
if (select) this.#selectAnchor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// if anchor is a fraction
|
||||||
|
if (this.scrolled) {
|
||||||
|
await this.#scrollTo(this.#anchor * this.viewSize, 'anchor')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { pages } = this
|
||||||
|
if (!pages) return
|
||||||
|
const newPage = Math.round(this.#anchor * (pages - 1))
|
||||||
|
await this.#scrollToPage(newPage, 'anchor')
|
||||||
|
}
|
||||||
|
#selectAnchor() {
|
||||||
|
const { defaultView } = this.#view.document
|
||||||
|
if (this.#anchor instanceof defaultView.Range) {
|
||||||
|
const sel = defaultView.getSelection()
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(this.#anchor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#getVisibleRange() {
|
||||||
|
return getVisibleRange(this.#view.document,
|
||||||
|
this.start, this.end, this.#getRectMapper(), this.scrolled)
|
||||||
|
}
|
||||||
|
#afterScroll(reason) {
|
||||||
|
const range = this.#getVisibleRange()
|
||||||
|
// don't set new anchor if relocation was to scroll to anchor
|
||||||
|
if (reason !== 'anchor') this.#anchor = range
|
||||||
|
const index = this.#index
|
||||||
|
if (this.scrolled)
|
||||||
|
this.onRelocated?.(range, index, this.end / this.viewSize)
|
||||||
|
else if (this.pages > 0)
|
||||||
|
this.onRelocated?.(range, index, (this.page + 1) / this.pages)
|
||||||
|
}
|
||||||
|
async #display(promise) {
|
||||||
|
const { index, src, anchor, onLoad, select } = await promise
|
||||||
|
this.#index = index
|
||||||
|
if (src) {
|
||||||
|
const view = this.#createView()
|
||||||
|
const afterLoad = doc => {
|
||||||
|
if (doc.head) {
|
||||||
|
const $style = doc.createElement('style')
|
||||||
|
doc.head.append($style)
|
||||||
|
this.#styleMap.set(doc, $style)
|
||||||
|
}
|
||||||
|
onLoad?.(doc, index)
|
||||||
|
}
|
||||||
|
const beforeRender = this.#beforeRender.bind(this)
|
||||||
|
await view.load(src, afterLoad, beforeRender)
|
||||||
|
const overlayers = this.createOverlayers?.(view.document, index)
|
||||||
|
if (overlayers) view.overlayers = overlayers
|
||||||
|
this.#view = view
|
||||||
|
}
|
||||||
|
this.#anchor = (typeof anchor === 'function'
|
||||||
|
? anchor(this.#view.document) : anchor) ?? 0
|
||||||
|
await this.#scrollToAnchor(select)
|
||||||
|
}
|
||||||
|
#canScrollToPage(page) {
|
||||||
|
return page > -1 && page < this.pages
|
||||||
|
}
|
||||||
|
scrollPrev() {
|
||||||
|
if (!this.#view) return null
|
||||||
|
if (this.scrolled) {
|
||||||
|
if (this.start > 0)
|
||||||
|
return this.#scrollTo(Math.max(0, this.start - this.size))
|
||||||
|
else return null
|
||||||
|
}
|
||||||
|
const page = this.page - 1
|
||||||
|
if (this.#canScrollToPage(page)) return this.#scrollToPage(page)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
scrollNext() {
|
||||||
|
if (!this.#view) return null
|
||||||
|
if (this.scrolled) {
|
||||||
|
if (this.viewSize - this.end > 2)
|
||||||
|
return this.#scrollTo(Math.min(this.viewSize, this.end))
|
||||||
|
else return null
|
||||||
|
}
|
||||||
|
const page = this.page + 1
|
||||||
|
if (this.#canScrollToPage(page)) return this.#scrollToPage(page)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
#canGoToIndex(index) {
|
||||||
|
return index >= 0 && index <= this.sections.length - 1
|
||||||
|
}
|
||||||
|
async #goTo(tryScroll, target, lock) {
|
||||||
|
if (this.#locked) return
|
||||||
|
if (lock) this.#locked = true
|
||||||
|
const scroll = tryScroll?.()
|
||||||
|
if (scroll) await scroll
|
||||||
|
else {
|
||||||
|
const { index, anchor, select } = await target
|
||||||
|
if (!this.#canGoToIndex(index)) {
|
||||||
|
this.#locked = false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (index === this.#index) await this.#display({ index, anchor, select })
|
||||||
|
else {
|
||||||
|
const oldIndex = this.#index
|
||||||
|
const onLoad = (...args) => {
|
||||||
|
this.sections[oldIndex]?.unload?.()
|
||||||
|
this.onLoad?.(...args)
|
||||||
|
}
|
||||||
|
await this.#display(Promise.resolve(this.sections[index].load())
|
||||||
|
.then(src => ({ index, src, anchor, onLoad, select }))
|
||||||
|
.catch(e => {
|
||||||
|
console.warn(e)
|
||||||
|
console.warn(new Error(`Failed to load section ${index}`))
|
||||||
|
return {}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lock) {
|
||||||
|
await wait(100) // throttle by 100ms
|
||||||
|
this.#locked = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async goTo(target) {
|
||||||
|
return this.#goTo(null, target)
|
||||||
|
}
|
||||||
|
#adjacentIndex(dir) {
|
||||||
|
for (let index = this.#index + dir; this.#canGoToIndex(index); index += dir)
|
||||||
|
if (this.sections[index]?.linear !== 'no') return index
|
||||||
|
}
|
||||||
|
prev() {
|
||||||
|
const index = this.#adjacentIndex(-1)
|
||||||
|
return this.#goTo(() => this.scrollPrev(), { index, anchor: () => 1 }, true)
|
||||||
|
}
|
||||||
|
next() {
|
||||||
|
const index = this.#adjacentIndex(1)
|
||||||
|
return this.#goTo(() => this.scrollNext(), { index }, true)
|
||||||
|
}
|
||||||
|
prevSection() {
|
||||||
|
return this.goTo({ index: this.#adjacentIndex(-1) })
|
||||||
|
}
|
||||||
|
nextSection() {
|
||||||
|
return this.goTo({ index: this.#adjacentIndex(1) })
|
||||||
|
}
|
||||||
|
firstSection() {
|
||||||
|
const index = this.sections.findIndex(section => section.linear !== 'no')
|
||||||
|
return this.goTo({ index })
|
||||||
|
}
|
||||||
|
lastSection() {
|
||||||
|
const index = this.sections.findLastIndex(section => section.linear !== 'no')
|
||||||
|
return this.goTo({ index })
|
||||||
|
}
|
||||||
|
getOverlayers() {
|
||||||
|
if (!this.#view) return []
|
||||||
|
return [{
|
||||||
|
index: this.#index,
|
||||||
|
overlayers: this.#view.overlayers,
|
||||||
|
document: this.#view.document,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
setStyle(style) {
|
||||||
|
const $style = this.#styleMap.get(this.#view?.document)
|
||||||
|
if ($style) $style.textContent = style
|
||||||
|
}
|
||||||
|
async #setAnchor(anchor, select) {
|
||||||
|
this.#anchor = anchor
|
||||||
|
await this.#scrollToAnchor(select)
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
// 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 {
|
||||||
|
constructor({ toc, ids, splitHref, getFragment }) {
|
||||||
|
assignIDs(toc)
|
||||||
|
const items = flatten(toc)
|
||||||
|
const grouped = new Map()
|
||||||
|
for (const [i, item] of items.entries()) {
|
||||||
|
const [id, fragment] = 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) {
|
||||||
|
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' ? 0 : s.size)
|
||||||
|
this.sizePerLoc = sizePerLoc
|
||||||
|
this.sizePerTimeUnit = sizePerTimeUnit
|
||||||
|
this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0)
|
||||||
|
}
|
||||||
|
// get progress given index of and fractions within a section
|
||||||
|
getProgress(index, fractionInSection) {
|
||||||
|
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 remainingTotal = sizeTotal - size
|
||||||
|
const remainingSection = (1 - fractionInSection) * sizeInSection
|
||||||
|
return {
|
||||||
|
fraction: size / sizeTotal,
|
||||||
|
section: {
|
||||||
|
current: index,
|
||||||
|
total: sizes.length,
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
current: Math.floor(size / 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) {
|
||||||
|
const { sizes, sizeTotal } = this
|
||||||
|
const target = fraction * sizeTotal
|
||||||
|
let index = -1
|
||||||
|
let fractionInSection = 0
|
||||||
|
let sum = 0
|
||||||
|
for (const [i, size] of sizes.entries()) {
|
||||||
|
const newSum = sum + size
|
||||||
|
if (newSum > target) {
|
||||||
|
index = i
|
||||||
|
fractionInSection = (target - sum) / size
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sum = newSum
|
||||||
|
}
|
||||||
|
return [index, fractionInSection]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: maybe use this for exact matches as it would be faster
|
||||||
|
/*
|
||||||
|
export const simpleSearch = function* (strs, query, locales = 'en') {
|
||||||
|
const haystack = strs.join('')
|
||||||
|
const lowerHaystack = haystack.toLocaleLowerCase(locales)
|
||||||
|
const needle = query.toLocaleLowerCase(locales)
|
||||||
|
const needleLength = needle.length
|
||||||
|
let index = -1
|
||||||
|
do {
|
||||||
|
index = lowerHaystack.indexOf(needle, index + 1)
|
||||||
|
if (index > -1) {
|
||||||
|
const end = index + needleLength
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
} 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 searchMatcher = (textWalker, opts) => {
|
||||||
|
const { defalutLocale, matchCase, matchDiacritics, matchWholeWords } = opts
|
||||||
|
return function* (doc, query) {
|
||||||
|
const iter = textWalker(doc, function* (strs, makeRange) {
|
||||||
|
for (const result of segmenterSearch(strs, query, {
|
||||||
|
locales: doc.body.lang || doc.documentElement.lang || defalutLocale || '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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for (const result of iter) yield result
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user