Add files via upload

This commit is contained in:
John Factotum
2022-10-18 18:55:54 +00:00
committed by GitHub
parent c986ba68e5
commit 5fc72d121c
10 changed files with 3801 additions and 0 deletions
+40
View File
@@ -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
}
+722
View File
@@ -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
View File
@@ -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,
}
+329
View File
@@ -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
View File
@@ -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')
}
}
+1157
View File
File diff suppressed because it is too large Load Diff
+103
View File
@@ -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
View File
@@ -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
View File
@@ -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]
}
}
+114
View File
@@ -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
}
}