mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
feat(epub): parse EPUB files into a Book (importer)
This commit is contained in:
1 parent
8b9fa7f24e
commit
406cd1289c
1 file changed
+420
@@ -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));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user