UNPKG

@xincmm/foliate-js

Version:
1,293 lines (1,224 loc) 974 kB
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/; 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))})`; 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); 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] }) }; const compare = (a, b) => { if (typeof a === 'string') a = parse(a); if (typeof b === 'string') b = parse(b); if (a.start || b.start) return compare(collapse(a), collapse(b)) || compare(collapse(a, true), collapse(b, true)) for (let i = 0; i < Math.max(a.length, b.length); i++) { const p = a[i] ?? [], q = b[i] ?? []; const maxIndex = Math.max(p.length, q.length) - 1; for (let i = 0; i <= maxIndex; i++) { const x = p[i], y = q[i]; if (!x) return -1 if (!y) return 1 if (x.index > y.index) return 1 if (x.index < y.index) return -1 if (i === maxIndex) { // TODO: compare temporal & spatial offsets if (x.offset > y.offset) return 1 if (x.offset < y.offset) return -1 } } } return 0 }; const isTextNode = ({ nodeType }) => nodeType === 3 || nodeType === 4; const isElementNode = ({ nodeType }) => nodeType === 1; const getChildNodes = (node, filter) => { const nodes = Array.from(node.childNodes) // "content other than element and character data is ignored" .filter(node => isTextNode(node) || isElementNode(node)); return filter ? nodes.map(node => { const accept = filter(node); if (accept === NodeFilter.FILTER_REJECT) return null else if (accept === NodeFilter.FILTER_SKIP) return getChildNodes(node, filter) else return node }).flat().filter(x => x) : nodes }; // child nodes are organized such that the result is always // [element, text, element, text, ..., element], // regardless of the actual structure in the document; // so multiple text nodes need to be combined, and nonexistent ones counted; // see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec const indexChildNodes = (node, filter) => { const nodes = getChildNodes(node, filter) .reduce((arr, node) => { let last = arr[arr.length - 1]; if (!last) arr.push(node); // "there is one chunk between each pair of child elements" else if (isTextNode(node)) { if (Array.isArray(last)) last.push(node); else if (isTextNode(last)) arr[arr.length - 1] = [last, node]; else arr.push(node); } else { if (isElementNode(last)) arr.push(null, node); else arr.push(node); } return arr }, []); // "the first chunk is located before the first child element" if (isElementNode(nodes[0])) nodes.unshift('first'); // "the last chunk is located after the last child element" if (isElementNode(nodes[nodes.length - 1])) nodes.push('last'); // "'virtual' elements" nodes.unshift('before'); // "0 is a valid index" nodes.push('after'); // "n+2 is a valid index" return nodes }; const partsToNode = (node, parts, filter) => { const { id } = parts[parts.length - 1]; if (id) { const el = node.ownerDocument.getElementById(id); if (el) return { node: el, offset: 0 } } for (const { index } of parts) { const newNode = node ? indexChildNodes(node, filter)[index] : null; // handle non-existent nodes if (newNode === 'first') return { node: node.firstChild ?? node } if (newNode === 'last') return { node: node.lastChild ?? node } if (newNode === 'before') return { node, before: true } if (newNode === 'after') return { node, after: true } node = newNode; } const { offset } = parts[parts.length - 1]; if (!Array.isArray(node)) return { node, offset } // get underlying text node and offset from the chunk let sum = 0; for (const n of node) { const { length } = n.nodeValue; if (sum + length >= offset) return { node: n, offset: offset - sum } sum += length; } }; const nodeToParts = (node, offset, filter) => { const { parentNode, id } = node; const indexed = indexChildNodes(parentNode, filter); const index = indexed.findIndex(x => Array.isArray(x) ? x.some(x => x === node) : x === node); // adjust offset as if merging the text nodes in the chunk const chunk = indexed[index]; if (Array.isArray(chunk)) { let sum = 0; for (const x of chunk) { if (x === node) { sum += offset; break } else sum += x.nodeValue.length; } offset = sum; } const part = { id, index, offset }; return (parentNode !== node.ownerDocument.documentElement ? nodeToParts(parentNode, null, filter).concat(part) : [part]) // remove ignored nodes .filter(x => x.index !== -1) }; const fromRange = (range, filter) => { const { startContainer, startOffset, endContainer, endOffset } = range; const start = nodeToParts(startContainer, startOffset, filter); if (range.collapsed) return toString([start]) const end = nodeToParts(endContainer, endOffset, filter); return buildRange([start], [end]) }; const toRange = (doc, parts, filter) => { const startParts = collapse(parts); const endParts = collapse(parts, true); const root = doc.documentElement; const start = partsToNode(root, startParts[0], filter); const end = partsToNode(root, endParts[0], filter); const range = doc.createRange(); if (start.before) range.setStartBefore(start.node); else if (start.after) range.setStartAfter(start.node); else range.setStart(start.node, start.offset); if (end.before) range.setEndBefore(end.node); else if (end.after) range.setEndAfter(end.node); else range.setEnd(end.node, end.offset); return range }; // faster way of getting CFIs for sorted elements in a single parent 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 }; const toElement = (doc, parts) => partsToNode(doc.documentElement, collapse(parts)).node; // turn indices into standard CFIs when you don't have an actual package document const fake = { fromIndex: index => wrap(`/6/${(index + 1) * 2}`), toIndex: parts => parts?.at(-1).index / 2 - 1, }; // get CFI from Calibre bookmarks // see https://github.com/johnfactotum/foliate/issues/849 const fromCalibrePos = pos => { const [parts] = parse(pos); const item = parts.shift(); parts.shift(); return toString([[{ index: 6 }, item], parts]) }; const fromCalibreHighlight = ({ spine_index, start_cfi, end_cfi }) => { const pre = fake.fromIndex(spine_index) + '!'; return buildRange(pre + start_cfi.slice(2), pre + end_cfi.slice(2)) }; var epubcfi = /*#__PURE__*/Object.freeze({ __proto__: null, collapse: collapse, compare: compare, fake: fake, fromCalibreHighlight: fromCalibreHighlight, fromCalibrePos: fromCalibrePos, fromElements: fromElements, fromRange: fromRange, isCFI: isCFI, joinIndir: joinIndir, parse: parse, toElement: toElement, toRange: toRange }); // 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(); class TOCProgress { async init({ toc, ids, splitHref, getFragment }) { assignIDs(toc); const items = flatten(toc); const grouped = new Map(); for (const [i, item] of items.entries()) { const [id, fragment] = await splitHref(item?.href) ?? []; const value = { fragment, item }; if (grouped.has(id)) grouped.get(id).items.push(value); else grouped.set(id, { prev: items[i - 1], items: [value] }); } const map = new Map(); for (const [i, id] of ids.entries()) { if (grouped.has(id)) map.set(id, grouped.get(id)); else map.set(id, map.get(ids[i - 1])); } this.ids = ids; this.map = map; this.getFragment = getFragment; } getProgress(index, range) { if (!this.ids) return const id = this.ids[index]; const obj = this.map.get(id); if (!obj) return null const { prev, items } = obj; if (!items) return prev if (!range || items.length === 1 && !items[0].fragment) return items[0].item const doc = range.startContainer.getRootNode(); for (const [i, { fragment }] of items.entries()) { const el = this.getFragment(doc, fragment); if (!el) continue if (range.comparePoint(el, 0) > 0) return (items[i - 1]?.item ?? prev) } return items[items.length - 1].item } } class SectionProgress { constructor(sections, sizePerLoc, sizePerTimeUnit) { this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0); this.sizePerLoc = sizePerLoc; this.sizePerTimeUnit = sizePerTimeUnit; this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0); this.sectionFractions = this.#getSectionFractions(); } #getSectionFractions() { const { sizeTotal } = this; const results = [0]; let sum = 0; for (const size of this.sizes) results.push((sum += size) / sizeTotal); return results } // get progress given index of and fractions within a section getProgress(index, fractionInSection, pageFraction = 0) { const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this; const sizeInSection = sizes[index] ?? 0; const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0); const size = sizeBefore + fractionInSection * sizeInSection; const nextSize = size + pageFraction * sizeInSection; const remainingTotal = sizeTotal - size; const remainingSection = (1 - fractionInSection) * sizeInSection; return { fraction: nextSize / sizeTotal, section: { current: index, total: sizes.length, }, location: { current: Math.floor(size / sizePerLoc), next: Math.floor(nextSize / sizePerLoc), total: Math.ceil(sizeTotal / sizePerLoc), }, time: { section: remainingSection / sizePerTimeUnit, total: remainingTotal / sizePerTimeUnit, }, } } // the inverse of `getProgress` // get index of and fraction in section based on total fraction getSection(fraction) { if (fraction <= 0) return [0, 0] if (fraction >= 1) return [this.sizes.length - 1, 1] fraction = fraction + Number.EPSILON; const { sizeTotal } = this; let index = this.sectionFractions.findIndex(x => x > fraction) - 1; if (index < 0) return [0, 0] while (!this.sizes[index]) index++; const fractionInSection = (fraction - this.sectionFractions[index]) / (this.sizes[index] / sizeTotal); return [index, fractionInSection] } } const createSVGElement$1 = tag => document.createElementNS('http://www.w3.org/2000/svg', tag); class Overlayer { #svg = createSVGElement$1('svg') #map = new Map() constructor() { Object.assign(this.#svg.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', pointerEvents: 'none', }); } get element() { return this.#svg } add(key, range, draw, options) { if (this.#map.has(key)) this.remove(key); if (typeof range === 'function') range = range(this.#svg.getRootNode()); const rects = range.getClientRects(); const element = draw(rects, options); this.#svg.append(element); this.#map.set(key, { range, draw, options, element, rects }); } remove(key) { if (!this.#map.has(key)) return this.#svg.removeChild(this.#map.get(key).element); this.#map.delete(key); } redraw() { for (const obj of this.#map.values()) { const { range, draw, options, element } = obj; this.#svg.removeChild(element); const rects = range.getClientRects(); const el = draw(rects, options); this.#svg.append(el); obj.element = el; obj.rects = rects; } } hitTest({ x, y }) { const arr = Array.from(this.#map.entries()); // loop in reverse to hit more recently added items first for (let i = arr.length - 1; i >= 0; i--) { const [key, obj] = arr[i]; for (const { left, top, right, bottom } of obj.rects) if (top <= y && left <= x && bottom > y && right > x) return [key, obj.range] } return [] } static underline(rects, options = {}) { const { color = 'red', width: strokeWidth = 2, writingMode } = options; const g = createSVGElement$1('g'); g.setAttribute('fill', color); if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') for (const { right, top, height } of rects) { const el = createSVGElement$1('rect'); el.setAttribute('x', right - strokeWidth); el.setAttribute('y', top); el.setAttribute('height', height); el.setAttribute('width', strokeWidth); g.append(el); } else for (const { left, bottom, width } of rects) { const el = createSVGElement$1('rect'); el.setAttribute('x', left); el.setAttribute('y', bottom - strokeWidth); el.setAttribute('height', strokeWidth); el.setAttribute('width', width); g.append(el); } return g } static strikethrough(rects, options = {}) { const { color = 'red', width: strokeWidth = 2, writingMode } = options; const g = createSVGElement$1('g'); g.setAttribute('fill', color); if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') for (const { right, left, top, height } of rects) { const el = createSVGElement$1('rect'); el.setAttribute('x', (right + left) / 2); el.setAttribute('y', top); el.setAttribute('height', height); el.setAttribute('width', strokeWidth); g.append(el); } else for (const { left, top, bottom, width } of rects) { const el = createSVGElement$1('rect'); el.setAttribute('x', left); el.setAttribute('y', (top + bottom) / 2); el.setAttribute('height', strokeWidth); el.setAttribute('width', width); g.append(el); } return g } static squiggly(rects, options = {}) { const { color = 'red', width: strokeWidth = 2, writingMode } = options; const g = createSVGElement$1('g'); g.setAttribute('fill', 'none'); g.setAttribute('stroke', color); g.setAttribute('stroke-width', strokeWidth); const block = strokeWidth * 1.5; if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') for (const { right, top, height } of rects) { const el = createSVGElement$1('path'); const n = Math.round(height / block / 1.5); const inline = height / n; const ls = Array.from({ length: n }, (_, i) => `l${i % 2 ? -block : block} ${inline}`).join(''); el.setAttribute('d', `M${right} ${top}${ls}`); g.append(el); } else for (const { left, bottom, width } of rects) { const el = createSVGElement$1('path'); const n = Math.round(width / block / 1.5); const inline = width / n; const ls = Array.from({ length: n }, (_, i) => `l${inline} ${i % 2 ? block : -block}`).join(''); el.setAttribute('d', `M${left} ${bottom}${ls}`); g.append(el); } return g } static highlight(rects, options = {}) { const { color = 'red' } = options; const g = createSVGElement$1('g'); g.setAttribute('fill', color); g.style.opacity = 'var(--overlayer-highlight-opacity, .3)'; g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)'; for (const { left, top, height, width } of rects) { const el = createSVGElement$1('rect'); el.setAttribute('x', left); el.setAttribute('y', top); el.setAttribute('height', height); el.setAttribute('width', width); g.append(el); } return g } static outline(rects, options = {}) { const { color = 'red', width: strokeWidth = 3, radius = 3 } = options; const g = createSVGElement$1('g'); g.setAttribute('fill', 'none'); g.setAttribute('stroke', color); g.setAttribute('stroke-width', strokeWidth); for (const { left, top, height, width } of rects) { const el = createSVGElement$1('rect'); el.setAttribute('x', left); el.setAttribute('y', top); el.setAttribute('height', height); el.setAttribute('width', width); el.setAttribute('rx', radius); g.append(el); } return g } // make an exact copy of an image in the overlay // one can then apply filters to the entire element, without affecting them; // it's a bit silly and probably better to just invert images twice // (though the color will be off in that case if you do heu-rotate) static copyImage([rect], options = {}) { const { src } = options; const image = createSVGElement$1('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 } } const walkRange = (range, walker) => { const nodes = []; for (let node = walker.currentNode; node; node = walker.nextNode()) { const compare = range.comparePoint(node, 0); if (compare === 0) nodes.push(node); else if (compare > 0) break } return nodes }; const walkDocument = (_, walker) => { const nodes = []; for (let node = walker.nextNode(); node; node = walker.nextNode()) nodes.push(node); return nodes }; const filter$1 = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_CDATA_SECTION; const acceptNode = node => { if (node.nodeType === 1) { const name = node.tagName.toLowerCase(); if (name === 'script' || name === 'style') return NodeFilter.FILTER_REJECT return NodeFilter.FILTER_SKIP } return NodeFilter.FILTER_ACCEPT }; const textWalker = function* (x, func, filterFunc) { const root = x.commonAncestorContainer ?? x.body ?? x; const walker = document.createTreeWalker(root, filter$1, { acceptNode: filterFunc || acceptNode }); const walk = x.commonAncestorContainer ? walkRange : walkDocument; const nodes = walk(x, walker); const strs = nodes.map(node => node.nodeValue); const makeRange = (startIndex, startOffset, endIndex, endOffset) => { const range = document.createRange(); range.setStart(nodes[startIndex], startOffset); range.setEnd(nodes[endIndex], endOffset); return range }; for (const match of func(strs, makeRange)) yield match; }; const SEARCH_PREFIX = 'foliate-search:'; const isZip = async file => { const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()); return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 }; const isPDF = async file => { const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()); return arr[0] === 0x25 && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 && arr[4] === 0x2d }; const isCBZ = ({ name, type }) => type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz'); const isFB2 = ({ name, type }) => type === 'application/x-fictionbook+xml' || name.endsWith('.fb2'); const isFBZ = ({ name, type }) => type === 'application/x-zip-compressed-fb2' || name.endsWith('.fb2.zip') || name.endsWith('.fbz'); const makeZipLoader = async file => { const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = await Promise.resolve().then(function () { return zip; }); configure({ useWebWorkers: false }); const reader = new ZipReader(new BlobReader(file)); const entries = await reader.getEntries(); const map = new Map(entries.map(entry => [entry.filename, entry])); const load = f => (name, ...args) => map.has(name) ? f(map.get(name), ...args) : null; const loadText = load(entry => entry.getData(new TextWriter())); const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))); const getSize = name => map.get(name)?.uncompressedSize ?? 0; return { entries, loadText, loadBlob, getSize } }; const getFileEntries = async entry => entry.isFile ? entry : (await Promise.all(Array.from( await new Promise((resolve, reject) => entry.createReader() .readEntries(entries => resolve(entries), error => reject(error))), getFileEntries))).flat(); const makeDirectoryLoader = async entry => { const entries = await getFileEntries(entry); const files = await Promise.all( entries.map(entry => new Promise((resolve, reject) => entry.file(file => resolve([file, entry.fullPath]), error => reject(error))))); const map = new Map(files.map(([file, path]) => [path.replace(entry.fullPath + '/', ''), file])); const decoder = new TextDecoder(); const decode = x => x ? decoder.decode(x) : null; const getBuffer = name => map.get(name)?.arrayBuffer() ?? null; const loadText = async name => decode(await getBuffer(name)); const loadBlob = name => map.get(name); const getSize = name => map.get(name)?.size ?? 0; return { loadText, loadBlob, getSize } }; class ResponseError extends Error {} class NotFoundError extends Error {} class UnsupportedTypeError extends Error {} const fetchFile = async url => { const res = await fetch(url); if (!res.ok) throw new ResponseError( `${res.status} ${res.statusText}`, { cause: res }) return new File([await res.blob()], new URL(res.url).pathname) }; const makeBook = async file => { if (typeof file === 'string') file = await fetchFile(file); let book; if (file.isDirectory) { const loader = await makeDirectoryLoader(file); const { EPUB } = await Promise.resolve().then(function () { return epub; }); book = await new EPUB(loader).init(); } else if (!file.size) throw new NotFoundError('File not found') else if (await isZip(file)) { const loader = await makeZipLoader(file); if (isCBZ(file)) { const { makeComicBook } = await Promise.resolve().then(function () { return comicBook; }); book = makeComicBook(loader, file); } else if (isFBZ(file)) { const { makeFB2 } = await Promise.resolve().then(function () { return fb2; }); const { entries } = loader; const entry = entries.find(entry => entry.filename.endsWith('.fb2')); const blob = await loader.loadBlob((entry ?? entries[0]).filename); book = await makeFB2(blob); } else { const { EPUB } = await Promise.resolve().then(function () { return epub; }); book = await new EPUB(loader).init(); } } else if (await isPDF(file)) { const { makePDF } = await Promise.resolve().then(function () { return pdf; }); book = await makePDF(file); } else { const { isMOBI, MOBI } = await Promise.resolve().then(function () { return mobi; }); if (await isMOBI(file)) { const fflate$1 = await Promise.resolve().then(function () { return fflate; }); book = await new MOBI({ unzlib: fflate$1.unzlibSync }).open(file); } else if (isFB2(file)) { const { makeFB2 } = await Promise.resolve().then(function () { return fb2; }); book = await makeFB2(file); } } if (!book) throw new UnsupportedTypeError('File type not supported') return book }; class CursorAutohider { #timeout #el #check #state constructor(el, check, state = {}) { this.#el = el; this.#check = check; this.#state = state; if (this.#state.hidden) this.hide(); this.#el.addEventListener('mousemove', ({ screenX, screenY }) => { // check if it actually moved if (screenX === this.#state.x && screenY === this.#state.y) return this.#state.x = screenX, this.#state.y = screenY; this.show(); if (this.#timeout) clearTimeout(this.#timeout); if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000); }, false); } cloneFor(el) { return new CursorAutohider(el, this.#check, this.#state) } hide() { this.#el.style.cursor = 'none'; this.#state.hidden = true; } show() { this.#el.style.removeProperty('cursor'); this.#state.hidden = false; } } class History extends EventTarget { #arr = [] #index = -1 pushState(x) { const last = this.#arr[this.#index]; if (last === x || last?.fraction && last.fraction === x.fraction) return this.#arr[++this.#index] = x; this.#arr.length = this.#index + 1; this.dispatchEvent(new Event('index-change')); } replaceState(x) { const index = this.#index; this.#arr[index] = x; } back() { const index = this.#index; if (index <= 0) return const detail = { state: this.#arr[index - 1] }; this.#index = index - 1; this.dispatchEvent(new CustomEvent('popstate', { detail })); this.dispatchEvent(new Event('index-change')); } forward() { const index = this.#index; if (index >= this.#arr.length - 1) return const detail = { state: this.#arr[index + 1] }; this.#index = index + 1; this.dispatchEvent(new CustomEvent('popstate', { detail })); this.dispatchEvent(new Event('index-change')); } get canGoBack() { return this.#index > 0 } get canGoForward() { return this.#index < this.#arr.length - 1 } clear() { this.#arr = []; this.#index = -1; } } const languageInfo = lang => { if (!lang) return {} try { const canonical = Intl.getCanonicalLocales(lang)[0]; const locale = new Intl.Locale(canonical); const isCJK = ['zh', 'ja', 'kr'].includes(locale.language); const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction; return { canonical, locale, isCJK, direction } } catch (e) { console.warn(e); return {} } }; let View$1 = class View extends HTMLElement { #root = this.attachShadow({ mode: 'closed' }) #sectionProgress #tocProgress #pageProgress #searchResults = new Map() #cursorAutohider = new CursorAutohider(this, () => this.hasAttribute('autohide-cursor')) isFixedLayout = false lastLocation history = new History() constructor() { super(); this.history.addEventListener('popstate', ({ detail }) => { const resolved = this.resolveNavigation(detail.state); this.renderer.goTo(resolved); }); } async open(book) { if (typeof book === 'string' || typeof book.arrayBuffer === 'function' || book.isDirectory) book = await makeBook(book); this.book = book; this.language = languageInfo(book.metadata?.language); if (book.splitTOCHref && book.getTOCFragment) { const ids = book.sections.map(s => s.id); this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600); const splitHref = book.splitTOCHref.bind(book); const getFragment = book.getTOCFragment.bind(book); this.#tocProgress = new TOCProgress(); await this.#tocProgress.init({ toc: book.toc ?? [], ids, splitHref, getFragment }); this.#pageProgress = new TOCProgress(); await this.#pageProgress.init({ toc: book.pageList ?? [], ids, splitHref, getFragment }); } this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'; if (this.isFixedLayout) { await Promise.resolve().then(function () { return fixedLayout; }); this.renderer = document.createElement('foliate-fxl'); } else { await Promise.resolve().then(function () { return paginator; }); this.renderer = document.createElement('foliate-paginator'); } this.renderer.setAttribute('exportparts', 'head,foot,filter'); this.renderer.addEventListener('load', e => this.#onLoad(e.detail)); this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail)); this.renderer.addEventListener('create-overlayer', e => e.detail.attach(this.#createOverlayer(e.detail))); this.renderer.open(book); this.#root.append(this.renderer); if (book.sections.some(section => section.mediaOverlay)) { const activeClass = book.media.activeClass; const playbackActiveClass = book.media.playbackActiveClass; this.mediaOverlay = book.getMediaOverlay(); let lastActive; this.mediaOverlay.addEventListener('highlight', e => { const resolved = this.resolveNavigation(e.detail.text); this.renderer.goTo(resolved) .then(() => { const { doc } = this.renderer.getContents() .find(x => x.index = resolved.index); const el = resolved.anchor(doc); el.classList.add(activeClass); if (playbackActiveClass) el.ownerDocument .documentElement.classList.add(playbackActiveClass); lastActive = new WeakRef(el); }); }); this.mediaOverlay.addEventListener('unhighlight', () => { const el = lastActive?.deref(); if (el) { el.classList.remove(activeClass); if (playbackActiveClass) el.ownerDocument .documentElement.classList.remove(playbackActiveClass); } }); } } close() { this.renderer?.destroy(); this.renderer?.remove(); this.#sectionProgress = null; this.#tocProgress = null; this.#pageProgress = null; this.#searchResults = new Map(); this.lastLocation = null; this.history.clear(); this.tts = null; this.mediaOverlay = null; } goToTextStart() { return this.goTo(this.book.landmarks ?.find(m => m.type.includes('bodymatter') || m.type.includes('text')) ?.href ?? this.book.sections.findIndex(s => s.linear !== 'no')) } async init({ lastLocation, showTextStart }) { const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null; if (resolved) { await this.renderer.goTo(resolved); this.history.pushState(lastLocation); } else if (showTextStart) await this.goToTextStart(); else { this.history.pushState(0); await this.next(); } } #emit(name, detail, cancelable) { return this.dispatchEvent(new CustomEvent(name, { detail, cancelable })) } #onRelocate({ reason, range, index, fraction, size }) { const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {}; const tocItem = this.#tocProgress?.getProgress(index, range); const pageItem = this.#pageProgress?.getProgress(index, range); const cfi = this.getCFI(index, range); this.lastLocation = { ...progress, tocItem, pageItem, cfi, range }; if (reason === 'snap' || reason === 'page' || reason === 'scroll') this.history.replaceState(cfi); this.#emit('relocate', this.lastLocation); } #onLoad({ doc, index }) { // set language and dir if not already set doc.documentElement.lang ||= this.language.canonical ?? ''; if (!this.language.isCJK) doc.documentElement.dir ||= this.language.direction ?? ''; this.#handleLinks(doc, index); this.#cursorAutohider.cloneFor(doc.documentElement); this.#emit('load', { doc, index }); } #handleLinks(doc, index) { const { book } = this; const section = book.sections[index]; doc.addEventListener('click', e => { const a = e.target.closest('a[href]'); if (!a) return e.preventDefault(); const href_ = a.getAttribute('href'); const href = section?.resolveHref?.(href_) ?? href_; if (book?.isExternal?.(href)) Promise.resolve(this.#emit('external-link', { a, href }, true)) .then(x => x ? globalThis.open(href, '_blank') : null) .catch(e => console.error(e)); else Promise.resolve(this.#emit('link', { a, href }, true)) .then(x => x ? this.goTo(href) : null) .catch(e => console.error(e)); }); } async addAnnotation(annotation, remove) { const { value } = annotation; if (value.startsWith(SEARCH_PREFIX)) { const cfi = value.replace(SEARCH_PREFIX, ''); const { index, anchor } = await this.resolveNavigation(cfi); const obj = this.#getOverlayer(index); if (obj) { const { overlayer, doc } = obj; if (remove) { overlayer.remove(value); return } const range = doc ? anchor(doc) : anchor; overlayer.add(value, range, Overlayer.outline); } return } const { index, anchor } = await this.resolveNavigation(value); const obj = this.#getOverlayer(index); if (obj) { const { overlayer, doc } = obj; overlayer.remove(value); if (!remove) { const range = doc ? anchor(doc) : anchor; const draw = (func, opts) => overlayer.add(value, range, func, opts); this.#emit('draw-annotation', { draw, annotation, doc, range }); } } const label = this.#tocProgress.getProgress(index)?.label ?? ''; return { index, label } } deleteAnnotation(annotation) { return this.addAnnotation(annotation, true) } #getOverlayer(index) { return this.renderer.getContents() .find(x => x.index === index && x.overlayer) } #createOverlayer({ doc, index }) { const overlayer = new Overlayer(); doc.addEventListener('click', e => { const [value, range] = overlayer.hitTest(e); if (value && !value.startsWith(SEARCH_PREFIX)) { this.#emit('show-annotation', { value, index, range }); } }, false); const list = this.#searchResults.get(index); if (list) for (const item of list) this.addAnnotation(item); this.#emit('create-overlay', { index }); return overlayer } async showAnnotation(annotation) { const { value } = annotation; const resolved = await this.goTo(value); if (resolved) { const { index, anchor } = resolved; const { doc } = this.#getOverlayer(index); const range = anchor(doc); this.#emit('show-annotation', { value, index, range }); } } getCFI(index, range) { const baseCFI = this.book.sections[index].cfi ?? fake.fromIndex(index); if (!range) return baseCFI return joinIndir(baseCFI, fromRange(range)) } resolveCFI(cfi) { if (this.book.resolveCFI) return this.book.resolveCFI(cfi) else { const parts = parse(cfi); const index = fake.toIndex((parts.parent ?? parts).shift()); const anchor = doc => toRange(doc, parts); return { index, anchor } } } resolveNavigation(target) { try { if (typeof target === 'number') return { index: target } if (typeof target.fraction === 'number') { const [index, anchor] = this.#sectionProgress.getSection(target.fraction); return { index, anchor } } if (isCFI.test(target)) return this.resolveCFI(target) return this.book.resolveHref(target) } catch (e) { console.error(e); console.error(`Could not resolve target ${target}`); } } async goTo(target) { const resolved = this.resolveNavigation(target); try { await this.renderer.goTo(resolved); this.history.pushState(target); return resolved } catch(e) { console.error(e); console.error(`Could not go to ${target}`); } } async goToFraction(frac) { const [index, anchor] = this.#sectionProgress.getSection(frac); await this.renderer.goTo({ index, anchor }); this.history.pushState({ fraction: frac }); } async select(target) { try { const obj = await this.resolveNavigation(target); await this.renderer.goTo({ ...obj, select: true }); this.history.pushState(target); } catch(e) { console.error(e); console.error(`Could not go to ${target}`); } } deselect() { for (const { doc } of this.renderer.getContents()) doc.defaultView.getSelection().removeAllRanges(); } getSectionFractions() { return (this.#sectionProgress?.sectionFractions ?? []) .map(x => x + Number.EPSILON) } getProgressOf(index, range) { const tocItem = this.#tocProgress?.getProgress(index, range); const pageItem = this.#pageProgress?.getProgress(index, range); return { tocItem, pageItem } } async getTOCItemOf(target) { try { const { index, anchor } = await this.resolveNavigation(target); const doc = await this.book.sections[index].createDocument(); const frag = anchor(doc); const isRange = frag instanceof Range; const range = isRange ? frag : doc.createRange(); if (!isRange) range.selectNodeContents(frag); return this.#tocProgress.getProgress(index, range) } catch(e) { console.error(e); console.error(`Could not get ${target}`); } } async prev(distance) { await this.renderer.prev(distance); } async next(distance) { await this.renderer.next(distance); } goLeft() { return this.book.dir === 'rtl' ? this.next() : this.prev() } goRight() { return this.book.dir === 'rtl' ? this.prev() : this.next() } async * #searchSection(matcher, query, index) { const doc = await this.book.sections[index].createDocument(); for (const { range, excerpt } of matcher(doc, query)) yield { cfi: this.getCFI(index, range), excerpt }; } async * #searchBook(matcher, query) { const { sections } = this.book; for (const [index, { createDocument }] of sections.entries()) { if (!createDocument) continue const doc = await createDocument(); const subitems = Array.from(matcher(doc, query), ({ range, excerpt }) => ({ cfi: this.getCFI(index, range), excerpt })); const progress = (index + 1) / sections.length; yield { progress }; if (subitems.length) yield { index, subitems }; } } async * search(opts) { this.clearSearch(); const { searchMatcher } = await Promise.resolve().then(function () { return search$1; }); const { query, index } = opts; const matcher = searchMatcher(textWalker, { defaultLocale: this.language, ...opts }); const iter = index != null ? this.#searchSection(matcher, query, index) : this.#searchBook(matcher, query); const list = []; this.#searchResults.set(index, list); for await (const result of iter) { if (result.subitems){ const list = result.subitems .map(({ cfi }) => ({ value: SEARCH_PREFIX + cfi })); this.#searchResults.set(result.index, list); for (const item of list) this.addAnnotation(item); yield { label: this.#tocProgress.getProgress(result.index)?.label ?? '', subitems: result.subitems, }; } else { if (result.cfi) { const item = { value: SEARCH_PREFIX + result.cfi }; list.push(item); this.addAnnotation(item); } yield result; } } yield 'done'; } clearSearch() { for (const list of this.#searchResults.values()) for (const item of list) this.deleteAnnotation(item); this.#searchResults.clear(); } async initTTS(granularity = 'word', highlight) { const doc = this.renderer.getContents()[0].doc; if (this.tts && this.tts.doc === doc) return const { TTS } = await Promise.resolve().then(function () { return tts; }); this.tts = new TTS(doc, textWalker, highlight || (range => this.renderer.scrollToAnchor(range, true)), granularity); } startMediaOverlay() { const { index } = this.renderer.getContents()[0]; return this.mediaOverlay.start(index) } }; customElements.define('foliate-view', View$1); const NS$3 = { 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/', ENC: 'http://www.w3.org/2001/04/xmlenc#', NCX: 'http://www.daisy.org/z3986/2005/ncx/', XLINK: 'http://www.w3.org/19