@xincmm/foliate-js
Version:
Render e-books in the browser
1,293 lines (1,224 loc) • 974 kB
JavaScript
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