Release EPUB Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 08:53:49 +02:00
parent f68dcfe4ea
commit efd80eb18a
41 changed files with 3250 additions and 614 deletions
+12 -1
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+386
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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 byte16 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 byte16 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;
}
+146
View File
@@ -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
View File
@@ -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);
+17
View File
@@ -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
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
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
View File
@@ -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
View File
@@ -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,
};
}
+127
View File
@@ -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),
);
}
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+23 -3
View File
@@ -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
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";