import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import type { JSONContent } from "@tiptap/core"; import { type Book, type BookMetadata, type Chapter, type Cover, clampLanguage, createCover } from "../model/book"; import { DEFAULT_FONTS } from "../model/fonts"; export interface RawFile { path: string; data: string; encoding: "utf8" | "base64"; } type Mark = { type: string; attrs?: Record }; 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, attrs?: Record): Mark[] { return hasMark(marks, type) ? marks : [...marks, attrs ? { type, attrs } : { 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 (tag === "u") { collectInline(el, addMark(marks, "underline"), out); } else if (tag === "s" || tag === "strike" || tag === "del") { collectInline(el, addMark(marks, "strike"), out); } else if (tag === "code") { collectInline(el, addMark(marks, "code"), out); } else if (tag === "a") { const href = el.getAttribute("href"); collectInline(el, href ? addMark(marks, "link", { href }) : marks, 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 [ { type: "figure", attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" }, }, ]; } const alt = (img.getAttribute("alt") ?? "").trim(); return alt ? [{ type: "paragraph", content: [{ type: "text", text: alt }] }] : []; } function svgFigure(el: Element): JSONContent[] { const markup = el.outerHTML; if (!markup) return []; const src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(markup)))}`; return [{ type: "figure", attrs: { src, 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, depth: number): JSONContent[] { const items: JSONContent[] = []; Array.from(el.children) .filter((c) => c.localName.toLowerCase() === "li") .forEach((li) => { const content = blocksFrom(li, depth + 1); items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] }); }); return items; } function tableBlocks(el: Element): JSONContent[] { const out: JSONContent[] = []; Array.from(el.querySelectorAll("tr")).forEach((tr) => { const content: JSONContent[] = []; Array.from(tr.children) .filter((c) => c.localName.toLowerCase() === "td" || c.localName.toLowerCase() === "th") .forEach((cell) => { const inline = inlineOf(cell); if (!meaningful(inline)) return; if (content.length) content.push({ type: "text", text: " ยท " }); content.push(...inline); }); if (meaningful(content)) out.push({ type: "paragraph", content }); }); return out; } function blockFromElement(el: Element, depth: number): 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, depth + 1); return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }]; } case "ul": { const items = listItems(el, depth); return items.length ? [{ type: "bulletList", content: items }] : []; } case "ol": { const items = listItems(el, depth); return items.length ? [{ type: "orderedList", content: items }] : []; } case "hr": return [{ type: "horizontalRule" }]; case "figure": return figureFrom(el); case "img": return imgFigure(el); case "svg": return svgFigure(el); case "table": return tableBlocks(el); case "script": case "style": return []; default: return hasBlockChild(el) ? blocksFrom(el, depth + 1) : paragraphAndFigures(el); } } function blocksFrom(parent: Node, depth = 0): JSONContent[] { if (depth > 64) return []; 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, depth)); } }); return out; } function headingText(node: JSONContent): string { return (node.content ?? []).map((n) => n.text ?? "").join("").trim(); } function isbnChecksum(s: string): boolean { if (s.length === 10) { let sum = 0; for (let i = 0; i < 10; i++) { const v = s[i] === "X" ? 10 : Number(s[i]); if (Number.isNaN(v) || (s[i] === "X" && i !== 9)) return false; sum += v * (10 - i); } return sum % 11 === 0; } if (s.length === 13 && /^\d{13}$/.test(s)) { let sum = 0; for (let i = 0; i < 13; i++) sum += Number(s[i]) * (i % 2 === 0 ? 1 : 3); return sum % 10 === 0; } return false; } function findIsbn(opf: Document): string { const ids = Array.from(opf.getElementsByTagName("*")).filter((el) => el.localName === "identifier"); const ranked = ids .map((el) => ({ text: el.textContent ?? "", tagged: /isbn/i.test(el.getAttribute("opf:scheme") || el.getAttribute("scheme") || "") || /urn:isbn:/i.test(el.textContent ?? ""), })) .sort((a, b) => Number(b.tagged) - Number(a.tagged)); for (const { text } of ranked) { const cleaned = text.replace(/[^0-9Xx]/g, "").toUpperCase(); if (isbnChecksum(cleaned)) return cleaned; } return ""; } interface ManifestItem { href: string; mediaType: string; properties: string; } function readManifest(opf: Document, opfDir: string): Map { const items = new Map(); 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): 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 cleanLabel(text: string | null | undefined): string { return (text ?? "") .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ") .replace(/\s+/g, " ") .trim(); } function readNavTitles( manifest: Map, byPath: Map, ): Map { const titles = new Map(); 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 key = resolvePath(baseDir, decodeHref(a.getAttribute("href")!)); const label = cleanLabel(a.textContent); if (label && !titles.has(key)) titles.set(key, 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 = cleanLabel(firstByLocal(point, "text")?.textContent); const src = firstByLocal(point, "content")?.getAttribute("src"); if (label && src) { const key = resolvePath(baseDir, decodeHref(src)); if (!titles.has(key)) titles.set(key, label); } }); } } return titles; } function fileToDataUri(file: RawFile, mime: string): string { const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data))); return `data:${mime};base64,${data}`; } function resolveImages( doc: Document, baseDir: string, byPath: Map, manifest: Map, ): void { const mimeByPath = new Map(); 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; } img.setAttribute("src", fileToDataUri(file, mimeByPath.get(resolved) || mimeFromExt(resolved))); }); } function findCover( opf: Document, manifest: Map, byPath: Map, ): Cover { let item = Array.from(manifest.values()).find((m) => m.properties.split(/\s+/).includes("cover-image")); if (!item) { const metaCover = Array.from(opf.getElementsByTagName("*")).find( (el) => el.localName === "meta" && el.getAttribute("name") === "cover", ); const id = metaCover?.getAttribute("content"); if (id) item = manifest.get(id); } if (!item) return createCover(); const file = byPath.get(normalize(item.href)); if (!file) return createCover(); return { ...createCover(), kind: "image", image: fileToDataUri(file, item.mediaType || mimeFromExt(item.href)) }; } function buildChapter( file: RawFile, baseDir: string, navTitle: string | undefined, index: number, byPath: Map, manifest: Map, ): 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" }] }, updatedAt: Date.now(), }; } export function filesToBook(files: RawFile[], fallbackName = "Imported book"): Book { const byPath = new Map(); 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; try { chapters.push( buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest), ); } catch { return; } }); if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found."); const metadata: BookMetadata = { title: localText(opf, "title") || fallbackName, subtitle: "", author: localText(opf, "creator"), isbn: findIsbn(opf), language: clampLanguage(localText(opf, "language") || "en"), }; return { schema: "margin/1", id: crypto.randomUUID(), metadata, theme: "quiet-press", settings: { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS }, cover: findCover(opf, manifest, byPath), chapters, }; } export async function importEpub(): Promise { const selected = await open({ multiple: false, directory: false, filters: [{ name: "EPUB", extensions: ["epub"] }], }); if (typeof selected !== "string") return null; const files = await invoke("unzip_epub", { path: selected }); return filesToBook(files, baseName(selected)); }