@@ -31,11 +31,22 @@ export function HelpDialog({
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p>Read, inspect and repair EPUB publications locally in the browser.</p>
|
||||
<p>
|
||||
Read, search, navigate, annotate, inspect and repair EPUB publications
|
||||
locally in the browser. UTF-8 HTML/XHTML, Markdown and plain text can be
|
||||
adapted into a one-chapter EPUB workspace with visible loss evidence.
|
||||
Packaged styles, fonts, images and media are bounded and rewritten to
|
||||
temporary local URLs.
|
||||
</p>
|
||||
<p>
|
||||
All processing is performed in this browser. Imported data is treated as
|
||||
untrusted and bounded before parsing.
|
||||
</p>
|
||||
<p>
|
||||
Notes stay in memory unless you explicitly export them. The safe reader
|
||||
removes publication scripts and network resources; its parent-only DOM
|
||||
access handles internal links, fragment scrolling and selected quotes.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
+738
-76
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,386 @@
|
||||
import { BlobWriter, TextReader, ZipWriter } from "@zip.js/zip.js";
|
||||
import {
|
||||
openEpub,
|
||||
type EpubOpenProgress,
|
||||
type EpubOpenProgressCallback,
|
||||
} from "./archive";
|
||||
import type { EpubAdaptation, EpubBook } from "./types";
|
||||
|
||||
const TEXT_LIMIT = 16 * 1024 * 1024;
|
||||
const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
||||
const SAFE_ELEMENTS = new Set([
|
||||
"a",
|
||||
"article",
|
||||
"aside",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"dd",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"em",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"q",
|
||||
"s",
|
||||
"section",
|
||||
"small",
|
||||
"span",
|
||||
"strong",
|
||||
"sub",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"u",
|
||||
"ul",
|
||||
]);
|
||||
const SAFE_ATTRIBUTES = new Set([
|
||||
"colspan",
|
||||
"dir",
|
||||
"id",
|
||||
"lang",
|
||||
"rowspan",
|
||||
"scope",
|
||||
"title",
|
||||
]);
|
||||
|
||||
export type PublicationAdapter = "epub" | "html" | "markdown" | "text";
|
||||
|
||||
function extension(name: string): string {
|
||||
return name.toLowerCase().split(".").pop() ?? "";
|
||||
}
|
||||
|
||||
export function publicationAdapterForFile(
|
||||
file: Pick<File, "name" | "type">,
|
||||
): PublicationAdapter | undefined {
|
||||
const suffix = extension(file.name);
|
||||
if (suffix === "epub" || file.type === "application/epub+zip") return "epub";
|
||||
if (
|
||||
["html", "htm", "xhtml"].includes(suffix) ||
|
||||
/^(?:text\/html|application\/xhtml\+xml)$/iu.test(file.type)
|
||||
)
|
||||
return "html";
|
||||
if (
|
||||
["md", "markdown", "mdown", "mkd"].includes(suffix) ||
|
||||
file.type === "text/markdown"
|
||||
)
|
||||
return "markdown";
|
||||
if (suffix === "txt" || file.type === "text/plain") return "text";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replace(/\p{Cc}/gu, (character) =>
|
||||
character === "\t" || character === "\n" || character === "\r"
|
||||
? character
|
||||
: "�",
|
||||
)
|
||||
.replace(/[\ufffe\uffff]/gu, "�")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function titleFromFilename(name: string): string {
|
||||
const value = name
|
||||
.replace(/\.[^.]+$/u, "")
|
||||
.replace(/[_-]+/gu, " ")
|
||||
.trim();
|
||||
return (value || "Imported publication").slice(0, 300);
|
||||
}
|
||||
|
||||
async function contentIdentifier(bytes: Uint8Array): Promise<string> {
|
||||
const buffer = bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", buffer));
|
||||
return `urn:sha256:${[...digest]
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes: Uint8Array): string {
|
||||
if (!bytes.length || bytes.length > TEXT_LIMIT)
|
||||
throw new RangeError("Text publication adapters accept 1 byte–16 MiB.");
|
||||
const offset =
|
||||
bytes.length >= 3 &&
|
||||
bytes[0] === 0xef &&
|
||||
bytes[1] === 0xbb &&
|
||||
bytes[2] === 0xbf
|
||||
? 3
|
||||
: 0;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
bytes.subarray(offset),
|
||||
);
|
||||
} catch {
|
||||
throw new TypeError(
|
||||
"Text, Markdown and HTML adapters require valid UTF-8 input.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHtmlNode(node: Node, depth = 0, budget = { nodes: 0 }): string {
|
||||
budget.nodes += 1;
|
||||
if (budget.nodes > 100_000)
|
||||
throw new RangeError("HTML adaptation exceeds 100,000 nodes.");
|
||||
if (depth > 80) throw new RangeError("HTML nesting exceeds 80 levels.");
|
||||
if (node.nodeType === Node.TEXT_NODE)
|
||||
return xmlEscape(node.textContent ?? "");
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
const element = node as Element;
|
||||
const tag = element.localName.toLowerCase();
|
||||
const children = [...element.childNodes]
|
||||
.map((child) => renderHtmlNode(child, depth + 1, budget))
|
||||
.join("");
|
||||
if (!SAFE_ELEMENTS.has(tag)) return children;
|
||||
const attributes: string[] = [];
|
||||
for (const attribute of [...element.attributes]) {
|
||||
const name = attribute.name.toLowerCase();
|
||||
if (SAFE_ATTRIBUTES.has(name))
|
||||
attributes.push(
|
||||
` ${name}="${xmlEscape(attribute.value.slice(0, 2_000))}"`,
|
||||
);
|
||||
else if (
|
||||
tag === "a" &&
|
||||
name === "href" &&
|
||||
/^#[A-Za-z][\w:.-]{0,255}$/u.test(attribute.value)
|
||||
)
|
||||
attributes.push(` href="${xmlEscape(attribute.value)}"`);
|
||||
}
|
||||
if (tag === "br" || tag === "hr") return `<${tag}${attributes.join("")}/>`;
|
||||
return `<${tag}${attributes.join("")}>${children}</${tag}>`;
|
||||
}
|
||||
|
||||
function adaptHtml(
|
||||
source: string,
|
||||
fallbackTitle: string,
|
||||
): { title: string; body: string; losses: string[] } {
|
||||
if (/<!ENTITY/iu.test(source))
|
||||
throw new TypeError("HTML adapters reject entity declarations.");
|
||||
const document = new DOMParser().parseFromString(source, "text/html");
|
||||
const title = (document.title.trim() || fallbackTitle).slice(0, 300);
|
||||
const budget = { nodes: 0 };
|
||||
const body = [...document.body.childNodes]
|
||||
.map((node) => renderHtmlNode(node, 0, budget))
|
||||
.join("");
|
||||
return {
|
||||
title,
|
||||
body: body || `<p>${xmlEscape(fallbackTitle)}</p>`,
|
||||
losses: [
|
||||
"Scripts, forms, embedded media, styles, classes, external resources and non-fragment links were removed while adapting HTML.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function inlineMarkdown(value: string): string {
|
||||
return xmlEscape(value)
|
||||
.replace(/`([^`\n]+)`/gu, "<code>$1</code>")
|
||||
.replace(/\*\*([^*\n]+)\*\*/gu, "<strong>$1</strong>")
|
||||
.replace(/_([^_\n]+)_/gu, "<em>$1</em>");
|
||||
}
|
||||
|
||||
function adaptMarkdown(
|
||||
source: string,
|
||||
fallbackTitle: string,
|
||||
): { title: string; body: string; losses: string[] } {
|
||||
const output: string[] = [];
|
||||
const paragraph: string[] = [];
|
||||
let title = fallbackTitle;
|
||||
let code: string[] | undefined;
|
||||
let list: "ol" | "ul" | undefined;
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length) {
|
||||
output.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
|
||||
paragraph.length = 0;
|
||||
}
|
||||
};
|
||||
const closeList = () => {
|
||||
if (list) output.push(`</${list}>`);
|
||||
list = undefined;
|
||||
};
|
||||
const lines = source.replaceAll("\r\n", "\n").split("\n");
|
||||
if (lines.length > 200_000)
|
||||
throw new RangeError("Markdown adaptation exceeds 200,000 lines.");
|
||||
for (const line of lines) {
|
||||
if (/^\s*```/u.test(line)) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
if (code) {
|
||||
output.push(`<pre><code>${xmlEscape(code.join("\n"))}</code></pre>`);
|
||||
code = undefined;
|
||||
} else code = [];
|
||||
continue;
|
||||
}
|
||||
if (code) {
|
||||
code.push(line);
|
||||
continue;
|
||||
}
|
||||
const heading = /^(#{1,6})\s+(.+)$/u.exec(line);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
const level = heading[1]!.length;
|
||||
if (level === 1 && title === fallbackTitle)
|
||||
title =
|
||||
heading[2]!.replace(/[*_`]/gu, "").trim().slice(0, 300) || title;
|
||||
output.push(`<h${level}>${inlineMarkdown(heading[2]!)}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const item = /^\s*(?:(\d+)\.|[-+*])\s+(.+)$/u.exec(line);
|
||||
if (item) {
|
||||
flushParagraph();
|
||||
const next = item[1] ? "ol" : "ul";
|
||||
if (list !== next) {
|
||||
closeList();
|
||||
output.push(`<${next}>`);
|
||||
list = next;
|
||||
}
|
||||
output.push(`<li>${inlineMarkdown(item[2]!)}</li>`);
|
||||
continue;
|
||||
}
|
||||
if (!line.trim()) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
} else paragraph.push(line.trim());
|
||||
}
|
||||
if (code)
|
||||
output.push(`<pre><code>${xmlEscape(code.join("\n"))}</code></pre>`);
|
||||
flushParagraph();
|
||||
closeList();
|
||||
return {
|
||||
title,
|
||||
body: output.join("\n") || `<p>${xmlEscape(fallbackTitle)}</p>`,
|
||||
losses: [
|
||||
"Markdown adaptation supports headings, paragraphs, lists, fenced code, emphasis and inline code; other syntax remains literal text.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function createAdaptedEpub(
|
||||
sourceFile: File,
|
||||
kind: Exclude<PublicationAdapter, "epub">,
|
||||
onProgress?: EpubOpenProgressCallback,
|
||||
): Promise<{ file: File; adaptation: EpubAdaptation }> {
|
||||
if (!sourceFile.size || sourceFile.size > TEXT_LIMIT)
|
||||
throw new RangeError("Text publication adapters accept 1 byte–16 MiB.");
|
||||
onProgress?.({
|
||||
phase: "archive",
|
||||
message: `Adapting ${kind === "text" ? "plain text" : kind} into a local publication…`,
|
||||
progress: 0.03,
|
||||
});
|
||||
const bytes = new Uint8Array(await sourceFile.arrayBuffer());
|
||||
const source = decodeUtf8(bytes);
|
||||
const fallbackTitle = titleFromFilename(sourceFile.name);
|
||||
const adapted =
|
||||
kind === "html"
|
||||
? adaptHtml(source, fallbackTitle)
|
||||
: kind === "markdown"
|
||||
? adaptMarkdown(source, fallbackTitle)
|
||||
: {
|
||||
title: fallbackTitle,
|
||||
body: `<pre>${xmlEscape(source)}</pre>`,
|
||||
losses: [
|
||||
"Plain text has no publication structure; line breaks are preserved in one preformatted chapter.",
|
||||
],
|
||||
};
|
||||
const identifier = await contentIdentifier(bytes);
|
||||
const chapter = `<?xml version="1.0" encoding="UTF-8"?>\n<html xmlns="${XHTML_NS}" lang="und"><head><title>${xmlEscape(adapted.title)}</title></head><body>${adapted.body}</body></html>`;
|
||||
const writer = new ZipWriter(new BlobWriter("application/epub+zip"));
|
||||
await writer.add("mimetype", new TextReader("application/epub+zip"), {
|
||||
level: 0,
|
||||
});
|
||||
await writer.add(
|
||||
"META-INF/container.xml",
|
||||
new TextReader(
|
||||
'<?xml version="1.0" encoding="UTF-8"?><container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>',
|
||||
),
|
||||
);
|
||||
await writer.add(
|
||||
"EPUB/package.opf",
|
||||
new TextReader(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="publication-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="publication-id">${identifier}</dc:identifier><dc:title>${xmlEscape(adapted.title)}</dc:title><dc:language>und</dc:language><meta property="dcterms:modified">1970-01-01T00:00:00Z</meta></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="content" href="content.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="content"/></spine></package>`,
|
||||
),
|
||||
);
|
||||
await writer.add(
|
||||
"EPUB/nav.xhtml",
|
||||
new TextReader(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><html xmlns="${XHTML_NS}" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol><li><a href="content.xhtml">${xmlEscape(adapted.title)}</a></li></ol></nav></body></html>`,
|
||||
),
|
||||
);
|
||||
await writer.add("EPUB/content.xhtml", new TextReader(chapter));
|
||||
const blob = await writer.close();
|
||||
return {
|
||||
file: new File([blob], `${titleFromFilename(sourceFile.name)}.epub`, {
|
||||
type: "application/epub+zip",
|
||||
lastModified: sourceFile.lastModified,
|
||||
}),
|
||||
adaptation: {
|
||||
kind,
|
||||
sourceFilename: sourceFile.name,
|
||||
sourceMediaType:
|
||||
sourceFile.type || (kind === "text" ? "text/plain" : `text/${kind}`),
|
||||
sourceBytes: sourceFile.size,
|
||||
losses: adapted.losses,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapProgress(progress: EpubOpenProgress): EpubOpenProgress {
|
||||
return { ...progress, progress: 0.2 + progress.progress * 0.8 };
|
||||
}
|
||||
|
||||
export async function openPublication(
|
||||
file: File,
|
||||
onProgress?: EpubOpenProgressCallback,
|
||||
): Promise<EpubBook> {
|
||||
let kind = publicationAdapterForFile(file);
|
||||
if (!kind && file.size >= 4) {
|
||||
const signature = new Uint8Array(await file.slice(0, 4).arrayBuffer());
|
||||
if (
|
||||
signature[0] === 0x50 &&
|
||||
signature[1] === 0x4b &&
|
||||
((signature[2] === 0x03 && signature[3] === 0x04) ||
|
||||
(signature[2] === 0x05 && signature[3] === 0x06) ||
|
||||
(signature[2] === 0x07 && signature[3] === 0x08))
|
||||
)
|
||||
kind = "epub";
|
||||
}
|
||||
if (!kind)
|
||||
throw new TypeError(
|
||||
"Supported inputs are EPUB, UTF-8 HTML/XHTML, Markdown and plain text.",
|
||||
);
|
||||
if (kind === "epub") return openEpub(file, onProgress);
|
||||
const adapted = await createAdaptedEpub(file, kind, onProgress);
|
||||
const book = await openEpub(adapted.file, (progress) =>
|
||||
onProgress?.(mapProgress(progress)),
|
||||
);
|
||||
book.adapter = adapted.adaptation;
|
||||
book.issues.unshift({
|
||||
severity: "info",
|
||||
code: "adapted-publication",
|
||||
message: `${adapted.adaptation.sourceFilename} was converted locally into a one-chapter EPUB workspace. ${adapted.adaptation.losses.join(" ")}`,
|
||||
path: adapted.adaptation.sourceFilename,
|
||||
});
|
||||
return book;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { stableStringify } from "@add-ideas/toolbox-helpers";
|
||||
|
||||
export interface EpubBookmark {
|
||||
id: string;
|
||||
path: string;
|
||||
fragment?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface EpubAnnotation extends EpubBookmark {
|
||||
note: string;
|
||||
quote?: string;
|
||||
}
|
||||
|
||||
export interface EpubReadingNotes {
|
||||
schemaVersion: 1;
|
||||
publication: { identifier: string; title: string };
|
||||
bookmarks: EpubBookmark[];
|
||||
annotations: EpubAnnotation[];
|
||||
}
|
||||
|
||||
const NOTES_LIMITS = {
|
||||
text: 2 * 1024 * 1024,
|
||||
entries: 5_000,
|
||||
path: 2_048,
|
||||
label: 500,
|
||||
fragment: 500,
|
||||
note: 16_000,
|
||||
quote: 2_000,
|
||||
} as const;
|
||||
|
||||
export function createReadingNoteId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return [...bytes]
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function exportReadingNotes(notes: EpubReadingNotes): string {
|
||||
return (
|
||||
stableStringify(validateReadingNotes(notes), 2, {
|
||||
maxDepth: 8,
|
||||
maxNodes: 50_000,
|
||||
maxTextChars: NOTES_LIMITS.text,
|
||||
}) + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
export function importReadingNotes(
|
||||
source: string,
|
||||
publication: EpubReadingNotes["publication"],
|
||||
): EpubReadingNotes {
|
||||
if (source.length > NOTES_LIMITS.text)
|
||||
throw new Error("Reading-notes JSON exceeds 2 MiB.");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(source) as unknown;
|
||||
} catch {
|
||||
throw new Error("Reading-notes input is not valid JSON.");
|
||||
}
|
||||
const notes = validateReadingNotes(parsed);
|
||||
if (
|
||||
notes.publication.identifier &&
|
||||
publication.identifier &&
|
||||
notes.publication.identifier !== publication.identifier
|
||||
)
|
||||
throw new Error(
|
||||
"Reading notes belong to a different publication identifier.",
|
||||
);
|
||||
return { ...notes, publication };
|
||||
}
|
||||
|
||||
function validateReadingNotes(value: unknown): EpubReadingNotes {
|
||||
if (!record(value) || value.schemaVersion !== 1 || !record(value.publication))
|
||||
throw new Error("Unsupported reading-notes document.");
|
||||
const bookmarks = entries(value.bookmarks, false) as EpubBookmark[];
|
||||
const annotations = entries(value.annotations, true) as EpubAnnotation[];
|
||||
if (bookmarks.length + annotations.length > NOTES_LIMITS.entries)
|
||||
throw new Error("Reading notes exceed 5,000 entries.");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
publication: {
|
||||
identifier: text(value.publication.identifier, "identifier", 1_000, true),
|
||||
title: text(value.publication.title, "title", 1_000, true),
|
||||
},
|
||||
bookmarks,
|
||||
annotations,
|
||||
};
|
||||
}
|
||||
|
||||
function entries(
|
||||
value: unknown,
|
||||
annotation: boolean,
|
||||
): Array<EpubBookmark | EpubAnnotation> {
|
||||
if (!Array.isArray(value))
|
||||
throw new Error("Reading-note entries must be arrays.");
|
||||
return value.map((item, index) => {
|
||||
if (!record(item))
|
||||
throw new Error(`Reading-note entry ${index + 1} is invalid.`);
|
||||
const base: EpubBookmark = {
|
||||
id: text(item.id, "id", 100),
|
||||
path: safePath(text(item.path, "path", NOTES_LIMITS.path)),
|
||||
label: text(item.label, "label", NOTES_LIMITS.label),
|
||||
...(item.fragment === undefined
|
||||
? {}
|
||||
: { fragment: text(item.fragment, "fragment", NOTES_LIMITS.fragment) }),
|
||||
};
|
||||
return annotation
|
||||
? {
|
||||
...base,
|
||||
note: text(item.note, "note", NOTES_LIMITS.note, true),
|
||||
...(item.quote === undefined
|
||||
? {}
|
||||
: { quote: text(item.quote, "quote", NOTES_LIMITS.quote) }),
|
||||
}
|
||||
: base;
|
||||
});
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function text(
|
||||
value: unknown,
|
||||
field: string,
|
||||
maximum: number,
|
||||
empty = false,
|
||||
): string {
|
||||
if (typeof value !== "string" || (!empty && !value.trim()))
|
||||
throw new Error(`${field} must be a${empty ? "" : " non-empty"} string.`);
|
||||
if (value.length > maximum) throw new Error(`${field} is too long.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function safePath(value: string): string {
|
||||
if (
|
||||
value.startsWith("/") ||
|
||||
value.includes("\\") ||
|
||||
value.includes("\0") ||
|
||||
value.split("/").some((part) => part === "." || part === "..")
|
||||
)
|
||||
throw new Error("Reading-note path must be publication-relative.");
|
||||
return value;
|
||||
}
|
||||
+199
-5
@@ -17,19 +17,156 @@ import { validatePublication } from "./validation";
|
||||
|
||||
export { EPUB_LIMITS, readEntryBytes, readEntryText } from "./entries";
|
||||
|
||||
export type EpubOpenPhase =
|
||||
| "archive"
|
||||
| "entries"
|
||||
| "container"
|
||||
| "package"
|
||||
| "navigation"
|
||||
| "validation"
|
||||
| "ready";
|
||||
export interface EpubOpenProgress {
|
||||
phase: EpubOpenPhase;
|
||||
message: string;
|
||||
progress: number;
|
||||
current?: number;
|
||||
total?: number;
|
||||
}
|
||||
export type EpubOpenProgressCallback = (progress: EpubOpenProgress) => void;
|
||||
|
||||
function ratio(current: number, total: number): number {
|
||||
return total > 0 ? Math.min(1, Math.max(0, current / total)) : 0;
|
||||
}
|
||||
|
||||
const IDPF_FONT_OBFUSCATION = "http://www.idpf.org/2008/embedding" as const;
|
||||
|
||||
async function inspectEncryption(
|
||||
source: Pick<EpubBook, "entryByPath">,
|
||||
identifier: string,
|
||||
): Promise<{
|
||||
fonts: EpubBook["obfuscatedFonts"];
|
||||
unsupported: number;
|
||||
issues: EpubIssue[];
|
||||
}> {
|
||||
const fonts: EpubBook["obfuscatedFonts"] = new Map();
|
||||
if (!source.entryByPath.has("META-INF/encryption.xml"))
|
||||
return { fonts, unsupported: 0, issues: [] };
|
||||
const issues: EpubIssue[] = [];
|
||||
let unsupported = 0;
|
||||
try {
|
||||
const xml = await readEntryText(
|
||||
source,
|
||||
"META-INF/encryption.xml",
|
||||
EPUB_LIMITS.xmlBytes,
|
||||
);
|
||||
const document = parseXml(xml, "META-INF/encryption.xml");
|
||||
let key: Uint8Array | undefined;
|
||||
for (const encrypted of elementsByLocalName(document, "EncryptedData")) {
|
||||
const method = elementsByLocalName(encrypted, "EncryptionMethod")[0];
|
||||
const reference = elementsByLocalName(encrypted, "CipherReference")[0];
|
||||
const algorithm = method?.getAttribute("Algorithm") ?? "";
|
||||
const uri = reference?.getAttribute("URI") ?? "";
|
||||
let path = "";
|
||||
try {
|
||||
path = validateEntryPath(
|
||||
decodeURIComponent(uri.split("#", 1)[0] ?? ""),
|
||||
);
|
||||
} catch {
|
||||
unsupported += 1;
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "invalid-encryption-reference",
|
||||
message: `Encryption entry has an unsafe or invalid resource URI: ${uri || "(empty)"}.`,
|
||||
path: "META-INF/encryption.xml",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (algorithm !== IDPF_FONT_OBFUSCATION) {
|
||||
unsupported += 1;
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "unsupported-encryption",
|
||||
message: `Protected resource uses an unsupported encryption algorithm: ${algorithm || "(missing)"}.`,
|
||||
path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!identifier) {
|
||||
unsupported += 1;
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "font-obfuscation-key-missing",
|
||||
message:
|
||||
"The IDPF-obfuscated font cannot be decoded because the package unique identifier is empty.",
|
||||
path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!key) {
|
||||
const digest = await globalThis.crypto?.subtle?.digest(
|
||||
"SHA-1",
|
||||
new TextEncoder().encode(identifier.replace(/[ \t\r\n]/gu, "")),
|
||||
);
|
||||
if (!digest)
|
||||
throw new Error(
|
||||
"Web Crypto SHA-1 digest support is unavailable for standardized font deobfuscation.",
|
||||
);
|
||||
key = new Uint8Array(digest);
|
||||
}
|
||||
fonts.set(path, { algorithm: IDPF_FONT_OBFUSCATION, key });
|
||||
}
|
||||
} catch (reason) {
|
||||
unsupported += 1;
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "invalid-encryption-document",
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "META-INF/encryption.xml could not be inspected.",
|
||||
path: "META-INF/encryption.xml",
|
||||
});
|
||||
}
|
||||
if (fonts.size)
|
||||
issues.push({
|
||||
severity: "info",
|
||||
code: "font-obfuscation-supported",
|
||||
message: `${fonts.size} font resource(s) use the standardized IDPF mask and can be decoded locally for reading.`,
|
||||
path: "META-INF/encryption.xml",
|
||||
});
|
||||
return { fonts, unsupported, issues };
|
||||
}
|
||||
|
||||
export async function closeBook(book: EpubBook | undefined): Promise<void> {
|
||||
if (book) await book.reader.close();
|
||||
}
|
||||
|
||||
export async function openEpub(file: File): Promise<EpubBook> {
|
||||
export async function openEpub(
|
||||
file: File,
|
||||
onProgress?: EpubOpenProgressCallback,
|
||||
): Promise<EpubBook> {
|
||||
if (file.size === 0 || file.size > EPUB_LIMITS.fileBytes)
|
||||
throw new RangeError("EPUB must be between 1 byte and 512 MiB.");
|
||||
onProgress?.({
|
||||
phase: "archive",
|
||||
message: "Reading the ZIP directory…",
|
||||
progress: 0.02,
|
||||
});
|
||||
const reader = new ZipReader(new BlobReader(file), {
|
||||
useWebWorkers: true,
|
||||
strictness: "strict",
|
||||
});
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
const entries = await reader.getEntries({
|
||||
onprogress: (current, total) =>
|
||||
onProgress?.({
|
||||
phase: "archive",
|
||||
message: `Reading archive directory · ${current.toLocaleString()} / ${total.toLocaleString()} entries`,
|
||||
progress: 0.02 + ratio(current, total) * 0.16,
|
||||
current,
|
||||
total,
|
||||
}),
|
||||
});
|
||||
if (!entries.length || entries.length > EPUB_LIMITS.entries)
|
||||
throw new RangeError(
|
||||
`EPUB entry count must be between 1 and ${EPUB_LIMITS.entries.toLocaleString()}.`,
|
||||
@@ -37,7 +174,20 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
||||
let totalUncompressed = 0;
|
||||
let compressedBytes = 0;
|
||||
const entryByPath = new Map<string, Entry>();
|
||||
for (const entry of entries) {
|
||||
const entryProgressInterval = Math.max(1, Math.ceil(entries.length / 100));
|
||||
for (const [entryIndex, entry] of entries.entries()) {
|
||||
if (
|
||||
entryIndex === 0 ||
|
||||
entryIndex % entryProgressInterval === 0 ||
|
||||
entryIndex + 1 === entries.length
|
||||
)
|
||||
onProgress?.({
|
||||
phase: "entries",
|
||||
message: `Checking archive limits · ${(entryIndex + 1).toLocaleString()} / ${entries.length.toLocaleString()} entries`,
|
||||
progress: 0.18 + ratio(entryIndex + 1, entries.length) * 0.18,
|
||||
current: entryIndex + 1,
|
||||
total: entries.length,
|
||||
});
|
||||
validateEntryPath(entry.filename.replace(/\/$/u, "") || entry.filename);
|
||||
if (entryByPath.has(entry.filename))
|
||||
throw new Error(`Duplicate archive path: ${entry.filename}`);
|
||||
@@ -80,6 +230,11 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
||||
const mimetypeFirst = physicalFiles[0]?.filename === "mimetype";
|
||||
const mimetypeStored = mimetypeEntry.compressionMethod === 0;
|
||||
const provisional = { entryByPath };
|
||||
onProgress?.({
|
||||
phase: "container",
|
||||
message: "Reading the EPUB container declaration…",
|
||||
progress: 0.4,
|
||||
});
|
||||
const containerXml = await readEntryText(
|
||||
provisional,
|
||||
"META-INF/container.xml",
|
||||
@@ -92,6 +247,11 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
||||
if (!rootfiles.length)
|
||||
throw new Error("container.xml does not declare a package rootfile.");
|
||||
const packagePath = validateEntryPath(rootfiles[0] ?? "");
|
||||
onProgress?.({
|
||||
phase: "package",
|
||||
message: "Parsing package metadata and reading order…",
|
||||
progress: 0.5,
|
||||
});
|
||||
const packageXml = await readEntryText(
|
||||
provisional,
|
||||
packagePath,
|
||||
@@ -99,6 +259,11 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
||||
);
|
||||
const parsed = parsePackageDocument(packageXml, packagePath);
|
||||
const issues: EpubIssue[] = [];
|
||||
const encryption = await inspectEncryption(
|
||||
provisional,
|
||||
parsed.package.uniqueIdentifierValue,
|
||||
);
|
||||
issues.push(...encryption.issues);
|
||||
if (rootfiles.length > 1)
|
||||
issues.push({
|
||||
severity: "info",
|
||||
@@ -114,6 +279,7 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
||||
package: parsed.package,
|
||||
packageXml,
|
||||
issues,
|
||||
obfuscatedFonts: encryption.fonts,
|
||||
summary: {
|
||||
filename: file.name,
|
||||
fileSize: file.size,
|
||||
@@ -124,9 +290,15 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
||||
mimetypeStored,
|
||||
encryptedResources:
|
||||
entries.some((entry) => entry.encrypted) ||
|
||||
entryByPath.has("META-INF/encryption.xml"),
|
||||
encryption.unsupported > 0,
|
||||
obfuscatedFontCount: encryption.fonts.size,
|
||||
},
|
||||
};
|
||||
onProgress?.({
|
||||
phase: "navigation",
|
||||
message: "Reading publication navigation…",
|
||||
progress: 0.62,
|
||||
});
|
||||
if (book.package.nav?.path && entryByPath.has(book.package.nav.path)) {
|
||||
try {
|
||||
book.package.navigation = parseNavigationDocument(
|
||||
@@ -163,7 +335,29 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
||||
});
|
||||
}
|
||||
}
|
||||
issues.push(...(await validatePublication(book)));
|
||||
onProgress?.({
|
||||
phase: "validation",
|
||||
message: "Validating content documents and local links…",
|
||||
progress: 0.72,
|
||||
});
|
||||
issues.push(
|
||||
...(await validatePublication(book, (current, total, path) =>
|
||||
onProgress?.({
|
||||
phase: "validation",
|
||||
message: path
|
||||
? `Validating content · ${current.toLocaleString()} / ${total.toLocaleString()} · ${path}`
|
||||
: "Finishing publication validation…",
|
||||
progress: 0.72 + ratio(current, total) * 0.23,
|
||||
current,
|
||||
total,
|
||||
}),
|
||||
)),
|
||||
);
|
||||
onProgress?.({
|
||||
phase: "ready",
|
||||
message: "Publication structure is ready.",
|
||||
progress: 0.96,
|
||||
});
|
||||
return book;
|
||||
} catch (reason) {
|
||||
await reader.close().catch(() => undefined);
|
||||
|
||||
@@ -39,3 +39,20 @@ export async function readEntryText(
|
||||
): Promise<string> {
|
||||
return decodeXml(await readEntryBytes(book, path, maximum));
|
||||
}
|
||||
|
||||
/** Read a publication resource and reverse the standardized IDPF font mask. */
|
||||
export async function readPublicationResourceBytes(
|
||||
book: EpubBook,
|
||||
path: string,
|
||||
maximum = EPUB_LIMITS.resourceBytes,
|
||||
): Promise<Uint8Array> {
|
||||
const bytes = await readEntryBytes(book, path, maximum);
|
||||
const obfuscation = book.obfuscatedFonts.get(path);
|
||||
if (!obfuscation) return bytes;
|
||||
const output = bytes.slice();
|
||||
const length = Math.min(1_040, output.length);
|
||||
for (let index = 0; index < length; index += 1)
|
||||
output[index] =
|
||||
output[index]! ^ obfuscation.key[index % obfuscation.key.length]!;
|
||||
return output;
|
||||
}
|
||||
|
||||
+246
-3
@@ -1,6 +1,13 @@
|
||||
import { BlobReader, BlobWriter, TextReader, ZipWriter } from "@zip.js/zip.js";
|
||||
import {
|
||||
BlobReader,
|
||||
BlobWriter,
|
||||
TextReader,
|
||||
Uint8ArrayReader,
|
||||
ZipWriter,
|
||||
} from "@zip.js/zip.js";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import { readEntryText } from "./entries";
|
||||
import DOMPurify from "dompurify";
|
||||
import { readEntryText, readPublicationResourceBytes } from "./entries";
|
||||
import type { EpubBook, EpubMetadata } from "./types";
|
||||
import { elementsByLocalName, parseXml } from "./xml";
|
||||
|
||||
@@ -49,9 +56,16 @@ export function patchPackageMetadata(
|
||||
setMetadataValues(document, metadataElement, "title", [metadata.title]);
|
||||
setMetadataValues(document, metadataElement, "creator", metadata.creators);
|
||||
setMetadataValues(document, metadataElement, "language", [metadata.language]);
|
||||
const identifierId =
|
||||
document.documentElement.getAttribute("unique-identifier") || "pub-id";
|
||||
document.documentElement.setAttribute("unique-identifier", identifierId);
|
||||
setMetadataValues(document, metadataElement, "identifier", [
|
||||
metadata.identifier,
|
||||
]);
|
||||
elementsByLocalName(metadataElement, "identifier")[0]?.setAttribute(
|
||||
"id",
|
||||
identifierId,
|
||||
);
|
||||
setMetadataValues(document, metadataElement, "publisher", [
|
||||
metadata.publisher,
|
||||
]);
|
||||
@@ -93,7 +107,7 @@ export async function rebuildEpub(
|
||||
): Promise<{ blob: Blob; changes: string[]; packageXml: string }> {
|
||||
if (book.summary.encryptedResources)
|
||||
throw new Error(
|
||||
"Rebuilding is disabled when encryption or DRM metadata is present; copying may invalidate protection or obfuscation metadata.",
|
||||
"Rebuilding is disabled when unsupported encryption or DRM metadata is present.",
|
||||
);
|
||||
if (options.cover && !book.package.cover)
|
||||
throw new Error("This publication has no declared cover item to replace.");
|
||||
@@ -110,6 +124,26 @@ export async function rebuildEpub(
|
||||
"Rewrote package metadata",
|
||||
"Placed an uncompressed mimetype entry first",
|
||||
];
|
||||
const oldIdentifier = book.package.uniqueIdentifierValue.replace(
|
||||
/[ \t\r\n]/gu,
|
||||
"",
|
||||
);
|
||||
const newIdentifier = options.metadata.identifier.replace(/[ \t\r\n]/gu, "");
|
||||
let newObfuscationKey: Uint8Array | undefined;
|
||||
if (book.obfuscatedFonts.size && oldIdentifier !== newIdentifier) {
|
||||
const digest = await globalThis.crypto?.subtle?.digest(
|
||||
"SHA-1",
|
||||
new TextEncoder().encode(newIdentifier),
|
||||
);
|
||||
if (!digest)
|
||||
throw new Error(
|
||||
"Web Crypto SHA-1 support is required to preserve obfuscated fonts after changing the publication identifier.",
|
||||
);
|
||||
newObfuscationKey = new Uint8Array(digest);
|
||||
changes.push(
|
||||
`Re-keyed ${book.obfuscatedFonts.size} standardized obfuscated font(s)`,
|
||||
);
|
||||
}
|
||||
await writer.add("mimetype", new TextReader("application/epub+zip"), {
|
||||
level: 0,
|
||||
lastModDate: options.normalizeTimestamps ? fixedDate : undefined,
|
||||
@@ -134,6 +168,19 @@ export async function rebuildEpub(
|
||||
lastModDate,
|
||||
});
|
||||
changes.push(`Replaced cover bytes at ${entry.filename}`);
|
||||
} else if (newObfuscationKey && book.obfuscatedFonts.has(entry.filename)) {
|
||||
const bytes = await readPublicationResourceBytes(
|
||||
book,
|
||||
entry.filename,
|
||||
64 * 1024 * 1024,
|
||||
);
|
||||
for (let byte = 0; byte < Math.min(1_040, bytes.length); byte += 1)
|
||||
bytes[byte] =
|
||||
bytes[byte]! ^ newObfuscationKey[byte % newObfuscationKey.length]!;
|
||||
await writer.add(entry.filename, new Uint8ArrayReader(bytes), {
|
||||
level: entry.compressionMethod === 0 ? 0 : 6,
|
||||
lastModDate,
|
||||
});
|
||||
} else {
|
||||
const blob = await entry.getData(new BlobWriter(), {
|
||||
checkSignature: true,
|
||||
@@ -179,3 +226,199 @@ export async function exportPlainText(book: EpubBook): Promise<Blob> {
|
||||
type: "text/plain;charset=utf-8",
|
||||
});
|
||||
}
|
||||
|
||||
interface ExportSection {
|
||||
title: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
const SAFE_EXPORT_TAGS = [
|
||||
"article",
|
||||
"aside",
|
||||
"b",
|
||||
"bdi",
|
||||
"bdo",
|
||||
"blockquote",
|
||||
"br",
|
||||
"caption",
|
||||
"cite",
|
||||
"code",
|
||||
"col",
|
||||
"colgroup",
|
||||
"data",
|
||||
"dd",
|
||||
"del",
|
||||
"details",
|
||||
"dfn",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"em",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"footer",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"header",
|
||||
"hgroup",
|
||||
"hr",
|
||||
"i",
|
||||
"ins",
|
||||
"kbd",
|
||||
"li",
|
||||
"main",
|
||||
"mark",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"q",
|
||||
"rp",
|
||||
"rt",
|
||||
"ruby",
|
||||
"s",
|
||||
"samp",
|
||||
"section",
|
||||
"small",
|
||||
"span",
|
||||
"strong",
|
||||
"sub",
|
||||
"summary",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"time",
|
||||
"tr",
|
||||
"u",
|
||||
"ul",
|
||||
"var",
|
||||
"wbr",
|
||||
] as const;
|
||||
|
||||
const SAFE_EXPORT_ATTRIBUTES = [
|
||||
"abbr",
|
||||
"colspan",
|
||||
"datetime",
|
||||
"dir",
|
||||
"headers",
|
||||
"id",
|
||||
"lang",
|
||||
"open",
|
||||
"reversed",
|
||||
"role",
|
||||
"rowspan",
|
||||
"scope",
|
||||
"span",
|
||||
"start",
|
||||
"title",
|
||||
"value",
|
||||
] as const;
|
||||
|
||||
async function readingOrderSections(book: EpubBook): Promise<ExportSection[]> {
|
||||
const sections: ExportSection[] = [];
|
||||
let total = 0;
|
||||
for (const spine of book.package.spine) {
|
||||
if (!spine.item || !/xhtml|html/iu.test(spine.item.mediaType)) continue;
|
||||
const source = await readEntryText(book, spine.item.path, 4 * 1024 * 1024);
|
||||
const document = new DOMParser().parseFromString(source, "text/html");
|
||||
document
|
||||
.querySelectorAll(
|
||||
"script, style, link, iframe, object, embed, form, input, button, textarea, select, video, audio, source",
|
||||
)
|
||||
.forEach((element) => element.remove());
|
||||
document.querySelectorAll("img, svg").forEach((element) => {
|
||||
const replacement = document.createElement("span");
|
||||
replacement.textContent = element.getAttribute("alt")
|
||||
? `[Image: ${element.getAttribute("alt")}]`
|
||||
: "[Image omitted]";
|
||||
element.replaceWith(replacement);
|
||||
});
|
||||
document.querySelectorAll("a").forEach((anchor) => {
|
||||
anchor.removeAttribute("href");
|
||||
anchor.removeAttribute("target");
|
||||
});
|
||||
document.querySelectorAll("*").forEach((element) => {
|
||||
for (const attribute of [...element.attributes])
|
||||
if (
|
||||
attribute.name === "style" ||
|
||||
attribute.name.startsWith("on") ||
|
||||
attribute.name === "srcset"
|
||||
)
|
||||
element.removeAttribute(attribute.name);
|
||||
});
|
||||
const title = document.title.trim() || spine.item.id || spine.item.path;
|
||||
const text = (document.body.textContent ?? "")
|
||||
.replace(/[ \t]+/gu, " ")
|
||||
.replace(/\n{3,}/gu, "\n\n")
|
||||
.trim();
|
||||
const html = String(
|
||||
DOMPurify.sanitize(document.body.innerHTML, {
|
||||
ALLOWED_TAGS: [...SAFE_EXPORT_TAGS],
|
||||
ALLOWED_ATTR: [...SAFE_EXPORT_ATTRIBUTES],
|
||||
ALLOW_ARIA_ATTR: true,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
SANITIZE_NAMED_PROPS: true,
|
||||
FORBID_TAGS: [
|
||||
"base",
|
||||
"frame",
|
||||
"frameset",
|
||||
"meta",
|
||||
"noscript",
|
||||
"template",
|
||||
],
|
||||
}),
|
||||
);
|
||||
total += text.length + html.length;
|
||||
if (total > 32 * 1024 * 1024)
|
||||
throw new RangeError(
|
||||
"Reading-order export exceeds the 32 MiB text limit.",
|
||||
);
|
||||
sections.push({ title, text, html });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
export async function exportMarkdown(book: EpubBook): Promise<Blob> {
|
||||
const sections = await readingOrderSections(book);
|
||||
const escapeHeading = (value: string) =>
|
||||
value.replaceAll(/([\\`*_{}[\]()#+.!|-])/gu, "\\$1");
|
||||
return new Blob(
|
||||
[
|
||||
sections
|
||||
.map(
|
||||
(section) => `# ${escapeHeading(section.title)}\n\n${section.text}`,
|
||||
)
|
||||
.join("\n\n---\n\n"),
|
||||
],
|
||||
{ type: "text/markdown;charset=utf-8" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportReadingOrderHtml(book: EpubBook): Promise<Blob> {
|
||||
const sections = await readingOrderSections(book);
|
||||
const escape = (value: string) =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
const title = book.package.metadata.title || "EPUB reading order";
|
||||
const html = `<!doctype html>
|
||||
<html lang="${escape(book.package.metadata.language || "und")}" dir="${book.package.direction}">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>${escape(title)}</title><style>body{max-width:48rem;margin:auto;padding:2rem;font:1rem/1.65 ui-serif,serif;color:#202332;background:#fff}section+section{margin-top:3rem;padding-top:2rem;border-top:1px solid #bbb}table{border-collapse:collapse;max-width:100%}td,th{border:1px solid;padding:.3rem}pre{white-space:pre-wrap}</style></head>
|
||||
<body><header><h1>${escape(title)}</h1><p>${escape(book.package.metadata.creators.join(", "))}</p></header>${sections
|
||||
.map(
|
||||
(section) =>
|
||||
`<section><h1>${escape(section.title)}</h1>${section.html}</section>`,
|
||||
)
|
||||
.join("")}</body></html>`;
|
||||
return new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
}
|
||||
|
||||
+52
-1
@@ -19,6 +19,15 @@ function firstDirect(parent: Element, name: string): Element | undefined {
|
||||
return directChildrenByLocalName(parent, name)[0];
|
||||
}
|
||||
|
||||
function enumeration<T extends string>(
|
||||
value: string | null | undefined,
|
||||
allowed: readonly T[],
|
||||
fallback: T,
|
||||
): T {
|
||||
const normalized = value?.trim().toLowerCase() as T | undefined;
|
||||
return normalized && allowed.includes(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
export function parsePackageDocument(
|
||||
packageXml: string,
|
||||
packagePath: string,
|
||||
@@ -49,6 +58,12 @@ export function parsePackageDocument(
|
||||
.map((item) => text(item))
|
||||
.filter(Boolean),
|
||||
};
|
||||
const uniqueIdentifier = root.getAttribute("unique-identifier") ?? undefined;
|
||||
const identifierElements = elementsByLocalName(metadataElement, "identifier");
|
||||
const uniqueIdentifierValue =
|
||||
identifierElements
|
||||
.find((element) => element.getAttribute("id") === uniqueIdentifier)
|
||||
?.textContent?.trim() ?? metadata.identifier;
|
||||
const packageDirectory = directoryOf(packagePath);
|
||||
const manifest: ManifestItem[] = directChildrenByLocalName(
|
||||
manifestElement,
|
||||
@@ -93,16 +108,52 @@ export function parsePackageDocument(
|
||||
const ncx =
|
||||
(ncxId ? byId.get(ncxId) : undefined) ??
|
||||
manifest.find((item) => item.mediaType === "application/x-dtbncx+xml");
|
||||
const rendition = new Map(
|
||||
elementsByLocalName(metadataElement, "meta")
|
||||
.map(
|
||||
(element) =>
|
||||
[element.getAttribute("property") ?? "", text(element)] as const,
|
||||
)
|
||||
.filter(([property]) => property.startsWith("rendition:")),
|
||||
);
|
||||
return {
|
||||
package: {
|
||||
version: root.getAttribute("version") ?? "",
|
||||
uniqueIdentifier: root.getAttribute("unique-identifier") ?? undefined,
|
||||
uniqueIdentifier,
|
||||
uniqueIdentifierValue,
|
||||
packagePath,
|
||||
packageDirectory,
|
||||
metadata,
|
||||
manifest,
|
||||
spine,
|
||||
navigation: [],
|
||||
direction: enumeration(
|
||||
root.getAttribute("dir"),
|
||||
["ltr", "rtl", "auto"],
|
||||
"auto",
|
||||
),
|
||||
pageProgressionDirection: enumeration(
|
||||
spineElement.getAttribute("page-progression-direction"),
|
||||
["ltr", "rtl", "default"],
|
||||
"default",
|
||||
),
|
||||
rendition: {
|
||||
layout: enumeration(
|
||||
rendition.get("rendition:layout"),
|
||||
["reflowable", "pre-paginated"],
|
||||
"reflowable",
|
||||
),
|
||||
orientation: enumeration(
|
||||
rendition.get("rendition:orientation"),
|
||||
["auto", "landscape", "portrait"],
|
||||
"auto",
|
||||
),
|
||||
spread: enumeration(
|
||||
rendition.get("rendition:spread"),
|
||||
["auto", "none", "landscape", "portrait", "both"],
|
||||
"auto",
|
||||
),
|
||||
},
|
||||
cover,
|
||||
nav,
|
||||
ncx,
|
||||
|
||||
+318
-47
@@ -1,11 +1,59 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { readEntryBytes, readEntryText } from "./entries";
|
||||
import { isExternalReference, resolvePackageHref } from "./paths";
|
||||
import { readEntryText, readPublicationResourceBytes } from "./entries";
|
||||
import {
|
||||
isExternalReference,
|
||||
resolvePackageHref,
|
||||
splitReference,
|
||||
} from "./paths";
|
||||
import type { EpubBook, NavigationItem, RenderedChapter } from "./types";
|
||||
|
||||
export type ReaderTheme = "light" | "dark";
|
||||
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
|
||||
const READER_IMAGE_LIMITS = {
|
||||
bytesPerImage: 25 * 1024 * 1024,
|
||||
uniqueImages: 128,
|
||||
totalBytes: 128 * 1024 * 1024,
|
||||
} as const;
|
||||
const READER_STYLE_LIMITS = {
|
||||
stylesheets: 16,
|
||||
bytesPerStylesheet: 2 * 1024 * 1024,
|
||||
totalStylesheetBytes: 8 * 1024 * 1024,
|
||||
links: 500,
|
||||
} as const;
|
||||
|
||||
const readerStyle = `
|
||||
:root { color-scheme: light dark; font-family: ui-serif, Georgia, serif; line-height: 1.62; }
|
||||
body { max-width: 48rem; margin: 0 auto; padding: 1.4rem; overflow-wrap: anywhere; }
|
||||
:root {
|
||||
--epub-reader-background: #ffffff;
|
||||
--epub-reader-text: #202332;
|
||||
color-scheme: light;
|
||||
font-family: ui-serif, Georgia, serif;
|
||||
line-height: 1.62;
|
||||
background: var(--epub-reader-background) !important;
|
||||
color: var(--epub-reader-text) !important;
|
||||
}
|
||||
:root[data-epub-reader-theme="dark"] {
|
||||
--epub-reader-background: #171d2d;
|
||||
--epub-reader-text: #edf1fb;
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
min-height: 100vh;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.4rem;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--epub-reader-background) !important;
|
||||
color: var(--epub-reader-text) !important;
|
||||
}
|
||||
:root[data-epub-layout="pre-paginated"] body {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
:root[dir="rtl"] body { direction: rtl; }
|
||||
[data-epub-reader-target="true"] {
|
||||
outline: .2rem solid #8055c7 !important;
|
||||
outline-offset: .2rem;
|
||||
}
|
||||
img, svg { max-width: 100%; height: auto; }
|
||||
table { max-width: 100%; border-collapse: collapse; overflow-wrap: anywhere; }
|
||||
th, td { padding: .3rem; border: 1px solid currentColor; }
|
||||
@@ -13,6 +61,13 @@ const readerStyle = `
|
||||
a { color: inherit; text-decoration: underline dotted; }
|
||||
`;
|
||||
|
||||
export function applyReaderTheme(html: string, theme: ReaderTheme): string {
|
||||
return html.replace(
|
||||
/<html(?=[\s>])/u,
|
||||
`<html data-epub-reader-theme="${theme}"`,
|
||||
);
|
||||
}
|
||||
|
||||
function flattenNavigation(items: NavigationItem[]): NavigationItem[] {
|
||||
return items.flatMap((item) => [item, ...flattenNavigation(item.children)]);
|
||||
}
|
||||
@@ -24,6 +79,38 @@ function safeInlineStyle(value: string): string {
|
||||
.replace(/expression\s*\([^)]*\)/giu, "");
|
||||
}
|
||||
|
||||
function safeStylesheetBase(value: string): string {
|
||||
return value
|
||||
.replace(/@charset\s+[^;]+;?/giu, "")
|
||||
.replace(/@import\s+(?:url\s*\([^)]*\)|[^;])+;?/giu, "")
|
||||
.replace(/expression\s*\([^)]*\)/giu, "")
|
||||
.replace(/(?:behavior|-moz-binding)\s*:[^;}]*/giu, "");
|
||||
}
|
||||
|
||||
async function rewriteStylesheetUrls(
|
||||
value: string,
|
||||
basePath: string,
|
||||
resolve: (reference: string, basePath: string) => Promise<string | undefined>,
|
||||
): Promise<string> {
|
||||
const source = safeStylesheetBase(value);
|
||||
const pattern = /url\s*\(\s*(["']?)(.*?)\1\s*\)/giu;
|
||||
const output: string[] = [];
|
||||
let cursor = 0;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const index = match.index;
|
||||
if (index === undefined) continue;
|
||||
output.push(source.slice(cursor, index));
|
||||
const reference = (match[2] ?? "").trim();
|
||||
const replacement = reference
|
||||
? await resolve(reference, basePath).catch(() => undefined)
|
||||
: undefined;
|
||||
output.push(replacement ? `url("${replacement}")` : "url(about:blank)");
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
output.push(source.slice(cursor));
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
export async function renderChapter(
|
||||
book: EpubBook,
|
||||
path: string,
|
||||
@@ -41,6 +128,18 @@ export async function renderChapter(
|
||||
"Only XHTML, HTML, and SVG spine documents can be rendered.",
|
||||
);
|
||||
const source = await readEntryText(book, path);
|
||||
const originalDocument = new DOMParser().parseFromString(source, "text/html");
|
||||
const linkedStylesheets = [
|
||||
...originalDocument.querySelectorAll<HTMLLinkElement>("link[href]"),
|
||||
]
|
||||
.filter((link) =>
|
||||
(link.getAttribute("rel") ?? "")
|
||||
.toLowerCase()
|
||||
.split(/\s+/u)
|
||||
.includes("stylesheet"),
|
||||
)
|
||||
.slice(0, READER_STYLE_LIMITS.stylesheets)
|
||||
.map((link) => link.getAttribute("href") ?? "");
|
||||
const sanitized = DOMPurify.sanitize(source, {
|
||||
WHOLE_DOCUMENT: true,
|
||||
USE_PROFILES: { html: true, svg: true, svgFilters: false },
|
||||
@@ -64,70 +163,221 @@ export async function renderChapter(
|
||||
String(sanitized),
|
||||
"text/html",
|
||||
);
|
||||
const objectUrls: string[] = [];
|
||||
const manifestByPath = new Map(
|
||||
book.package.manifest.map((candidate) => [candidate.path, candidate]),
|
||||
);
|
||||
const resourceUrls = new Map<string, string>();
|
||||
let resolvedResourceBytes = 0;
|
||||
const resolveResource = async (
|
||||
reference: string,
|
||||
basePath: string,
|
||||
allowed = /^(?:image|font|audio|video)\//iu,
|
||||
): Promise<string | undefined> => {
|
||||
const value = reference.trim();
|
||||
if (!value || value.startsWith("#")) return value || undefined;
|
||||
if (value.startsWith("data:"))
|
||||
return /^data:(?:image|font|audio|video)\//iu.test(value)
|
||||
? value
|
||||
: undefined;
|
||||
if (isExternalReference(value)) return undefined;
|
||||
const target = resolvePackageHref(basePath, value);
|
||||
const resource = target ? manifestByPath.get(target) : undefined;
|
||||
const applicationFont =
|
||||
resource &&
|
||||
/^(?:application\/(?:font-woff|vnd\.ms-fontobject|x-font-(?:ttf|opentype)))$/iu.test(
|
||||
resource.mediaType,
|
||||
);
|
||||
if (
|
||||
!target ||
|
||||
!resource ||
|
||||
(!allowed.test(resource.mediaType) &&
|
||||
!(applicationFont && allowed.test("font/legacy"))) ||
|
||||
!book.entryByPath.has(target)
|
||||
)
|
||||
return undefined;
|
||||
let url = resourceUrls.get(target);
|
||||
if (!url) {
|
||||
const entry = book.entryByPath.get(target);
|
||||
if (
|
||||
!entry ||
|
||||
resourceUrls.size >= READER_IMAGE_LIMITS.uniqueImages ||
|
||||
entry.uncompressedSize > READER_IMAGE_LIMITS.bytesPerImage ||
|
||||
resolvedResourceBytes + entry.uncompressedSize >
|
||||
READER_IMAGE_LIMITS.totalBytes
|
||||
)
|
||||
return undefined;
|
||||
const bytes = await readPublicationResourceBytes(
|
||||
book,
|
||||
target,
|
||||
READER_IMAGE_LIMITS.bytesPerImage,
|
||||
);
|
||||
url = URL.createObjectURL(
|
||||
new Blob([bytes as BlobPart], { type: resource.mediaType }),
|
||||
);
|
||||
resourceUrls.set(target, url);
|
||||
objectUrls.push(url);
|
||||
resolvedResourceBytes += entry.uncompressedSize;
|
||||
}
|
||||
const { fragment } = splitReference(value);
|
||||
return fragment ? `${url}#${fragment}` : url;
|
||||
};
|
||||
for (const element of [...document.querySelectorAll<HTMLElement>("[style]")])
|
||||
element.setAttribute(
|
||||
"style",
|
||||
safeInlineStyle(element.getAttribute("style") ?? ""),
|
||||
);
|
||||
for (const style of [...document.querySelectorAll("style")])
|
||||
style.textContent = safeInlineStyle(style.textContent ?? "");
|
||||
for (const anchor of [
|
||||
...document.querySelectorAll<HTMLAnchorElement>("a[href]"),
|
||||
]) {
|
||||
const original = anchor.getAttribute("href") ?? "";
|
||||
style.textContent = await rewriteStylesheetUrls(
|
||||
style.textContent ?? "",
|
||||
path,
|
||||
resolveResource,
|
||||
);
|
||||
const links: RenderedChapter["links"] = [];
|
||||
for (const anchor of [...document.querySelectorAll("a")]) {
|
||||
const direct = anchor.getAttribute("href");
|
||||
const legacy = anchor.getAttributeNS(XLINK_NAMESPACE, "href");
|
||||
const original = direct?.trim() ? direct : (legacy ?? direct ?? "");
|
||||
if (direct === null && legacy === null) continue;
|
||||
const external = isExternalReference(original);
|
||||
let targetPath: string | undefined;
|
||||
if (!external && original)
|
||||
try {
|
||||
targetPath = resolvePackageHref(path, original);
|
||||
} catch {
|
||||
targetPath = undefined;
|
||||
}
|
||||
const { fragment } = splitReference(original);
|
||||
if (links.length < READER_STYLE_LIMITS.links)
|
||||
links.push({
|
||||
label:
|
||||
(anchor.textContent ?? "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.slice(0, 300) ||
|
||||
original ||
|
||||
"Untitled link",
|
||||
reference: original,
|
||||
...(targetPath ? { path: targetPath } : {}),
|
||||
...(fragment ? { fragment } : {}),
|
||||
external,
|
||||
});
|
||||
anchor.removeAttribute("href");
|
||||
anchor.removeAttribute("xlink:href");
|
||||
anchor.removeAttributeNS(XLINK_NAMESPACE, "href");
|
||||
anchor.setAttribute("data-epub-href", original);
|
||||
anchor.setAttribute("href", "#");
|
||||
anchor.removeAttribute("target");
|
||||
anchor.title = original
|
||||
? `EPUB link disabled in safe reader: ${original}`
|
||||
: "EPUB link";
|
||||
anchor.setAttribute(
|
||||
"title",
|
||||
original ? `EPUB reader link: ${original}` : "EPUB link",
|
||||
);
|
||||
}
|
||||
const objectUrls: string[] = [];
|
||||
const resourceNodes: Array<{ element: Element; attribute: string }> = [
|
||||
const resourceNodes: Array<{
|
||||
reference: string;
|
||||
allowed: RegExp;
|
||||
replace: (value: string) => void;
|
||||
remove: () => void;
|
||||
}> = [
|
||||
...[...document.querySelectorAll("img[src]")].map((element) => ({
|
||||
element,
|
||||
attribute: "src",
|
||||
reference: element.getAttribute("src") ?? "",
|
||||
allowed: /^image\//iu,
|
||||
replace: (value: string) => element.setAttribute("src", value),
|
||||
remove: () => element.removeAttribute("src"),
|
||||
})),
|
||||
...[...document.querySelectorAll("svg image[href]")].map((element) => ({
|
||||
element,
|
||||
attribute: "href",
|
||||
...[
|
||||
...document.querySelectorAll("audio[src], video[src], source[src]"),
|
||||
].map((element) => ({
|
||||
reference: element.getAttribute("src") ?? "",
|
||||
allowed: /^(?:audio|video)\//iu,
|
||||
replace: (value: string) => element.setAttribute("src", value),
|
||||
remove: () => element.removeAttribute("src"),
|
||||
})),
|
||||
...[...document.querySelectorAll("svg image[xlink\\:href]")].map(
|
||||
(element) => ({ element, attribute: "xlink:href" }),
|
||||
),
|
||||
...[...document.querySelectorAll("video[poster]")].map((element) => ({
|
||||
reference: element.getAttribute("poster") ?? "",
|
||||
allowed: /^image\//iu,
|
||||
replace: (value: string) => element.setAttribute("poster", value),
|
||||
remove: () => element.removeAttribute("poster"),
|
||||
})),
|
||||
...[...document.querySelectorAll("svg image")]
|
||||
.map((element) => {
|
||||
const direct = element.getAttribute("href");
|
||||
const legacy = element.getAttributeNS(XLINK_NAMESPACE, "href");
|
||||
const reference = direct?.trim() ? direct : (legacy ?? direct);
|
||||
if (reference === null) return undefined;
|
||||
const remove = () => {
|
||||
element.removeAttribute("href");
|
||||
element.removeAttribute("xlink:href");
|
||||
element.removeAttributeNS(XLINK_NAMESPACE, "href");
|
||||
};
|
||||
return {
|
||||
reference,
|
||||
allowed: /^image\//iu,
|
||||
replace: (value: string) => {
|
||||
remove();
|
||||
if (direct !== null) element.setAttribute("href", value);
|
||||
else element.setAttributeNS(XLINK_NAMESPACE, "xlink:href", value);
|
||||
},
|
||||
remove,
|
||||
};
|
||||
})
|
||||
.filter((item) => item !== undefined),
|
||||
];
|
||||
for (const { element, attribute } of resourceNodes) {
|
||||
const reference = element.getAttribute(attribute) ?? "";
|
||||
if (
|
||||
!reference ||
|
||||
reference.startsWith("data:") ||
|
||||
isExternalReference(reference)
|
||||
) {
|
||||
if (isExternalReference(reference)) element.removeAttribute(attribute);
|
||||
for (const resourceNode of resourceNodes) {
|
||||
const { reference } = resourceNode;
|
||||
if (!reference) {
|
||||
resourceNode.remove();
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const target = resolvePackageHref(path, reference);
|
||||
const resource = target
|
||||
? book.package.manifest.find((candidate) => candidate.path === target)
|
||||
const replacement = await resolveResource(
|
||||
reference,
|
||||
path,
|
||||
resourceNode.allowed,
|
||||
);
|
||||
if (replacement) resourceNode.replace(replacement);
|
||||
else resourceNode.remove();
|
||||
} catch {
|
||||
resourceNode.remove();
|
||||
}
|
||||
}
|
||||
const publisherStyles: HTMLStyleElement[] = [];
|
||||
let stylesheetBytes = 0;
|
||||
for (const reference of linkedStylesheets) {
|
||||
try {
|
||||
const stylesheetPath = resolvePackageHref(path, reference);
|
||||
const stylesheetItem = stylesheetPath
|
||||
? manifestByPath.get(stylesheetPath)
|
||||
: undefined;
|
||||
const entry = stylesheetPath
|
||||
? book.entryByPath.get(stylesheetPath)
|
||||
: undefined;
|
||||
if (
|
||||
!target ||
|
||||
!resource ||
|
||||
!resource.mediaType.startsWith("image/") ||
|
||||
!book.entryByPath.has(target)
|
||||
) {
|
||||
element.removeAttribute(attribute);
|
||||
!stylesheetPath ||
|
||||
stylesheetItem?.mediaType !== "text/css" ||
|
||||
!entry ||
|
||||
entry.uncompressedSize > READER_STYLE_LIMITS.bytesPerStylesheet ||
|
||||
stylesheetBytes + entry.uncompressedSize >
|
||||
READER_STYLE_LIMITS.totalStylesheetBytes
|
||||
)
|
||||
continue;
|
||||
}
|
||||
const bytes = await readEntryBytes(book, target, 25 * 1024 * 1024);
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([bytes as BlobPart], { type: resource.mediaType }),
|
||||
const css = await readEntryText(
|
||||
book,
|
||||
stylesheetPath,
|
||||
READER_STYLE_LIMITS.bytesPerStylesheet,
|
||||
);
|
||||
objectUrls.push(url);
|
||||
element.setAttribute(attribute, url);
|
||||
const style = document.createElement("style");
|
||||
style.dataset.epubPublisherStylesheet = stylesheetPath;
|
||||
style.textContent = await rewriteStylesheetUrls(
|
||||
css,
|
||||
stylesheetPath,
|
||||
resolveResource,
|
||||
);
|
||||
publisherStyles.push(style);
|
||||
stylesheetBytes += entry.uncompressedSize;
|
||||
} catch {
|
||||
element.removeAttribute(attribute);
|
||||
// A missing or malformed optional publisher stylesheet cannot make the
|
||||
// safe fallback reader unusable.
|
||||
}
|
||||
}
|
||||
const csp = document.createElement("meta");
|
||||
@@ -136,7 +386,25 @@ export async function renderChapter(
|
||||
"default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data: blob:; media-src blob: data:; form-action 'none'; base-uri 'none'";
|
||||
const style = document.createElement("style");
|
||||
style.textContent = readerStyle;
|
||||
document.head.prepend(csp, style);
|
||||
document.documentElement.removeAttribute("data-epub-reader-theme");
|
||||
const sourceDirection = originalDocument.documentElement.getAttribute("dir");
|
||||
const direction = /^(?:ltr|rtl)$/iu.test(sourceDirection ?? "")
|
||||
? (sourceDirection!.toLowerCase() as "ltr" | "rtl")
|
||||
: book.package.direction;
|
||||
const spineItem = book.package.spine.find(
|
||||
(candidate) => candidate.item?.path === path,
|
||||
);
|
||||
const layout =
|
||||
item.properties.includes("rendition:layout-pre-paginated") ||
|
||||
spineItem?.properties.includes("rendition:layout-pre-paginated")
|
||||
? "pre-paginated"
|
||||
: item.properties.includes("rendition:layout-reflowable") ||
|
||||
spineItem?.properties.includes("rendition:layout-reflowable")
|
||||
? "reflowable"
|
||||
: book.package.rendition.layout;
|
||||
document.documentElement.setAttribute("dir", direction);
|
||||
document.documentElement.dataset.epubLayout = layout;
|
||||
document.head.prepend(csp, ...publisherStyles, style);
|
||||
const title =
|
||||
document.title.trim() ||
|
||||
flattenNavigation(book.package.navigation).find(
|
||||
@@ -151,6 +419,9 @@ export async function renderChapter(
|
||||
title,
|
||||
objectUrls,
|
||||
text,
|
||||
direction,
|
||||
layout,
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { readEntryText } from "./entries";
|
||||
import type { EpubBook, NavigationItem } from "./types";
|
||||
|
||||
export interface EpubSearchResult {
|
||||
path: string;
|
||||
title: string;
|
||||
occurrences: number;
|
||||
snippets: string[];
|
||||
}
|
||||
|
||||
export interface EpubSearchProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const SEARCH_LIMITS = {
|
||||
query: 200,
|
||||
documents: 2_000,
|
||||
bytesPerDocument: 8 * 1024 * 1024,
|
||||
totalBytes: 64 * 1024 * 1024,
|
||||
results: 500,
|
||||
occurrencesPerDocument: 10_000,
|
||||
snippetsPerDocument: 5,
|
||||
} as const;
|
||||
|
||||
function flatten(items: readonly NavigationItem[]): NavigationItem[] {
|
||||
return items.flatMap((item) => [item, ...flatten(item.children)]);
|
||||
}
|
||||
|
||||
function visibleText(source: string): { title: string; text: string } {
|
||||
const document = new DOMParser().parseFromString(source, "text/html");
|
||||
document
|
||||
.querySelectorAll(
|
||||
"script, style, template, iframe, object, embed, form, noscript",
|
||||
)
|
||||
.forEach((element) => element.remove());
|
||||
return {
|
||||
title: document.title.trim(),
|
||||
text: (
|
||||
document.body.textContent ??
|
||||
document.documentElement.textContent ??
|
||||
""
|
||||
)
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchPublication(
|
||||
book: EpubBook,
|
||||
input: string,
|
||||
onProgress?: (progress: EpubSearchProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EpubSearchResult[]> {
|
||||
const query = input.trim();
|
||||
if (!query) return [];
|
||||
if (query.length > SEARCH_LIMITS.query)
|
||||
throw new Error("Search query exceeds 200 characters.");
|
||||
const candidates = book.package.spine
|
||||
.flatMap((spine) =>
|
||||
spine.item &&
|
||||
/^(?:application\/xhtml\+xml|text\/html)$/iu.test(spine.item.mediaType)
|
||||
? [spine.item]
|
||||
: [],
|
||||
)
|
||||
.slice(0, SEARCH_LIMITS.documents);
|
||||
const navigation = flatten(book.package.navigation);
|
||||
const needle = query.toLocaleLowerCase();
|
||||
const results: EpubSearchResult[] = [];
|
||||
let bytes = 0;
|
||||
for (const [index, item] of candidates.entries()) {
|
||||
if (signal?.aborted)
|
||||
throw new DOMException("Search cancelled.", "AbortError");
|
||||
const entry = book.entryByPath.get(item.path);
|
||||
if (!entry || entry.uncompressedSize > SEARCH_LIMITS.bytesPerDocument)
|
||||
continue;
|
||||
bytes += entry.uncompressedSize;
|
||||
if (bytes > SEARCH_LIMITS.totalBytes)
|
||||
throw new Error("Publication search reached the 64 MiB text limit.");
|
||||
onProgress?.({
|
||||
current: index + 1,
|
||||
total: candidates.length,
|
||||
path: item.path,
|
||||
});
|
||||
const extracted = visibleText(
|
||||
await readEntryText(book, item.path, SEARCH_LIMITS.bytesPerDocument),
|
||||
);
|
||||
const haystack = extracted.text.toLocaleLowerCase();
|
||||
let cursor = 0;
|
||||
let occurrences = 0;
|
||||
const snippets: string[] = [];
|
||||
while (cursor <= haystack.length - needle.length) {
|
||||
const found = haystack.indexOf(needle, cursor);
|
||||
if (found < 0) break;
|
||||
occurrences += 1;
|
||||
if (snippets.length < SEARCH_LIMITS.snippetsPerDocument) {
|
||||
const start = Math.max(0, found - 70);
|
||||
const end = Math.min(extracted.text.length, found + query.length + 90);
|
||||
snippets.push(
|
||||
`${start ? "…" : ""}${extracted.text.slice(start, end)}${end < extracted.text.length ? "…" : ""}`,
|
||||
);
|
||||
}
|
||||
cursor = found + Math.max(1, needle.length);
|
||||
if (occurrences >= SEARCH_LIMITS.occurrencesPerDocument) break;
|
||||
}
|
||||
if (occurrences && results.length < SEARCH_LIMITS.results)
|
||||
results.push({
|
||||
path: item.path,
|
||||
title:
|
||||
extracted.title ||
|
||||
navigation.find((navigationItem) => navigationItem.path === item.path)
|
||||
?.label ||
|
||||
item.id ||
|
||||
item.path,
|
||||
occurrences,
|
||||
snippets,
|
||||
});
|
||||
if (index % 8 === 0)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
return results.sort(
|
||||
(left, right) =>
|
||||
right.occurrences - left.occurrences ||
|
||||
left.title.localeCompare(right.title),
|
||||
);
|
||||
}
|
||||
@@ -43,12 +43,20 @@ export interface NavigationItem {
|
||||
export interface EpubPackage {
|
||||
version: string;
|
||||
uniqueIdentifier?: string;
|
||||
uniqueIdentifierValue: string;
|
||||
packagePath: string;
|
||||
packageDirectory: string;
|
||||
metadata: EpubMetadata;
|
||||
manifest: ManifestItem[];
|
||||
spine: SpineItem[];
|
||||
navigation: NavigationItem[];
|
||||
direction: "ltr" | "rtl" | "auto";
|
||||
pageProgressionDirection: "ltr" | "rtl" | "default";
|
||||
rendition: {
|
||||
layout: "reflowable" | "pre-paginated";
|
||||
orientation: "auto" | "landscape" | "portrait";
|
||||
spread: "auto" | "none" | "landscape" | "portrait" | "both";
|
||||
};
|
||||
cover?: ManifestItem;
|
||||
nav?: ManifestItem;
|
||||
ncx?: ManifestItem;
|
||||
@@ -61,6 +69,14 @@ export interface EpubEntrySummary {
|
||||
directory: boolean;
|
||||
}
|
||||
|
||||
export interface EpubAdaptation {
|
||||
kind: "html" | "markdown" | "text";
|
||||
sourceFilename: string;
|
||||
sourceMediaType: string;
|
||||
sourceBytes: number;
|
||||
losses: string[];
|
||||
}
|
||||
|
||||
export interface EpubBook {
|
||||
file: File;
|
||||
reader: ZipReader<Blob>;
|
||||
@@ -69,6 +85,11 @@ export interface EpubBook {
|
||||
package: EpubPackage;
|
||||
packageXml: string;
|
||||
issues: EpubIssue[];
|
||||
adapter?: EpubAdaptation;
|
||||
obfuscatedFonts: Map<
|
||||
string,
|
||||
{ algorithm: "http://www.idpf.org/2008/embedding"; key: Uint8Array }
|
||||
>;
|
||||
summary: {
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
@@ -78,6 +99,7 @@ export interface EpubBook {
|
||||
mimetypeFirst: boolean;
|
||||
mimetypeStored: boolean;
|
||||
encryptedResources: boolean;
|
||||
obfuscatedFontCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,4 +109,13 @@ export interface RenderedChapter {
|
||||
title: string;
|
||||
objectUrls: string[];
|
||||
text: string;
|
||||
direction: "ltr" | "rtl" | "auto";
|
||||
layout: "reflowable" | "pre-paginated";
|
||||
links: Array<{
|
||||
label: string;
|
||||
reference: string;
|
||||
path?: string;
|
||||
fragment?: string;
|
||||
external: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
+46
-18
@@ -3,6 +3,14 @@ import { isExternalReference, resolvePackageHref } from "./paths";
|
||||
import type { EpubBook, EpubIssue } from "./types";
|
||||
import { parseXml } from "./xml";
|
||||
|
||||
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
|
||||
|
||||
export type ValidationProgressCallback = (
|
||||
current: number,
|
||||
total: number,
|
||||
path?: string,
|
||||
) => void;
|
||||
|
||||
function issue(
|
||||
severity: EpubIssue["severity"],
|
||||
code: string,
|
||||
@@ -14,6 +22,7 @@ function issue(
|
||||
|
||||
export async function validatePublication(
|
||||
book: EpubBook,
|
||||
onProgress?: ValidationProgressCallback,
|
||||
): Promise<EpubIssue[]> {
|
||||
const result: EpubIssue[] = [];
|
||||
if (!book.summary.mimetypeFirst)
|
||||
@@ -101,12 +110,12 @@ export async function validatePublication(
|
||||
book.package.packagePath,
|
||||
),
|
||||
);
|
||||
if (book.entryByPath.has("META-INF/encryption.xml"))
|
||||
if (book.summary.encryptedResources)
|
||||
result.push(
|
||||
issue(
|
||||
"warning",
|
||||
"encrypted-resources",
|
||||
"META-INF/encryption.xml is present. Font obfuscation may be valid; DRM-protected content is detected but not bypassed.",
|
||||
"Unsupported encrypted/protected resources are present. DRM is detected but never bypassed.",
|
||||
"META-INF/encryption.xml",
|
||||
),
|
||||
);
|
||||
@@ -199,14 +208,20 @@ export async function validatePublication(
|
||||
const linkedTargets = new Set<string>();
|
||||
let inspectedBytes = 0;
|
||||
let inspectedDocuments = 0;
|
||||
for (const item of book.package.manifest) {
|
||||
if (
|
||||
!/^(?:application\/xhtml\+xml|image\/svg\+xml|text\/html)$/iu.test(
|
||||
const contentItems = book.package.manifest.filter(
|
||||
(item) =>
|
||||
/^(?:application\/xhtml\+xml|image\/svg\+xml|text\/html)$/iu.test(
|
||||
item.mediaType,
|
||||
) ||
|
||||
!book.entryByPath.has(item.path)
|
||||
) && book.entryByPath.has(item.path),
|
||||
);
|
||||
const progressInterval = Math.max(1, Math.ceil(contentItems.length / 100));
|
||||
for (const [contentIndex, item] of contentItems.entries()) {
|
||||
if (
|
||||
contentIndex === 0 ||
|
||||
contentIndex % progressInterval === 0 ||
|
||||
contentIndex + 1 === contentItems.length
|
||||
)
|
||||
continue;
|
||||
onProgress?.(contentIndex, contentItems.length, item.path);
|
||||
const entry = book.entryByPath.get(item.path);
|
||||
if (
|
||||
!entry ||
|
||||
@@ -232,15 +247,27 @@ export async function validatePublication(
|
||||
item.path,
|
||||
),
|
||||
);
|
||||
for (const element of [
|
||||
...document.querySelectorAll("[href], [src], [poster]"),
|
||||
]) {
|
||||
const attribute = element.hasAttribute("href")
|
||||
? "href"
|
||||
: element.hasAttribute("src")
|
||||
? "src"
|
||||
: "poster";
|
||||
const reference = element.getAttribute(attribute)?.trim() ?? "";
|
||||
const references = [...document.querySelectorAll("*")].flatMap(
|
||||
(element) => {
|
||||
const values: Array<{ attribute: string; reference: string }> = [];
|
||||
for (const attribute of ["href", "src", "poster"])
|
||||
if (element.hasAttribute(attribute))
|
||||
values.push({
|
||||
attribute,
|
||||
reference: element.getAttribute(attribute) ?? "",
|
||||
});
|
||||
const legacyHref = element.getAttributeNS(XLINK_NAMESPACE, "href");
|
||||
if (legacyHref !== null)
|
||||
values.push({ attribute: "xlink:href", reference: legacyHref });
|
||||
return values.map((value) => ({ element, ...value }));
|
||||
},
|
||||
);
|
||||
for (const {
|
||||
element,
|
||||
attribute,
|
||||
reference: rawReference,
|
||||
} of references) {
|
||||
const reference = rawReference.trim();
|
||||
if (
|
||||
!reference ||
|
||||
reference.startsWith("#") ||
|
||||
@@ -248,7 +275,7 @@ export async function validatePublication(
|
||||
)
|
||||
continue;
|
||||
if (isExternalReference(reference)) {
|
||||
if (attribute !== "href" || element.localName !== "a")
|
||||
if (!attribute.endsWith("href") || element.localName !== "a")
|
||||
result.push(
|
||||
issue(
|
||||
"warning",
|
||||
@@ -299,6 +326,7 @@ export async function validatePublication(
|
||||
);
|
||||
}
|
||||
}
|
||||
onProgress?.(contentItems.length, contentItems.length);
|
||||
if (
|
||||
inspectedDocuments <
|
||||
book.package.manifest.filter((item) =>
|
||||
|
||||
+158
-2
@@ -60,6 +60,10 @@ button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.button[aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
:where(button, input, select, textarea, a):focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
@@ -97,6 +101,11 @@ input[type="file"] {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.book-workspaces {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hero,
|
||||
.panel,
|
||||
.summary-grid article {
|
||||
@@ -239,7 +248,7 @@ input[type="file"] {
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.workspace-tabs button[aria-selected="true"] {
|
||||
.workspace-tabs button[aria-pressed="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
@@ -292,18 +301,109 @@ input[type="file"] {
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
.navigation-tree {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.navigation-tree[data-depth="1"] {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
.navigation-tree[data-depth="2"],
|
||||
.navigation-tree[data-depth="3"] {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
.navigation-tree button {
|
||||
width: 100%;
|
||||
}
|
||||
.reader-tool {
|
||||
margin-top: 0.45rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.reader-tool > summary {
|
||||
padding: 0.65rem;
|
||||
cursor: pointer;
|
||||
font-weight: 760;
|
||||
}
|
||||
.reader-tool-body {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0 0.65rem 0.65rem;
|
||||
}
|
||||
.reader-tool-body textarea {
|
||||
min-height: 5rem;
|
||||
}
|
||||
.reader-tool-body > small {
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.search-results,
|
||||
.reading-notes-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
}
|
||||
.search-results button {
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
gap: 0.2rem;
|
||||
text-align: left;
|
||||
}
|
||||
.search-results button :where(small, span),
|
||||
.reading-notes-list small {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.reading-notes-list article {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.reading-notes-list article > button:first-child {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-content: start;
|
||||
gap: 0.2rem;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
.reading-notes-list article > button:last-child {
|
||||
width: 2.55rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.page-reader {
|
||||
min-height: 38rem;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.page-reader .panel-heading small {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.page-reader iframe {
|
||||
width: 100%;
|
||||
height: min(65rem, calc(100vh - 13rem));
|
||||
min-height: 34rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: #fff;
|
||||
background: var(--toolbox-surface);
|
||||
color-scheme: inherit;
|
||||
}
|
||||
.page-reader iframe[data-reader-layout="pre-paginated"] {
|
||||
min-height: min(75rem, calc(100vh - 10rem));
|
||||
}
|
||||
.reader-busy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
@@ -453,6 +553,62 @@ td code {
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.opening-overlay {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
inset: 4.5rem 0 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--toolbox-background) 78%, transparent);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
.opening-card {
|
||||
width: min(100%, 34rem);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.8rem 0.9rem;
|
||||
align-items: center;
|
||||
box-shadow: 0 18px 55px rgb(10 18 40 / 24%);
|
||||
}
|
||||
.opening-card h2,
|
||||
.opening-card p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.opening-card p:not(.eyebrow),
|
||||
.opening-card small {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.opening-card progress,
|
||||
.opening-card small {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
.opening-card progress {
|
||||
height: 0.6rem;
|
||||
accent-color: var(--toolbox-accent);
|
||||
}
|
||||
.opening-spinner {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
flex: 0 0 auto;
|
||||
border: 0.18rem solid var(--toolbox-border);
|
||||
border-top-color: var(--toolbox-accent);
|
||||
border-radius: 50%;
|
||||
animation: opening-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes opening-spin {
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opening-spinner {
|
||||
animation: none;
|
||||
border-right-color: var(--toolbox-accent);
|
||||
}
|
||||
}
|
||||
.help-dialog {
|
||||
width: min(36rem, calc(100% - 2rem));
|
||||
border: 1px solid var(--toolbox-border);
|
||||
|
||||
@@ -3,24 +3,44 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.epub-tools",
|
||||
"name": "EPUB Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Read, inspect and repair EPUB publications locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["documents", "ebooks", "publishing"],
|
||||
"tags": ["epub", "ebook", "metadata", "reader", "repair"],
|
||||
"tags": ["epub", "ebook", "metadata", "reader", "repair", "html", "markdown"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": false,
|
||||
"secureContext": true,
|
||||
"workers": true,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{ "mediaType": "application/epub+zip", "extensions": [".epub"] },
|
||||
{ "mediaType": "text/html", "extensions": [".html", ".htm"] },
|
||||
{ "mediaType": "application/xhtml+xml", "extensions": [".xhtml"] },
|
||||
{ "mediaType": "text/markdown", "extensions": [".md", ".markdown"] },
|
||||
{ "mediaType": "text/plain", "extensions": [".txt"] }
|
||||
],
|
||||
"produces": [
|
||||
{ "mediaType": "application/epub+zip", "extensions": [".epub"] },
|
||||
{ "mediaType": "text/plain", "extensions": [".txt"] },
|
||||
{ "mediaType": "text/markdown", "extensions": [".md"] },
|
||||
{ "mediaType": "text/html", "extensions": [".html"] },
|
||||
{ "mediaType": "application/json", "extensions": [".json"] }
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": ["workers", "web-crypto"],
|
||||
"optional": []
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
Reference in New Issue
Block a user