feat(epub): parse EPUB files into a Book (importer)

This commit is contained in:
pj committed 2026-06-20 18:36:18 -04:00
1 parent 8b9fa7f24e
commit 406cd1289c
1 file changed
+420
+420
View File
@@ -0,0 +1,420 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import type { JSONContent } from "@tiptap/core";
import type { Book, BookMetadata, Chapter } from "../model/book";
export interface RawFile {
path: string;
data: string;
encoding: "utf8" | "base64";
}
type Mark = { type: string };
const BLOCK_TAGS = new Set([
"p", "div", "section", "article", "main", "header", "footer", "aside",
"blockquote", "ul", "ol", "li", "figure", "img", "hr", "table", "tr",
"td", "th", "thead", "tbody", "h1", "h2", "h3", "h4", "h5", "h6",
]);
function normalize(path: string): string {
return path.replace(/^\.?\//, "");
}
function dirOf(path: string): string {
const i = path.lastIndexOf("/");
return i === -1 ? "" : path.slice(0, i);
}
function baseName(path: string): string {
const file = path.split(/[\\/]/).pop() ?? path;
return file.replace(/\.[^.]+$/, "");
}
function decodeHref(href: string): string {
const clean = href.split("#")[0];
try {
return decodeURIComponent(clean);
} catch {
return clean;
}
}
function resolvePath(baseDir: string, rel: string): string {
if (rel.startsWith("/")) return normalize(rel);
const parts = (baseDir ? baseDir.split("/") : []).concat(rel.split("/"));
const out: string[] = [];
for (const part of parts) {
if (part === "" || part === ".") continue;
if (part === "..") out.pop();
else out.push(part);
}
return out.join("/");
}
function mimeFromExt(path: string): string {
const ext = path.toLowerCase().split(".").pop() ?? "";
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
if (ext === "png") return "image/png";
if (ext === "gif") return "image/gif";
if (ext === "webp") return "image/webp";
if (ext === "svg") return "image/svg+xml";
return "application/octet-stream";
}
function parseXml(text: string, mime: DOMParserSupportedType): Document {
return new DOMParser().parseFromString(text, mime);
}
function firstByLocal(root: Document | Element, local: string): Element | null {
for (const el of Array.from(root.getElementsByTagName("*"))) {
if (el.localName === local) return el;
}
return null;
}
function localText(root: Document | Element, local: string): string {
return firstByLocal(root, local)?.textContent?.trim() ?? "";
}
function hasMark(marks: Mark[], type: string): boolean {
return marks.some((m) => m.type === type);
}
function addMark(marks: Mark[], type: string): Mark[] {
return hasMark(marks, type) ? marks : [...marks, { type }];
}
function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
node.childNodes.forEach((child) => {
if (child.nodeType === 3) {
const text = (child.nodeValue ?? "").replace(/\s+/g, " ");
if (text === "") return;
out.push(marks.length ? { type: "text", text, marks } : { type: "text", text });
return;
}
if (child.nodeType !== 1) return;
const el = child as Element;
const tag = el.localName.toLowerCase();
if (tag === "br") {
out.push({ type: "hardBreak" });
} else if (tag === "strong" || tag === "b") {
collectInline(el, addMark(marks, "bold"), out);
} else if (tag === "em" || tag === "i") {
collectInline(el, addMark(marks, "italic"), out);
} else if (!BLOCK_TAGS.has(tag)) {
collectInline(el, marks, out);
}
});
}
function inlineOf(el: Element): JSONContent[] {
const out: JSONContent[] = [];
collectInline(el, [], out);
return trimInline(out);
}
function trimInline(nodes: JSONContent[]): JSONContent[] {
const out = nodes.slice();
while (out.length && out[0].type === "text") {
out[0] = { ...out[0], text: (out[0].text ?? "").replace(/^\s+/, "") };
if (out[0].text === "") out.shift();
else break;
}
while (out.length) {
const last = out[out.length - 1];
if (last.type !== "text") break;
out[out.length - 1] = { ...last, text: (last.text ?? "").replace(/\s+$/, "") };
if (out[out.length - 1].text === "") out.pop();
else break;
}
return out.filter((n) => n.type !== "text" || n.text !== "");
}
function meaningful(inline: JSONContent[]): boolean {
return inline.some((n) => (n.type === "text" && n.text!.trim() !== "") || n.type === "hardBreak");
}
function imgFigure(img: Element, caption = ""): JSONContent[] {
const src = img.getAttribute("src");
if (!src || !src.startsWith("data:")) return [];
return [
{
type: "figure",
attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" },
},
];
}
function figureFrom(el: Element): JSONContent[] {
const img = el.querySelector("img");
if (!img) return [];
const caption = el.querySelector("figcaption")?.textContent?.trim() ?? "";
return imgFigure(img, caption);
}
function paragraphAndFigures(el: Element): JSONContent[] {
const out: JSONContent[] = [];
const inline = inlineOf(el);
if (meaningful(inline)) out.push({ type: "paragraph", content: inline });
el.querySelectorAll("img").forEach((img) => out.push(...imgFigure(img)));
return out;
}
function hasBlockChild(el: Element): boolean {
return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase()));
}
function listItems(el: Element): JSONContent[] {
const items: JSONContent[] = [];
Array.from(el.children)
.filter((c) => c.localName.toLowerCase() === "li")
.forEach((li) => {
const content = blocksFrom(li);
items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] });
});
return items;
}
function blockFromElement(el: Element): JSONContent[] {
const tag = el.localName.toLowerCase();
switch (tag) {
case "p":
return paragraphAndFigures(el);
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6": {
const level = tag === "h1" || tag === "h2" ? 2 : 3;
const content = inlineOf(el);
return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : [];
}
case "blockquote": {
const inner = blocksFrom(el);
return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }];
}
case "ul": {
const items = listItems(el);
return items.length ? [{ type: "bulletList", content: items }] : [];
}
case "ol": {
const items = listItems(el);
return items.length ? [{ type: "orderedList", content: items }] : [];
}
case "hr":
return [{ type: "horizontalRule" }];
case "figure":
return figureFrom(el);
case "img":
return imgFigure(el);
case "script":
case "style":
return [];
default:
return hasBlockChild(el) ? blocksFrom(el) : paragraphAndFigures(el);
}
}
function blocksFrom(parent: Node): JSONContent[] {
const out: JSONContent[] = [];
parent.childNodes.forEach((child) => {
if (child.nodeType === 3) {
const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim();
if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] });
} else if (child.nodeType === 1) {
out.push(...blockFromElement(child as Element));
}
});
return out;
}
function headingText(node: JSONContent): string {
return (node.content ?? []).map((n) => n.text ?? "").join("").trim();
}
interface ManifestItem {
href: string;
mediaType: string;
properties: string;
}
function readManifest(opf: Document, opfDir: string): Map<string, ManifestItem> {
const items = new Map<string, ManifestItem>();
Array.from(opf.getElementsByTagName("*"))
.filter((el) => el.localName === "item")
.forEach((el) => {
const id = el.getAttribute("id");
const href = el.getAttribute("href");
if (!id || !href) return;
items.set(id, {
href: resolvePath(opfDir, decodeHref(href)),
mediaType: el.getAttribute("media-type") ?? "",
properties: el.getAttribute("properties") ?? "",
});
});
return items;
}
function readSpine(opf: Document, manifest: Map<string, ManifestItem>): ManifestItem[] {
return Array.from(opf.getElementsByTagName("*"))
.filter((el) => el.localName === "itemref")
.map((el) => manifest.get(el.getAttribute("idref") ?? ""))
.filter((item): item is ManifestItem => !!item)
.filter((item) => !item.properties.split(/\s+/).includes("nav"))
.filter((item) => /xhtml|html/.test(item.mediaType) || /\.x?html?$/.test(item.href));
}
function readNavTitles(
manifest: Map<string, ManifestItem>,
byPath: Map<string, RawFile>,
): Map<string, string> {
const titles = new Map<string, string>();
const nav = Array.from(manifest.values()).find((m) =>
m.properties.split(/\s+/).includes("nav"),
);
const ncx = Array.from(manifest.values()).find((m) =>
m.mediaType.includes("dtbncx"),
);
if (nav) {
const file = byPath.get(normalize(nav.href));
if (file) {
const doc = parseXml(file.data, "text/html");
const baseDir = dirOf(nav.href);
doc.querySelectorAll("a[href]").forEach((a) => {
const href = a.getAttribute("href")!;
const label = a.textContent?.trim();
if (label) titles.set(resolvePath(baseDir, decodeHref(href)), label);
});
}
}
if (!titles.size && ncx) {
const file = byPath.get(normalize(ncx.href));
if (file) {
const doc = parseXml(file.data, "application/xml");
const baseDir = dirOf(ncx.href);
Array.from(doc.getElementsByTagName("*"))
.filter((el) => el.localName === "navPoint")
.forEach((point) => {
const label = firstByLocal(point, "text")?.textContent?.trim();
const src = firstByLocal(point, "content")?.getAttribute("src");
if (label && src) titles.set(resolvePath(baseDir, decodeHref(src)), label);
});
}
}
return titles;
}
function resolveImages(
doc: Document,
baseDir: string,
byPath: Map<string, RawFile>,
manifest: Map<string, ManifestItem>,
): void {
const mimeByPath = new Map<string, string>();
manifest.forEach((item) => mimeByPath.set(item.href, item.mediaType));
doc.querySelectorAll("img").forEach((img) => {
const src = img.getAttribute("src");
if (!src || src.startsWith("data:")) return;
const resolved = resolvePath(baseDir, decodeHref(src));
const file = byPath.get(normalize(resolved));
if (!file) {
img.removeAttribute("src");
return;
}
const mime = mimeByPath.get(resolved) || mimeFromExt(resolved);
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
img.setAttribute("src", `data:${mime};base64,${data}`);
});
}
function buildChapter(
file: RawFile,
baseDir: string,
navTitle: string | undefined,
index: number,
byPath: Map<string, RawFile>,
manifest: Map<string, ManifestItem>,
): Chapter {
const doc = parseXml(file.data, "text/html");
resolveImages(doc, baseDir, byPath, manifest);
const blocks = blocksFrom(doc.body);
let title = navTitle ?? "";
if (blocks.length && blocks[0].type === "heading") {
const text = headingText(blocks[0]);
if (!title) title = text;
if (text && text.toLowerCase() === title.toLowerCase()) blocks.shift();
}
return {
id: crypto.randomUUID(),
title: title || `Chapter ${index + 1}`,
content: { type: "doc", content: blocks.length ? blocks : [{ type: "paragraph" }] },
};
}
export function filesToBook(files: RawFile[], fallbackName = "Imported book"): Book {
const byPath = new Map<string, RawFile>();
files.forEach((f) => byPath.set(normalize(f.path), f));
const container = byPath.get("META-INF/container.xml");
let opfPath = "";
if (container) {
const doc = parseXml(container.data, "application/xml");
opfPath = firstByLocal(doc, "rootfile")?.getAttribute("full-path") ?? "";
}
if (!opfPath) {
opfPath = files.find((f) => f.path.toLowerCase().endsWith(".opf"))?.path ?? "";
}
const opfFile = opfPath ? byPath.get(normalize(opfPath)) : undefined;
if (!opfFile) throw new Error("Not a valid EPUB: no package document found.");
const opf = parseXml(opfFile.data, "application/xml");
const opfDir = dirOf(normalize(opfPath));
const manifest = readManifest(opf, opfDir);
const spine = readSpine(opf, manifest);
const navTitles = readNavTitles(manifest, byPath);
const chapters: Chapter[] = [];
spine.forEach((item, i) => {
const file = byPath.get(normalize(item.href));
if (!file) return;
chapters.push(
buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest),
);
});
if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found.");
const identifier = localText(opf, "identifier");
const metadata: BookMetadata = {
title: localText(opf, "title") || fallbackName,
subtitle: "",
author: localText(opf, "creator"),
isbn: /\d{9,13}/.test(identifier) ? identifier.replace(/[^0-9Xx]/g, "") : "",
language: localText(opf, "language") || "en",
};
return {
schema: "margin/1",
id: crypto.randomUUID(),
metadata,
theme: "quiet-press",
settings: { trim: "6x9", bleed: true },
chapters,
};
}
export async function importEpub(): Promise<Book | null> {
const selected = await open({
multiple: false,
directory: false,
filters: [{ name: "EPUB", extensions: ["epub"] }],
});
if (typeof selected !== "string") return null;
const files = await invoke<RawFile[]>("unzip_epub", { path: selected });
return filesToBook(files, baseName(selected));
}