Release EPUB Tools 0.1.0
This commit is contained in:
+35
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("/");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user