Release EPUB Tools 0.1.0

This commit is contained in:
2026-09-01 02:39:03 +02:00
commit f68dcfe4ea
66 changed files with 10579 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<ErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
>
<Suspense
fallback={
<p className="loading" role="status">
Preparing EPUB Tools
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</ErrorBoundary>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
export class ErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Application failure", error, info);
}
render() {
if (this.state.error)
return (
<main className="fatal">
<h1>EPUB Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</main>
);
return this.props.children;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const node = dialog.current;
if (!node) return;
if (open && !node.open) node.showModal();
if (!open && node.open) node.close();
}, [open]);
return (
<dialog
ref={dialog}
className="help-dialog"
onClose={onClose}
onCancel={onClose}
aria-labelledby="help-title"
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first help</p>
<h2 id="help-title">About EPUB Tools</h2>
</div>
<button type="button" onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>Read, inspect and repair EPUB publications locally in the browser.</p>
<p>
All processing is performed in this browser. Imported data is treated as
untrusted and bounded before parsing.
</p>
</dialog>
);
}
+732
View File
@@ -0,0 +1,732 @@
import { useEffect, useMemo, useState } from "react";
import {
sanitizeDownloadFilename,
triggerBlobDownload,
} from "@add-ideas/toolbox-helpers";
import { closeBook, openEpub, readEntryBytes } from "../epub/archive";
import {
downloadJson,
exportPlainText,
rebuildEpub,
type MetadataPatch,
} from "../epub/export";
import { renderChapter, revokeRenderedChapter } from "../epub/reader";
import type {
EpubBook,
ManifestItem,
NavigationItem,
RenderedChapter,
} from "../epub/types";
type View = "reader" | "metadata" | "validation" | "package" | "export";
const views: ReadonlyArray<readonly [View, string]> = [
["reader", "Reader"],
["metadata", "Metadata"],
["validation", "Validation"],
["package", "Package tree"],
["export", "Export & rebuild"],
];
function initialMetadata(book: EpubBook): MetadataPatch {
return {
...book.package.metadata,
creators: [...book.package.metadata.creators],
subjects: [...book.package.metadata.subjects],
};
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
const units = ["KiB", "MiB", "GiB"];
let scaled = value / 1024;
let unit = 0;
while (scaled >= 1024 && unit < units.length - 1) {
scaled /= 1024;
unit += 1;
}
return `${scaled.toFixed(scaled < 10 ? 1 : 0)} ${units[unit]}`;
}
function flatten(items: NavigationItem[]): NavigationItem[] {
return items.flatMap((item) => [item, ...flatten(item.children)]);
}
function EmptyState({ onFile }: { onFile: (file: File) => void }) {
return (
<section className="drop panel">
<div className="book-mark" aria-hidden="true">
EPUB
</div>
<div>
<p className="eyebrow">Open a local publication</p>
<h2>Inspect and read without uploading</h2>
<p>
Select an EPUB 2 or EPUB 3 file. Package parsing, validation, chapter
rendering, metadata editing, and rebuilding stay in this browser.
</p>
<label className="button primary">
Choose EPUB
<input
type="file"
accept=".epub,application/epub+zip"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onFile(file);
event.currentTarget.value = "";
}}
/>
</label>
</div>
</section>
);
}
function Summary({ book }: { book: EpubBook }) {
const errors = book.issues.filter((item) => item.severity === "error").length;
const warnings = book.issues.filter(
(item) => item.severity === "warning",
).length;
return (
<section className="summary-grid">
<article>
<span>Title</span>
<strong>{book.package.metadata.title || "Untitled"}</strong>
<small>
{book.package.metadata.creators.join(", ") || "Unknown creator"}
</small>
</article>
<article>
<span>Format</span>
<strong>EPUB {book.package.version || "unknown"}</strong>
<small>
{book.package.spine.length} spine items ·{" "}
{book.package.manifest.length} manifest items
</small>
</article>
<article>
<span>Container</span>
<strong>{formatBytes(book.summary.fileSize)}</strong>
<small>
{book.summary.entryCount} entries ·{" "}
{formatBytes(book.summary.uncompressedBytes)} expanded
</small>
</article>
<article className={errors ? "bad" : warnings ? "warn" : "good"}>
<span>Validation</span>
<strong>
{errors} errors · {warnings} warnings
</strong>
<small>
{book.summary.encryptedResources
? "Encryption metadata detected"
: "No encryption metadata detected"}
</small>
</article>
</section>
);
}
function ReaderView({
book,
chapter,
busy,
error,
onOpen,
}: {
book: EpubBook;
chapter?: RenderedChapter;
busy: boolean;
error: string;
onOpen: (item: ManifestItem) => void;
}) {
const navigation = useMemo(() => flatten(book.package.navigation), [book]);
const spine = book.package.spine.filter((item) => item.item && item.linear);
return (
<section className="reader-layout">
<aside className="panel chapter-list">
<div>
<p className="eyebrow">Reading order</p>
<h2>Contents</h2>
</div>
{spine.map((spineItem, index) => {
const item = spineItem.item!;
const label =
navigation.find((entry) => entry.path === item.path)?.label ||
item.id ||
`Chapter ${index + 1}`;
return (
<button
type="button"
className={chapter?.path === item.path ? "active" : ""}
key={`${item.id}-${index}`}
onClick={() => onOpen(item)}
>
<span>{index + 1}</span>
{label}
</button>
);
})}
</aside>
<section className="panel page-reader">
<div className="panel-heading">
<div>
<p className="eyebrow">Sandboxed, script-free preview</p>
<h2>
{chapter?.title ??
(busy ? "Preparing chapter…" : "Choose a chapter")}
</h2>
</div>
{chapter && (
<button
type="button"
onClick={() =>
triggerBlobDownload(
new Blob([chapter.html], { type: "application/xhtml+xml" }),
sanitizeDownloadFilename(
`${chapter.title}.xhtml`,
"chapter.xhtml",
),
)
}
>
Export sanitized chapter
</button>
)}
</div>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
{chapter ? (
<iframe
sandbox=""
title={`Safe EPUB preview: ${chapter.title}`}
srcDoc={chapter.html}
/>
) : (
<p className="empty">
{busy
? "Sanitizing the chapter and resolving local images…"
: "Choose an item from the reading order."}
</p>
)}
<p className="notice">
Scripts, forms, frames, embedded objects, external resources,
navigation, stylesheet links, and CSS URLs are blocked or removed.
Inline publisher styling may remain after URL stripping; the reader
adds a responsive fallback stylesheet.
</p>
</section>
</section>
);
}
function MetadataView({
draft,
setDraft,
}: {
draft: MetadataPatch;
setDraft: (value: MetadataPatch) => void;
}) {
const field = (
key: keyof Omit<MetadataPatch, "creators" | "subjects">,
label: string,
multiline = false,
) => (
<label className="field">
<span>{label}</span>
{multiline ? (
<textarea
value={String(draft[key])}
onChange={(event) =>
setDraft({ ...draft, [key]: event.target.value })
}
/>
) : (
<input
value={String(draft[key])}
onChange={(event) =>
setDraft({ ...draft, [key]: event.target.value })
}
/>
)}
</label>
);
return (
<section className="panel workspace">
<p className="eyebrow">Editable Dublin Core fields</p>
<h2>Publication metadata</h2>
<div className="form-grid">
{field("title", "Title")}
{field("language", "Language")}
{field("identifier", "Identifier")}
{field("publisher", "Publisher")}
{field("date", "Date")}
{field("rights", "Rights")}
</div>
<label className="field">
<span>Creators (one per line)</span>
<textarea
value={draft.creators.join("\n")}
onChange={(event) =>
setDraft({ ...draft, creators: event.target.value.split(/\r?\n/u) })
}
/>
</label>
<label className="field">
<span>Subjects (one per line)</span>
<textarea
value={draft.subjects.join("\n")}
onChange={(event) =>
setDraft({ ...draft, subjects: event.target.value.split(/\r?\n/u) })
}
/>
</label>
{field("description", "Description", true)}
<p className="notice">
Edits remain staged until you create a rebuilt EPUB. The original File
object is never modified.
</p>
</section>
);
}
function ValidationView({ book }: { book: EpubBook }) {
const grouped = (["error", "warning", "info"] as const).map(
(severity) =>
[
severity,
book.issues.filter((item) => item.severity === severity),
] as const,
);
return (
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Bounded structural and link checks</p>
<h2>Validation report</h2>
</div>
<button
type="button"
onClick={() =>
downloadJson(
{
schemaVersion: 1,
generatedBy: "add-ideas EPUB Tools 0.1.0",
file: book.summary,
package: book.package,
issues: book.issues,
},
"epub-validation-report.json",
)
}
>
Download JSON report
</button>
</div>
{grouped.map(([severity, issues]) => (
<section key={severity} className="issue-group">
<h3>
{severity} ({issues.length})
</h3>
{issues.length ? (
issues.map((item, index) => (
<article
className={`issue ${severity}`}
key={`${item.code}-${index}`}
>
<strong>{item.code}</strong>
<span>{item.message}</span>
{item.path && <code>{item.path}</code>}
</article>
))
) : (
<p className="muted">None.</p>
)}
</section>
))}
<p className="notice">
This is a focused in-browser preflight, not a claim of complete
EPUBCheck conformance. It does not validate CSS grammar, media overlays,
accessibility semantics, signatures, font licences, or every schema
constraint.
</p>
</section>
);
}
function PackageView({ book }: { book: EpubBook }) {
const mediaByPath = new Map(
book.package.manifest.map((item) => [item.path, item.mediaType]),
);
return (
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">ZIP package inventory</p>
<h2>{book.summary.entryCount} entries</h2>
</div>
<button
type="button"
onClick={() =>
downloadJson(
{
file: book.summary.filename,
packagePath: book.package.packagePath,
entries: book.entries.map((entry) => ({
path: entry.filename,
directory: entry.directory,
compressedSize: entry.compressedSize,
uncompressedSize: entry.uncompressedSize,
encrypted: entry.encrypted,
mediaType: mediaByPath.get(entry.filename),
})),
},
"epub-package-tree.json",
)
}
>
Export inventory
</button>
</div>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Path</th>
<th>Type</th>
<th>Compressed</th>
<th>Expanded</th>
</tr>
</thead>
<tbody>
{book.entries.map((entry) => (
<tr key={`${entry.filename}-${entry.offset}`}>
<td>
<code>{entry.filename}</code>
</td>
<td>
{entry.directory
? "directory"
: (mediaByPath.get(entry.filename) ?? "unmanifested")}
</td>
<td>{formatBytes(entry.compressedSize)}</td>
<td>{formatBytes(entry.uncompressedSize)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function ExportView({ book, draft }: { book: EpubBook; draft: MetadataPatch }) {
const [cover, setCover] = useState<File>();
const [normalize, setNormalize] = useState(true);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const rebuild = async () => {
setBusy(true);
setError("");
try {
const result = await rebuildEpub(
book,
{ metadata: draft, cover, normalizeTimestamps: normalize },
setStatus,
);
triggerBlobDownload(
result.blob,
sanitizeDownloadFilename(
book.file.name.replace(/\.epub$/iu, "-updated.epub"),
"updated.epub",
),
);
setStatus(`Ready: ${result.changes.join("; ")}`);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Rebuild failed.");
} finally {
setBusy(false);
}
};
const exportCover = async () => {
if (!book.package.cover) return;
try {
const bytes = await readEntryBytes(
book,
book.package.cover.path,
25 * 1024 * 1024,
);
triggerBlobDownload(
new Blob([bytes as BlobPart], { type: book.package.cover.mediaType }),
sanitizeDownloadFilename(book.package.cover.href, "cover"),
);
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Cover export failed.",
);
}
};
const plainText = async () => {
setBusy(true);
try {
triggerBlobDownload(
await exportPlainText(book),
sanitizeDownloadFilename(
`${draft.title || "publication"}.txt`,
"publication.txt",
),
);
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Text export failed.",
);
} finally {
setBusy(false);
}
};
return (
<section className="panel workspace">
<p className="eyebrow">Fresh deterministic container</p>
<h2>Export and rebuild</h2>
<div className="export-grid">
<article>
<h3>Portable reports</h3>
<p>
Export parsed metadata, structure, and validation without changing
the book.
</p>
<div className="actions">
<button
type="button"
onClick={() =>
downloadJson(
{
schemaVersion: 1,
metadata: draft,
packageVersion: book.package.version,
packagePath: book.package.packagePath,
},
"epub-metadata.json",
)
}
>
Metadata JSON
</button>
<button
type="button"
disabled={busy}
onClick={() => void plainText()}
>
Reading-order text
</button>
{book.package.cover && (
<button type="button" onClick={() => void exportCover()}>
Original cover
</button>
)}
</div>
</article>
<article>
<h3>Updated EPUB</h3>
<p>
Write edited metadata, optionally replace the declared cover,
normalize timestamps, and repair mimetype ordering/compression.
</p>
<label className="field">
<span>
Replacement cover{" "}
{book.package.cover
? `(declared at ${book.package.cover.path})`
: "(no cover item declared)"}
</span>
<input
type="file"
disabled={!book.package.cover}
accept="image/jpeg,image/png,image/webp,image/svg+xml"
onChange={(event) => setCover(event.target.files?.[0])}
/>
</label>
<label className="check">
<input
type="checkbox"
checked={normalize}
onChange={(event) => setNormalize(event.target.checked)}
/>{" "}
Normalize entry timestamps to 1980-01-01 and sort paths
</label>
<button
className="primary"
type="button"
disabled={busy || book.summary.encryptedResources}
onClick={() => void rebuild()}
>
{busy ? "Rebuilding…" : "Create updated EPUB"}
</button>
</article>
</div>
{status && (
<p className="success" role="status">
{status}
</p>
)}
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<p className="warning">
A rebuild re-compresses entries and invalidates existing package
signatures. It is disabled when encryption metadata exists. Replacement
cover MIME metadata is updated, but the existing internal filename is
retained and its extension can therefore differ.
</p>
</section>
);
}
export function Workbench() {
const [book, setBook] = useState<EpubBook>();
const [view, setView] = useState<View>("reader");
const [draft, setDraft] = useState<MetadataPatch>();
const [chapter, setChapter] = useState<RenderedChapter>();
const [busy, setBusy] = useState(false);
const [chapterBusy, setChapterBusy] = useState(false);
const [error, setError] = useState("");
const [chapterError, setChapterError] = useState("");
useEffect(
() => () => {
void closeBook(book);
},
[book],
);
useEffect(() => () => revokeRenderedChapter(chapter), [chapter]);
const openChapter = async (item: ManifestItem, currentBook = book) => {
if (!currentBook) return;
setChapterBusy(true);
setChapterError("");
try {
setChapter(await renderChapter(currentBook, item.path));
} catch (reason) {
setChapterError(
reason instanceof Error ? reason.message : "Chapter rendering failed.",
);
} finally {
setChapterBusy(false);
}
};
const openFile = async (file: File) => {
setBusy(true);
setError("");
setChapter(undefined);
try {
const opened = await openEpub(file);
setBook(opened);
setDraft(initialMetadata(opened));
setView("reader");
const first = opened.package.spine.find(
(item) => item.linear && item.item,
)?.item;
if (first) await openChapter(first, opened);
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Unable to open EPUB.",
);
} finally {
setBusy(false);
}
};
const content =
book && draft ? (
view === "reader" ? (
<ReaderView
book={book}
chapter={chapter}
busy={chapterBusy}
error={chapterError}
onOpen={(item) => void openChapter(item)}
/>
) : view === "metadata" ? (
<MetadataView draft={draft} setDraft={setDraft} />
) : view === "validation" ? (
<ValidationView book={book} />
) : view === "package" ? (
<PackageView book={book} />
) : (
<ExportView book={book} draft={draft} />
)
) : null;
return (
<main className="workbench">
<header className="hero">
<div>
<p className="eyebrow">Local publication workbench</p>
<h1>EPUB Tools</h1>
<p>
Read safely, inspect the package, validate links and structure, edit
metadata and covers, and create a normalized EPUB without uploading
the book.
</p>
</div>
<span className="privacy-pill">Sandboxed reader · local files</span>
</header>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
{busy && (
<p className="panel loading" role="status">
Opening and validating the bounded publication
</p>
)}
{!book && !busy ? (
<EmptyState onFile={(file) => void openFile(file)} />
) : (
book && (
<>
<div className="file-bar panel">
<div>
<strong>{book.file.name}</strong>
<span>
{book.package.metadata.title || "Untitled publication"}
</span>
</div>
<label className="button">
Open another
<input
type="file"
accept=".epub,application/epub+zip"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void openFile(file);
event.currentTarget.value = "";
}}
/>
</label>
</div>
<Summary book={book} />
<nav
className="panel workspace-tabs"
role="tablist"
aria-label="EPUB workspaces"
>
{views.map(([value, label]) => (
<button
type="button"
role="tab"
aria-selected={view === value}
key={value}
onClick={() => setView(value)}
>
{label}
</button>
))}
</nav>
{content}
</>
)
)}
</main>
);
}
+172
View File
@@ -0,0 +1,172 @@
import {
BlobReader,
Uint8ArrayWriter,
ZipReader,
type Entry,
} from "@zip.js/zip.js";
import { EPUB_LIMITS, readEntryText } from "./entries";
import {
parseNavigationDocument,
parseNcxDocument,
parsePackageDocument,
} from "./package";
import { validateEntryPath } from "./paths";
import type { EpubBook, EpubIssue } from "./types";
import { elementsByLocalName, parseXml } from "./xml";
import { validatePublication } from "./validation";
export { EPUB_LIMITS, readEntryBytes, readEntryText } from "./entries";
export async function closeBook(book: EpubBook | undefined): Promise<void> {
if (book) await book.reader.close();
}
export async function openEpub(file: File): Promise<EpubBook> {
if (file.size === 0 || file.size > EPUB_LIMITS.fileBytes)
throw new RangeError("EPUB must be between 1 byte and 512 MiB.");
const reader = new ZipReader(new BlobReader(file), {
useWebWorkers: true,
strictness: "strict",
});
try {
const entries = await reader.getEntries();
if (!entries.length || entries.length > EPUB_LIMITS.entries)
throw new RangeError(
`EPUB entry count must be between 1 and ${EPUB_LIMITS.entries.toLocaleString()}.`,
);
let totalUncompressed = 0;
let compressedBytes = 0;
const entryByPath = new Map<string, Entry>();
for (const entry of entries) {
validateEntryPath(entry.filename.replace(/\/$/u, "") || entry.filename);
if (entryByPath.has(entry.filename))
throw new Error(`Duplicate archive path: ${entry.filename}`);
entryByPath.set(entry.filename, entry);
if (!entry.directory) {
totalUncompressed += entry.uncompressedSize;
compressedBytes += entry.compressedSize;
if (entry.uncompressedSize > EPUB_LIMITS.resourceBytes)
throw new RangeError(
`${entry.filename} exceeds the 64 MiB per-resource limit.`,
);
if (
entry.compressedSize === 0
? entry.uncompressedSize > 0
: entry.uncompressedSize / entry.compressedSize >
EPUB_LIMITS.expansionRatio
)
throw new RangeError(
`${entry.filename} exceeds the ${EPUB_LIMITS.expansionRatio}:1 expansion-ratio limit.`,
);
}
}
if (totalUncompressed > EPUB_LIMITS.totalUncompressedBytes)
throw new RangeError("EPUB exceeds the 1 GiB total uncompressed limit.");
const mimetypeEntry = entryByPath.get("mimetype");
if (!mimetypeEntry || mimetypeEntry.directory)
throw new Error("The EPUB has no root mimetype file.");
const mimetype = new TextDecoder("utf-8", { fatal: true }).decode(
await mimetypeEntry.getData(new Uint8ArrayWriter(), {
checkSignature: true,
}),
);
if (mimetype !== "application/epub+zip")
throw new Error(
"The root mimetype entry is not exactly application/epub+zip.",
);
const physicalFiles = entries
.filter((entry) => !entry.directory)
.toSorted((left, right) => left.offset - right.offset);
const mimetypeFirst = physicalFiles[0]?.filename === "mimetype";
const mimetypeStored = mimetypeEntry.compressionMethod === 0;
const provisional = { entryByPath };
const containerXml = await readEntryText(
provisional,
"META-INF/container.xml",
EPUB_LIMITS.xmlBytes,
);
const container = parseXml(containerXml, "META-INF/container.xml");
const rootfiles = elementsByLocalName(container, "rootfile")
.map((element) => element.getAttribute("full-path") ?? "")
.filter(Boolean);
if (!rootfiles.length)
throw new Error("container.xml does not declare a package rootfile.");
const packagePath = validateEntryPath(rootfiles[0] ?? "");
const packageXml = await readEntryText(
provisional,
packagePath,
EPUB_LIMITS.xmlBytes,
);
const parsed = parsePackageDocument(packageXml, packagePath);
const issues: EpubIssue[] = [];
if (rootfiles.length > 1)
issues.push({
severity: "info",
code: "multiple-renditions",
message: `${rootfiles.length} package rootfiles are present; version 0.1 opens the first rendition.`,
path: "META-INF/container.xml",
});
const book: EpubBook = {
file,
reader,
entries,
entryByPath,
package: parsed.package,
packageXml,
issues,
summary: {
filename: file.name,
fileSize: file.size,
entryCount: entries.length,
compressedBytes,
uncompressedBytes: totalUncompressed,
mimetypeFirst,
mimetypeStored,
encryptedResources:
entries.some((entry) => entry.encrypted) ||
entryByPath.has("META-INF/encryption.xml"),
},
};
if (book.package.nav?.path && entryByPath.has(book.package.nav.path)) {
try {
book.package.navigation = parseNavigationDocument(
await readEntryText(book, book.package.nav.path),
book.package.nav.path,
);
} catch (reason) {
issues.push({
severity: "error",
code: "invalid-navigation",
message:
reason instanceof Error
? reason.message
: "Navigation parsing failed.",
path: book.package.nav.path,
});
}
} else if (
book.package.ncx?.path &&
entryByPath.has(book.package.ncx.path)
) {
try {
book.package.navigation = parseNcxDocument(
await readEntryText(book, book.package.ncx.path),
book.package.ncx.path,
);
} catch (reason) {
issues.push({
severity: "error",
code: "invalid-ncx",
message:
reason instanceof Error ? reason.message : "NCX parsing failed.",
path: book.package.ncx.path,
});
}
}
issues.push(...(await validatePublication(book)));
return book;
} catch (reason) {
await reader.close().catch(() => undefined);
throw reason;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Uint8ArrayWriter } from "@zip.js/zip.js";
import { decodeXml } from "./xml";
import type { EpubBook } from "./types";
export const EPUB_LIMITS = {
fileBytes: 512 * 1024 * 1024,
entries: 20_000,
totalUncompressedBytes: 1024 * 1024 * 1024,
expansionRatio: 500,
xmlBytes: 16 * 1024 * 1024,
chapterBytes: 16 * 1024 * 1024,
resourceBytes: 64 * 1024 * 1024,
};
export async function readEntryBytes(
book: Pick<EpubBook, "entryByPath">,
path: string,
maximum = EPUB_LIMITS.resourceBytes,
): Promise<Uint8Array> {
const entry = book.entryByPath.get(path);
if (!entry || entry.directory)
throw new Error(`EPUB entry is missing: ${path}`);
if (entry.encrypted)
throw new Error(`ZIP-encrypted entry is unsupported: ${path}`);
if (entry.uncompressedSize > maximum)
throw new RangeError(
`${path} exceeds the ${Math.round(maximum / 1024 / 1024)} MiB extraction limit.`,
);
return entry.getData(new Uint8ArrayWriter(), {
checkSignature: true,
useWebWorkers: true,
});
}
export async function readEntryText(
book: Pick<EpubBook, "entryByPath">,
path: string,
maximum = EPUB_LIMITS.chapterBytes,
): Promise<string> {
return decodeXml(await readEntryBytes(book, path, maximum));
}
+181
View File
@@ -0,0 +1,181 @@
import { BlobReader, BlobWriter, TextReader, ZipWriter } from "@zip.js/zip.js";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { readEntryText } from "./entries";
import type { EpubBook, EpubMetadata } from "./types";
import { elementsByLocalName, parseXml } from "./xml";
export interface MetadataPatch extends Omit<
EpubMetadata,
"creators" | "subjects"
> {
creators: string[];
subjects: string[];
}
export interface RebuildOptions {
metadata: MetadataPatch;
cover?: File;
normalizeTimestamps?: boolean;
}
const DC_NAMESPACE = "http://purl.org/dc/elements/1.1/";
function setMetadataValues(
document: XMLDocument,
metadataElement: Element,
localName: string,
values: string[],
): void {
for (const element of elementsByLocalName(metadataElement, localName))
element.remove();
for (const value of values.map((item) => item.trim()).filter(Boolean)) {
const element = document.createElementNS(DC_NAMESPACE, `dc:${localName}`);
element.textContent = value;
metadataElement.append(element);
}
}
export function patchPackageMetadata(
packageXml: string,
metadata: MetadataPatch,
coverMediaType?: string,
coverId?: string,
): string {
const document = parseXml(packageXml, "package document");
const metadataElement = elementsByLocalName(
document.documentElement,
"metadata",
)[0];
if (!metadataElement) throw new Error("Package metadata element is missing.");
setMetadataValues(document, metadataElement, "title", [metadata.title]);
setMetadataValues(document, metadataElement, "creator", metadata.creators);
setMetadataValues(document, metadataElement, "language", [metadata.language]);
setMetadataValues(document, metadataElement, "identifier", [
metadata.identifier,
]);
setMetadataValues(document, metadataElement, "publisher", [
metadata.publisher,
]);
setMetadataValues(document, metadataElement, "date", [metadata.date]);
setMetadataValues(document, metadataElement, "description", [
metadata.description,
]);
setMetadataValues(document, metadataElement, "rights", [metadata.rights]);
setMetadataValues(document, metadataElement, "subject", metadata.subjects);
if (coverMediaType && coverId) {
const coverItem = elementsByLocalName(
document.documentElement,
"item",
).find((item) => item.getAttribute("id") === coverId);
coverItem?.setAttribute("media-type", coverMediaType);
}
const serialized = new XMLSerializer().serializeToString(document);
return serialized.startsWith("<?xml")
? serialized
: `<?xml version="1.0" encoding="UTF-8"?>\n${serialized}`;
}
function coverType(file: File): string {
if (
["image/jpeg", "image/png", "image/webp", "image/svg+xml"].includes(
file.type,
)
)
return file.type;
throw new Error(
"Replacement cover must be JPEG, PNG, WebP, or SVG with an explicit browser MIME type.",
);
}
export async function rebuildEpub(
book: EpubBook,
options: RebuildOptions,
onProgress?: (message: string) => void,
): 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.",
);
if (options.cover && !book.package.cover)
throw new Error("This publication has no declared cover item to replace.");
const replacementType = options.cover ? coverType(options.cover) : undefined;
const packageXml = patchPackageMetadata(
book.packageXml,
options.metadata,
replacementType,
book.package.cover?.id,
);
const writer = new ZipWriter(new BlobWriter("application/epub+zip"));
const fixedDate = new Date("1980-01-01T00:00:00.000Z");
const changes = [
"Rewrote package metadata",
"Placed an uncompressed mimetype entry first",
];
await writer.add("mimetype", new TextReader("application/epub+zip"), {
level: 0,
lastModDate: options.normalizeTimestamps ? fixedDate : undefined,
});
const entries = book.entries
.filter((entry) => !entry.directory && entry.filename !== "mimetype")
.toSorted((left, right) => left.filename.localeCompare(right.filename));
for (const [index, entry] of entries.entries()) {
if (entry.directory) continue;
onProgress?.(`Writing ${index + 1} / ${entries.length}: ${entry.filename}`);
const lastModDate = options.normalizeTimestamps
? fixedDate
: entry.lastModDate;
if (entry.filename === book.package.packagePath)
await writer.add(entry.filename, new TextReader(packageXml), {
level: 6,
lastModDate,
});
else if (options.cover && entry.filename === book.package.cover?.path) {
await writer.add(entry.filename, new BlobReader(options.cover), {
level: 6,
lastModDate,
});
changes.push(`Replaced cover bytes at ${entry.filename}`);
} else {
const blob = await entry.getData(new BlobWriter(), {
checkSignature: true,
useWebWorkers: true,
});
await writer.add(entry.filename, new BlobReader(blob), {
level: entry.compressionMethod === 0 ? 0 : 6,
lastModDate,
});
}
}
const blob = await writer.close();
return { blob, changes, packageXml };
}
export function downloadJson(value: unknown, filename: string): void {
triggerBlobDownload(
new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }),
filename,
);
}
export async function exportPlainText(book: EpubBook): Promise<Blob> {
const sections: string[] = [];
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, iframe, object, embed")
.forEach((element) => element.remove());
const value = (document.body.textContent ?? "")
.replace(/[ \t]+/gu, " ")
.replace(/\n{3,}/gu, "\n\n")
.trim();
total += value.length;
if (total > 32 * 1024 * 1024)
throw new RangeError("Plain-text export exceeds the 32 MiB text limit.");
sections.push(`# ${spine.item.id}\n\n${value}`);
}
return new Blob([sections.join("\n\n")], {
type: "text/plain;charset=utf-8",
});
}
+181
View File
@@ -0,0 +1,181 @@
import type {
EpubMetadata,
EpubPackage,
ManifestItem,
NavigationItem,
SpineItem,
} from "./types";
import { directoryOf, resolvePackageHref, splitReference } from "./paths";
import {
directChildrenByLocalName,
elementsByLocalName,
parseXml,
} from "./xml";
function text(element: Element | undefined): string {
return element?.textContent?.trim() ?? "";
}
function firstDirect(parent: Element, name: string): Element | undefined {
return directChildrenByLocalName(parent, name)[0];
}
export function parsePackageDocument(
packageXml: string,
packagePath: string,
): { package: EpubPackage; document: XMLDocument } {
const document = parseXml(packageXml, packagePath);
const root = document.documentElement;
if (root.localName !== "package")
throw new Error("The rootfile is not an EPUB package document.");
const metadataElement = firstDirect(root, "metadata");
const manifestElement = firstDirect(root, "manifest");
const spineElement = firstDirect(root, "spine");
if (!metadataElement || !manifestElement || !spineElement)
throw new Error(
"The package document must contain metadata, manifest, and spine elements.",
);
const metadata: EpubMetadata = {
title: text(elementsByLocalName(metadataElement, "title")[0]),
creators: elementsByLocalName(metadataElement, "creator")
.map((item) => text(item))
.filter(Boolean),
language: text(elementsByLocalName(metadataElement, "language")[0]),
identifier: text(elementsByLocalName(metadataElement, "identifier")[0]),
publisher: text(elementsByLocalName(metadataElement, "publisher")[0]),
date: text(elementsByLocalName(metadataElement, "date")[0]),
description: text(elementsByLocalName(metadataElement, "description")[0]),
rights: text(elementsByLocalName(metadataElement, "rights")[0]),
subjects: elementsByLocalName(metadataElement, "subject")
.map((item) => text(item))
.filter(Boolean),
};
const packageDirectory = directoryOf(packagePath);
const manifest: ManifestItem[] = directChildrenByLocalName(
manifestElement,
"item",
).map((element) => {
const href = element.getAttribute("href") ?? "";
return {
id: element.getAttribute("id") ?? "",
href,
path: resolvePackageHref(packagePath, href) ?? "",
mediaType: element.getAttribute("media-type") ?? "",
properties: (element.getAttribute("properties") ?? "")
.split(/\s+/u)
.filter(Boolean),
fallback: element.getAttribute("fallback") ?? undefined,
mediaOverlay: element.getAttribute("media-overlay") ?? undefined,
};
});
const byId = new Map(manifest.map((item) => [item.id, item]));
const spine: SpineItem[] = directChildrenByLocalName(
spineElement,
"itemref",
).map((element) => {
const idref = element.getAttribute("idref") ?? "";
return {
idref,
linear: element.getAttribute("linear") !== "no",
properties: (element.getAttribute("properties") ?? "")
.split(/\s+/u)
.filter(Boolean),
item: byId.get(idref),
};
});
const coverId = elementsByLocalName(metadataElement, "meta")
.find((element) => element.getAttribute("name") === "cover")
?.getAttribute("content");
const cover =
manifest.find((item) => item.properties.includes("cover-image")) ??
(coverId ? byId.get(coverId) : undefined);
const nav = manifest.find((item) => item.properties.includes("nav"));
const ncxId = spineElement.getAttribute("toc") ?? undefined;
const ncx =
(ncxId ? byId.get(ncxId) : undefined) ??
manifest.find((item) => item.mediaType === "application/x-dtbncx+xml");
return {
package: {
version: root.getAttribute("version") ?? "",
uniqueIdentifier: root.getAttribute("unique-identifier") ?? undefined,
packagePath,
packageDirectory,
metadata,
manifest,
spine,
navigation: [],
cover,
nav,
ncx,
},
document,
};
}
function navigationChildren(list: Element, basePath: string): NavigationItem[] {
return directChildrenByLocalName(list, "li").map((item) => {
const anchor = directChildrenByLocalName(item, "a")[0];
const labelNode = anchor ?? directChildrenByLocalName(item, "span")[0];
const href = anchor?.getAttribute("href") ?? "";
const nested = directChildrenByLocalName(item, "ol")[0];
let path: string | undefined;
try {
path = href ? resolvePackageHref(basePath, href) : undefined;
} catch {
path = undefined;
}
return {
label: text(labelNode) || "Untitled",
href,
path,
fragment: splitReference(href).fragment || undefined,
children: nested ? navigationChildren(nested, basePath) : [],
};
});
}
export function parseNavigationDocument(
xml: string,
path: string,
): NavigationItem[] {
const document = parseXml(xml, path);
const navs = elementsByLocalName(document, "nav");
const toc =
navs.find((element) =>
(
element.getAttribute("epub:type") ??
element.getAttributeNS("http://www.idpf.org/2007/ops", "type") ??
""
)
.split(/\s+/u)
.includes("toc"),
) ?? navs[0];
if (!toc) return [];
const list = elementsByLocalName(toc, "ol")[0];
return list ? navigationChildren(list, path) : [];
}
export function parseNcxDocument(xml: string, path: string): NavigationItem[] {
const document = parseXml(xml, path);
const navMap = elementsByLocalName(document, "navMap")[0];
if (!navMap) return [];
const parsePoints = (parent: Element): NavigationItem[] =>
directChildrenByLocalName(parent, "navPoint").map((point) => {
const label = text(elementsByLocalName(point, "text")[0]) || "Untitled";
const href =
elementsByLocalName(point, "content")[0]?.getAttribute("src") ?? "";
let target: string | undefined;
try {
target = href ? resolvePackageHref(path, href) : undefined;
} catch {
target = undefined;
}
return {
label,
href,
path: target,
fragment: splitReference(href).fragment || undefined,
children: parsePoints(point),
};
});
return parsePoints(navMap);
}
+67
View File
@@ -0,0 +1,67 @@
export function isExternalReference(value: string): boolean {
return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/iu.test(value.trim());
}
export function validateEntryPath(input: string): string {
if (
!input ||
input.includes("\0") ||
input.includes("\\") ||
input.startsWith("/") ||
/^[A-Za-z]:/u.test(input)
)
throw new Error(`Unsafe archive path: ${input || "(empty)"}`);
const parts = input.split("/");
if (parts.some((part) => part === ".." || part === "."))
throw new Error(`Archive path contains traversal segments: ${input}`);
return input;
}
export function directoryOf(path: string): string {
const index = path.lastIndexOf("/");
return index < 0 ? "" : path.slice(0, index + 1);
}
export function splitReference(input: string): {
pathname: string;
fragment: string;
} {
const hash = input.indexOf("#");
const query = input.indexOf("?");
const end = Math.min(
hash < 0 ? input.length : hash,
query < 0 ? input.length : query,
);
return {
pathname: input.slice(0, end),
fragment: hash < 0 ? "" : input.slice(hash + 1),
};
}
export function resolvePackageHref(
basePath: string,
href: string,
): string | undefined {
const value = href.trim();
if (!value || isExternalReference(value)) return undefined;
const { pathname } = splitReference(value);
let decoded: string;
try {
decoded = decodeURIComponent(pathname);
} catch {
throw new Error(`Invalid percent-encoding in href: ${href}`);
}
if (!decoded) return basePath;
if (decoded.includes("\\") || decoded.startsWith("/"))
throw new Error(`Unsafe absolute or backslash href: ${href}`);
const stack = directoryOf(basePath).split("/").filter(Boolean);
for (const part of decoded.split("/")) {
if (!part || part === ".") continue;
if (part === "..") {
if (!stack.length)
throw new Error(`Href escapes the EPUB package: ${href}`);
stack.pop();
} else stack.push(part);
}
return stack.join("/");
}
+161
View File
@@ -0,0 +1,161 @@
import DOMPurify from "dompurify";
import { readEntryBytes, readEntryText } from "./entries";
import { isExternalReference, resolvePackageHref } from "./paths";
import type { EpubBook, NavigationItem, RenderedChapter } from "./types";
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; }
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; }
pre { max-width: 100%; overflow: auto; white-space: pre-wrap; }
a { color: inherit; text-decoration: underline dotted; }
`;
function flattenNavigation(items: NavigationItem[]): NavigationItem[] {
return items.flatMap((item) => [item, ...flattenNavigation(item.children)]);
}
function safeInlineStyle(value: string): string {
return value
.replace(/@import[^;]+;?/giu, "")
.replace(/url\s*\([^)]*\)/giu, "none")
.replace(/expression\s*\([^)]*\)/giu, "");
}
export async function renderChapter(
book: EpubBook,
path: string,
): Promise<RenderedChapter> {
const item = book.package.manifest.find(
(candidate) => candidate.path === path,
);
if (
!item ||
!/^(?:application\/xhtml\+xml|text\/html|image\/svg\+xml)$/iu.test(
item.mediaType,
)
)
throw new Error(
"Only XHTML, HTML, and SVG spine documents can be rendered.",
);
const source = await readEntryText(book, path);
const sanitized = DOMPurify.sanitize(source, {
WHOLE_DOCUMENT: true,
USE_PROFILES: { html: true, svg: true, svgFilters: false },
FORBID_TAGS: [
"script",
"iframe",
"object",
"embed",
"form",
"input",
"button",
"textarea",
"select",
"base",
"meta",
"link",
],
FORBID_ATTR: ["srcset", "formaction", "ping"],
});
const document = new DOMParser().parseFromString(
String(sanitized),
"text/html",
);
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") ?? "";
anchor.setAttribute("data-epub-href", original);
anchor.setAttribute("href", "#");
anchor.removeAttribute("target");
anchor.title = original
? `EPUB link disabled in safe reader: ${original}`
: "EPUB link";
}
const objectUrls: string[] = [];
const resourceNodes: Array<{ element: Element; attribute: string }> = [
...[...document.querySelectorAll("img[src]")].map((element) => ({
element,
attribute: "src",
})),
...[...document.querySelectorAll("svg image[href]")].map((element) => ({
element,
attribute: "href",
})),
...[...document.querySelectorAll("svg image[xlink\\:href]")].map(
(element) => ({ element, attribute: "xlink:href" }),
),
];
for (const { element, attribute } of resourceNodes) {
const reference = element.getAttribute(attribute) ?? "";
if (
!reference ||
reference.startsWith("data:") ||
isExternalReference(reference)
) {
if (isExternalReference(reference)) element.removeAttribute(attribute);
continue;
}
try {
const target = resolvePackageHref(path, reference);
const resource = target
? book.package.manifest.find((candidate) => candidate.path === target)
: undefined;
if (
!target ||
!resource ||
!resource.mediaType.startsWith("image/") ||
!book.entryByPath.has(target)
) {
element.removeAttribute(attribute);
continue;
}
const bytes = await readEntryBytes(book, target, 25 * 1024 * 1024);
const url = URL.createObjectURL(
new Blob([bytes as BlobPart], { type: resource.mediaType }),
);
objectUrls.push(url);
element.setAttribute(attribute, url);
} catch {
element.removeAttribute(attribute);
}
}
const csp = document.createElement("meta");
csp.httpEquiv = "Content-Security-Policy";
csp.content =
"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);
const title =
document.title.trim() ||
flattenNavigation(book.package.navigation).find(
(entry) => entry.path === path,
)?.label ||
item.id ||
path;
const text = (document.body.textContent ?? "").replace(/\s+/gu, " ").trim();
return {
path,
html: `<!doctype html>\n${document.documentElement.outerHTML}`,
title,
objectUrls,
text,
};
}
export function revokeRenderedChapter(
chapter: RenderedChapter | undefined,
): void {
for (const url of chapter?.objectUrls ?? []) URL.revokeObjectURL(url);
}
+90
View File
@@ -0,0 +1,90 @@
import type { Entry, ZipReader } from "@zip.js/zip.js";
export type IssueSeverity = "error" | "warning" | "info";
export interface EpubIssue {
severity: IssueSeverity;
code: string;
message: string;
path?: string;
}
export interface EpubMetadata {
title: string;
creators: string[];
language: string;
identifier: string;
publisher: string;
date: string;
description: string;
rights: string;
subjects: string[];
}
export interface ManifestItem {
id: string;
href: string;
path: string;
mediaType: string;
properties: string[];
fallback?: string;
mediaOverlay?: string;
}
export interface SpineItem {
idref: string;
linear: boolean;
properties: string[];
item?: ManifestItem;
}
export interface NavigationItem {
label: string;
href: string;
path?: string;
fragment?: string;
children: NavigationItem[];
}
export interface EpubPackage {
version: string;
uniqueIdentifier?: string;
packagePath: string;
packageDirectory: string;
metadata: EpubMetadata;
manifest: ManifestItem[];
spine: SpineItem[];
navigation: NavigationItem[];
cover?: ManifestItem;
nav?: ManifestItem;
ncx?: ManifestItem;
}
export interface EpubEntrySummary {
path: string;
compressedSize: number;
uncompressedSize: number;
mediaType?: string;
directory: boolean;
}
export interface EpubBook {
file: File;
reader: ZipReader<Blob>;
entries: Entry[];
entryByPath: Map<string, Entry>;
package: EpubPackage;
packageXml: string;
issues: EpubIssue[];
summary: {
filename: string;
fileSize: number;
entryCount: number;
compressedBytes: number;
uncompressedBytes: number;
mimetypeFirst: boolean;
mimetypeStored: boolean;
encryptedResources: boolean;
};
}
export interface RenderedChapter {
path: string;
html: string;
title: string;
objectUrls: string[];
text: string;
}
+340
View File
@@ -0,0 +1,340 @@
import { readEntryText } from "./entries";
import { isExternalReference, resolvePackageHref } from "./paths";
import type { EpubBook, EpubIssue } from "./types";
import { parseXml } from "./xml";
function issue(
severity: EpubIssue["severity"],
code: string,
message: string,
path?: string,
): EpubIssue {
return { severity, code, message, path };
}
export async function validatePublication(
book: EpubBook,
): Promise<EpubIssue[]> {
const result: EpubIssue[] = [];
if (!book.summary.mimetypeFirst)
result.push(
issue(
"warning",
"mimetype-not-first",
"The mimetype entry is not physically first; rebuilding can normalize this.",
"mimetype",
),
);
if (!book.summary.mimetypeStored)
result.push(
issue(
"warning",
"mimetype-compressed",
"The mimetype entry is compressed; EPUB requires it to be stored without compression.",
"mimetype",
),
);
if (!book.entryByPath.has("META-INF/container.xml"))
result.push(
issue("error", "missing-container", "META-INF/container.xml is missing."),
);
if (!book.package.version)
result.push(
issue(
"warning",
"missing-version",
"The package element has no EPUB version.",
book.package.packagePath,
),
);
if (!book.package.metadata.title)
result.push(
issue(
"error",
"missing-title",
"dc:title is missing or empty.",
book.package.packagePath,
),
);
if (!book.package.metadata.identifier)
result.push(
issue(
"error",
"missing-identifier",
"dc:identifier is missing or empty.",
book.package.packagePath,
),
);
if (!book.package.metadata.language)
result.push(
issue(
"error",
"missing-language",
"dc:language is missing or empty.",
book.package.packagePath,
),
);
if (!book.package.spine.length)
result.push(
issue(
"error",
"empty-spine",
"The publication spine is empty.",
book.package.packagePath,
),
);
if (!book.package.nav && !book.package.ncx)
result.push(
issue(
"warning",
"missing-navigation",
"No EPUB navigation document or NCX was declared.",
book.package.packagePath,
),
);
if (!book.package.cover)
result.push(
issue(
"info",
"missing-cover",
"No cover-image property or EPUB 2 cover metadata was found.",
book.package.packagePath,
),
);
if (book.entryByPath.has("META-INF/encryption.xml"))
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.",
"META-INF/encryption.xml",
),
);
if (book.entryByPath.has("META-INF/signatures.xml"))
result.push(
issue(
"info",
"signatures-unverified",
"Publication signatures are present but are not verified in v0.1.",
"META-INF/signatures.xml",
),
);
const ids = new Set<string>();
const manifestPaths = new Set<string>();
for (const item of book.package.manifest) {
if (!item.id)
result.push(
issue(
"error",
"manifest-id-missing",
"A manifest item has no id.",
book.package.packagePath,
),
);
else if (ids.has(item.id))
result.push(
issue(
"error",
"duplicate-manifest-id",
`Duplicate manifest id: ${item.id}`,
book.package.packagePath,
),
);
ids.add(item.id);
if (!item.href || !item.path) {
result.push(
issue(
"error",
"invalid-manifest-href",
`Manifest item ${item.id || "(without id)"} has an empty or external href.`,
book.package.packagePath,
),
);
continue;
}
if (manifestPaths.has(item.path))
result.push(
issue(
"warning",
"duplicate-manifest-target",
`More than one manifest item targets ${item.path}.`,
book.package.packagePath,
),
);
manifestPaths.add(item.path);
if (!book.entryByPath.has(item.path))
result.push(
issue(
"error",
"missing-manifest-resource",
`Manifest resource is missing: ${item.path}`,
item.path,
),
);
}
for (const spine of book.package.spine) {
if (!spine.item)
result.push(
issue(
"error",
"missing-spine-item",
`Spine idref does not exist in the manifest: ${spine.idref}`,
book.package.packagePath,
),
);
else if (
!/^(?:application\/xhtml\+xml|text\/html)$/iu.test(spine.item.mediaType)
)
result.push(
issue(
"warning",
"non-html-spine",
`Spine item ${spine.idref} has media type ${spine.item.mediaType || "(empty)"}.`,
spine.item.path,
),
);
}
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(
item.mediaType,
) ||
!book.entryByPath.has(item.path)
)
continue;
const entry = book.entryByPath.get(item.path);
if (
!entry ||
entry.directory ||
entry.uncompressedSize > 4 * 1024 * 1024 ||
inspectedBytes + entry.uncompressedSize > 32 * 1024 * 1024 ||
inspectedDocuments >= 500
)
continue;
inspectedBytes += entry.uncompressedSize;
inspectedDocuments += 1;
try {
const document = parseXml(
await readEntryText(book, item.path, 4 * 1024 * 1024),
item.path,
);
if (document.querySelector("script, iframe, object, embed, form"))
result.push(
issue(
"warning",
"active-content",
"Active or interactive content is present and will be removed by the safe reader.",
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() ?? "";
if (
!reference ||
reference.startsWith("#") ||
reference.startsWith("data:")
)
continue;
if (isExternalReference(reference)) {
if (attribute !== "href" || element.localName !== "a")
result.push(
issue(
"warning",
"external-resource",
`External ${attribute} is blocked by the safe reader: ${reference.slice(0, 180)}`,
item.path,
),
);
continue;
}
try {
const target = resolvePackageHref(item.path, reference);
if (target) {
linkedTargets.add(target);
if (!book.entryByPath.has(target))
result.push(
issue(
"error",
"broken-link",
`Referenced resource is missing: ${target}`,
item.path,
),
);
}
} catch (reason) {
result.push(
issue(
"error",
"unsafe-link",
reason instanceof Error
? reason.message
: "Unsafe relative link.",
item.path,
),
);
}
}
} catch (reason) {
result.push(
issue(
"error",
"invalid-content-document",
reason instanceof Error
? reason.message
: "Content document parsing failed.",
item.path,
),
);
}
}
if (
inspectedDocuments <
book.package.manifest.filter((item) =>
/xhtml|html|svg/iu.test(item.mediaType),
).length
)
result.push(
issue(
"info",
"link-scan-bounded",
`Link scan inspected ${inspectedDocuments} documents and ${inspectedBytes.toLocaleString()} bytes; oversized or excess documents were skipped.`,
book.package.packagePath,
),
);
const allowedUnmanifested = new Set([
"mimetype",
"META-INF/container.xml",
"META-INF/encryption.xml",
"META-INF/signatures.xml",
book.package.packagePath,
]);
const hidden = book.entries.filter(
(entry) =>
!entry.directory &&
!manifestPaths.has(entry.filename) &&
!allowedUnmanifested.has(entry.filename),
);
if (hidden.length)
result.push(
issue(
"info",
"unmanifested-resources",
`${hidden.length} unmanifested archive entries are present. Review the package tree before sharing.`,
hidden[0]?.filename,
),
);
return result;
}
+49
View File
@@ -0,0 +1,49 @@
import { decodeText } from "@add-ideas/toolbox-helpers";
export function decodeXml(bytes: Uint8Array): string {
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
return decodeText(bytes.subarray(2), "utf-16le");
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff)
return decodeText(bytes.subarray(2), "utf-16be");
const probe = new TextDecoder("ascii").decode(
bytes.subarray(0, Math.min(bytes.length, 256)),
);
const encoding = /<\?xml[^>]*encoding=["']([^"']+)/iu
.exec(probe)?.[1]
?.toLowerCase();
if (encoding && !["utf-8", "utf8"].includes(encoding))
throw new Error(
`Unsupported XML encoding: ${encoding}. EPUB text must use UTF-8 or UTF-16.`,
);
return decodeText(bytes, "utf-8");
}
export function parseXml(text: string, label: string): XMLDocument {
if (text.length > 16 * 1024 * 1024)
throw new RangeError(`${label} exceeds the 16 MiB XML limit.`);
if (/<!DOCTYPE|<!ENTITY/iu.test(text))
throw new Error(`${label} contains a forbidden DTD or entity declaration.`);
const document = new DOMParser().parseFromString(text, "application/xml");
const parserError = [...document.getElementsByTagName("parsererror")][0];
if (parserError)
throw new Error(
`${label} is not well-formed XML: ${parserError.textContent?.trim().slice(0, 240) ?? "parser error"}`,
);
return document;
}
export function elementsByLocalName(
parent: ParentNode,
name: string,
): Element[] {
return [...parent.querySelectorAll("*")].filter(
(element) => element.localName === name,
);
}
export function directChildrenByLocalName(
parent: Element,
name: string,
): Element[] {
return [...parent.children].filter((element) => element.localName === name);
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, { scope: new URL("./", document.baseURI).pathname })
.catch(() => undefined);
});
}
+513
View File
@@ -0,0 +1,513 @@
:root {
--toolbox-background: #f6f7fb;
--toolbox-surface: #fff;
--toolbox-surface-soft: #eff1f7;
--toolbox-text: #202332;
--toolbox-muted: #656b7d;
--toolbox-border: #d9dce7;
--toolbox-accent: #5b4ec4;
--toolbox-accent-hover: #493caf;
--toolbox-accent-soft: #ece9ff;
--toolbox-accent-contrast: #fff;
--toolbox-focus: #137d75;
--toolbox-danger: #b42342;
}
* {
box-sizing: border-box;
}
html {
min-width: 20rem;
min-height: 100%;
background: var(--toolbox-background);
scrollbar-gutter: stable;
}
body {
min-width: 20rem;
min-height: 100vh;
margin: 0;
background: var(--toolbox-background);
color: var(--toolbox-text);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
.button {
min-height: 2.55rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
font-weight: 720;
cursor: pointer;
text-decoration: none;
}
button:hover:not(:disabled),
.button:hover {
border-color: var(--toolbox-accent);
background: var(--toolbox-surface-soft);
}
button:disabled {
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;
}
input,
select,
textarea {
width: 100%;
min-height: 2.55rem;
padding: 0.58rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
textarea {
min-height: 7rem;
resize: vertical;
line-height: 1.45;
}
input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
min-height: 0;
padding: 0;
opacity: 0;
pointer-events: none;
}
.toolbox-shell__main {
width: min(100%, 90rem);
padding: clamp(0.75rem, 1.8vw, 1.5rem);
}
.workbench {
display: grid;
gap: 1rem;
}
.hero,
.panel,
.summary-grid article {
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
}
.hero {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
padding: clamp(1.1rem, 3vw, 2rem);
}
.hero h1,
.panel h2,
.panel h3,
.help-dialog h2,
.fatal h1 {
margin: 0;
letter-spacing: -0.025em;
}
.hero p:not(.eyebrow) {
max-width: 57rem;
margin: 0.55rem 0 0;
color: var(--toolbox-muted);
line-height: 1.55;
}
.eyebrow {
margin: 0 0 0.3rem;
color: var(--toolbox-accent);
font-size: 0.69rem;
font-weight: 820;
letter-spacing: 0.115em;
text-transform: uppercase;
}
.privacy-pill {
flex: 0 0 auto;
padding: 0.38rem 0.62rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.75rem;
font-weight: 760;
}
.panel {
padding: 1rem;
}
.primary {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.drop {
min-height: 19rem;
display: grid;
grid-template-columns: minmax(7rem, 13rem) minmax(15rem, 32rem);
place-content: center;
align-items: center;
gap: 2rem;
border-style: dashed;
}
.drop h2 {
margin-bottom: 0.6rem;
font-size: clamp(1.6rem, 4vw, 2.4rem);
}
.drop p:not(.eyebrow) {
color: var(--toolbox-muted);
line-height: 1.55;
}
.book-mark {
min-height: 11rem;
display: grid;
place-items: center;
border-radius: 0.8rem 0.4rem 0.4rem 0.8rem;
background: linear-gradient(140deg, var(--toolbox-accent), #8a75e8);
color: #fff;
font-size: 1.4rem;
font-weight: 900;
letter-spacing: 0.15em;
box-shadow: inset 1rem 0 rgb(0 0 0 / 12%);
}
.file-bar {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.file-bar > div {
min-width: 0;
display: grid;
gap: 0.2rem;
}
.file-bar strong {
overflow: hidden;
text-overflow: ellipsis;
}
.file-bar span {
color: var(--toolbox-muted);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
.summary-grid article {
min-width: 0;
display: grid;
gap: 0.35rem;
padding: 0.85rem;
}
.summary-grid span {
color: var(--toolbox-muted);
font-size: 0.72rem;
font-weight: 760;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.summary-grid strong,
.summary-grid small {
overflow: hidden;
text-overflow: ellipsis;
}
.summary-grid small {
color: var(--toolbox-muted);
}
.summary-grid .good {
border-color: #55a875;
}
.summary-grid .warn {
border-color: #d9a72e;
}
.summary-grid .bad {
border-color: var(--toolbox-danger);
}
.workspace-tabs {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding: 0.75rem;
}
.workspace-tabs button[aria-selected="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.workspace {
display: grid;
gap: 0.9rem;
min-width: 0;
}
.panel-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: end;
margin-bottom: 0.9rem;
}
.reader-layout {
display: grid;
grid-template-columns: minmax(15rem, 19rem) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.chapter-list {
max-height: calc(100vh - 12rem);
display: grid;
gap: 0.35rem;
overflow: auto;
position: sticky;
top: 0.75rem;
}
.chapter-list > div {
margin-bottom: 0.4rem;
}
.chapter-list button {
justify-content: start;
text-align: left;
}
.chapter-list button > span {
flex: 0 0 1.8rem;
display: grid;
place-items: center;
min-height: 1.8rem;
border-radius: 999px;
background: var(--toolbox-surface-soft);
color: var(--toolbox-muted);
font-size: 0.7rem;
}
.chapter-list button.active {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
}
.page-reader {
min-height: 38rem;
min-width: 0;
overflow: auto;
}
.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;
}
.field {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.field > span {
font-size: 0.76rem;
font-weight: 750;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: 0.7rem;
}
.issue-group {
display: grid;
gap: 0.5rem;
}
.issue {
display: grid;
grid-template-columns: minmax(9rem, 0.35fr) 1fr;
gap: 0.6rem;
padding: 0.65rem;
border-left: 0.25rem solid var(--toolbox-border);
border-radius: 0.45rem;
background: var(--toolbox-surface-soft);
}
.issue.error {
border-color: var(--toolbox-danger);
}
.issue.warning {
border-color: #d9a72e;
}
.issue.info {
border-color: var(--toolbox-accent);
}
.issue code {
grid-column: 2;
color: var(--toolbox-muted);
overflow-wrap: anywhere;
}
.table-scroll {
max-height: 42rem;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
th,
td {
padding: 0.55rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
vertical-align: top;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: var(--toolbox-surface-soft);
}
td code {
overflow-wrap: anywhere;
}
.export-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.8rem;
}
.export-grid article {
display: grid;
align-content: start;
gap: 0.7rem;
padding: 0.85rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
background: var(--toolbox-surface-soft);
}
.export-grid p {
margin: 0;
color: var(--toolbox-muted);
line-height: 1.5;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
font-weight: 680;
}
.check input {
width: auto;
min-height: auto;
}
.muted {
margin: 0;
color: var(--toolbox-muted);
line-height: 1.5;
}
.empty {
min-height: 10rem;
display: grid;
place-items: center;
margin: 0;
color: var(--toolbox-muted);
text-align: center;
}
.error,
.warning,
.notice,
.success {
margin: 0;
padding: 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
line-height: 1.5;
}
.error {
border-color: var(--toolbox-danger);
color: var(--toolbox-danger);
}
.warning {
border-color: #d9a72e;
background: #fff8df;
color: #725000;
}
.notice {
background: var(--toolbox-surface-soft);
}
.success {
border-color: #55a875;
background: #eaf8ef;
color: #176137;
}
.loading,
.fatal {
width: min(100% - 2rem, 60rem);
margin: 2rem auto;
padding: 1rem;
}
.help-dialog {
width: min(36rem, calc(100% - 2rem));
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.help-dialog::backdrop {
background: rgb(20 24 45 / 55%);
}
.dialog-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
@media (max-width: 68rem) {
.summary-grid {
grid-template-columns: repeat(2, 1fr);
}
.reader-layout {
grid-template-columns: 1fr;
}
.chapter-list {
position: static;
max-height: 18rem;
}
.export-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 44rem) {
.hero,
.file-bar,
.panel-heading {
flex-direction: column;
align-items: stretch;
}
.privacy-pill {
order: -1;
align-self: start;
}
.drop {
grid-template-columns: 1fr;
}
.book-mark {
min-height: 6rem;
}
.summary-grid {
grid-template-columns: 1fr;
}
.issue {
grid-template-columns: 1fr;
}
.issue code {
grid-column: auto;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.epub-tools",
"name": "EPUB Tools",
"version": "0.1.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"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": true,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/epub-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/epub-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />