import type { JSONContent } from "@tiptap/core"; import { invoke } from "@tauri-apps/api/core"; import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book"; export interface EpubFile { path: string; data: string; encoding: "utf8" | "base64"; } function esc(text: string): string { return text.replace(/&/g, "&").replace(//g, ">"); } function attr(text: string): string { return esc(text).replace(/"/g, """); } function slug(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } function imageExtension(dataUrl: string): string { const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl); const kind = (match?.[1] ?? "png").toLowerCase(); return kind === "jpeg" ? "jpg" : kind; } function mediaType(path: string): string { if (path.endsWith(".jpg")) return "image/jpeg"; if (path.endsWith(".png")) return "image/png"; if (path.endsWith(".gif")) return "image/gif"; if (path.endsWith(".webp")) return "image/webp"; if (path.endsWith(".svg")) return "image/svg+xml"; return "application/octet-stream"; } function inline(node: JSONContent): string { if (node.type === "text") { let text = esc(node.text ?? ""); const marks = node.marks ?? []; if (marks.some((m) => m.type === "bold")) text = `${text}`; if (marks.some((m) => m.type === "italic")) text = `${text}`; const href = marks.find((m) => m.type === "link")?.attrs?.href; if (href) text = `${text}`; return text; } if (node.type === "hardBreak") return "
"; return ""; } function inlines(content: JSONContent[] = []): string { return content.map(inline).join(""); } function listItem(item: JSONContent): string { const inner = (item.content ?? []) .filter((c) => c.type === "paragraph") .map((p) => inlines(p.content)) .join(" "); return `
  • ${inner}
  • `; } function figure(node: JSONContent, paths: Map): string { const path = paths.get(node.attrs?.src); if (!path) return ""; const placement = node.attrs?.placement ?? "inline"; const alt = attr(node.attrs?.alt ?? ""); const caption = node.attrs?.caption ? `
    ${esc(node.attrs.caption)}
    ` : ""; const placed = placement as FigurePlacement; const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100; const style = placement === "full-page" ? "" : ` style="width:${width}%"`; return `
    ${alt}${caption}
    `; } function block(node: JSONContent, paths: Map): string { switch (node.type) { case "paragraph": { const body = inlines(node.content); if (!body.trim()) return "

     

    "; return `${body}

    `; } case "heading": return node.attrs?.level === 3 ? `

    ${inlines(node.content)}

    ` : `

    ${inlines(node.content)}

    `; case "blockquote": return `
    ${(node.content ?? []).map((n) => block(n, paths)).join("")}
    `; case "bulletList": return `
      ${(node.content ?? []).map(listItem).join("")}
    `; case "orderedList": return `
      ${(node.content ?? []).map(listItem).join("")}
    `; case "horizontalRule": return `
    `; case "figure": return figure(node, paths); default: return ""; } } function chapterBody(content: JSONContent, paths: Map): string { return (content.content ?? []) .map((n) => block(n, paths)) .filter((s) => s.length > 0) .join("\n"); } function wrapTitle(title: string, max = 16): string[] { const words = title.split(/\s+/).filter(Boolean); const lines: string[] = []; let line = ""; for (const word of words) { if (line && (line + " " + word).length > max) { lines.push(line); line = word; } else { line = line ? line + " " + word : word; } } if (line) lines.push(line); return lines.length ? lines.slice(0, 4) : ["Untitled"]; } function coverSvg(book: Book): string { const c = book.cover; const m = book.metadata; const dims = TRIM_DIMS[book.settings.trim]; const W = 1200; const H = Math.round((W * dims.h) / dims.w); const lines = wrapTitle(m.title || "Untitled"); const titleSize = lines.length > 2 ? 88 : 104; const lineH = titleSize * 1.12; const blockMid = H * 0.4; const firstY = Math.round(blockMid - ((lines.length - 1) * lineH) / 2); const titleSpans = lines .map((l, i) => `${esc(l)}`) .join(""); const ruleY = firstY + Math.round((lines.length - 1) * lineH + 84); const rule = ``; const subtitle = m.subtitle ? `${esc(m.subtitle)}` : ""; const author = m.author ? `${esc(m.author.toUpperCase())}` : ""; return ` ${titleSpans} ${rule} ${subtitle} ${author} `; } function buildCover(book: Book): { asset: EpubFile; href: string; mediaType: string } { const c = book.cover; if (c.kind === "image" && c.image.startsWith("data:")) { const href = `assets/cover.${imageExtension(c.image)}`; return { asset: { path: `OEBPS/${href}`, data: c.image.slice(c.image.indexOf(",") + 1), encoding: "base64" }, href, mediaType: mediaType(href), }; } const href = "assets/cover.svg"; return { asset: { path: `OEBPS/${href}`, data: coverSvg(book), encoding: "utf8" }, href, mediaType: "image/svg+xml", }; } function coverXhtml(book: Book, href: string): string { const title = book.metadata.title || "Cover"; return ` ${esc(title)}
    ${attr(title)}
    `; } function extractFigures(book: Book): { assets: EpubFile[]; paths: Map } { const paths = new Map(); const assets: EpubFile[] = []; const visit = (node: JSONContent) => { if (node.type === "figure") { const src: string | undefined = node.attrs?.src; if (src && src.startsWith("data:") && !paths.has(src)) { const path = `assets/figure-${assets.length + 1}.${imageExtension(src)}`; paths.set(src, path); assets.push({ path: `OEBPS/${path}`, data: src.slice(src.indexOf(",") + 1), encoding: "base64", }); } } (node.content ?? []).forEach(visit); }; book.chapters.forEach((chapter) => visit(chapter.content)); return { assets, paths }; } function identifier(book: Book): string { if (book.metadata.isbn) return book.metadata.isbn; const seed = slug(book.metadata.title || "untitled") || "untitled"; return `urn:uuid:margin-${seed}`; } function containerXml(): string { return ` `; } function contentOpf( book: Book, chapterIds: string[], assetPaths: string[], cover: { href: string; mediaType: string }, ): string { const meta = book.metadata; const manifest: string[] = [ ``, ``, ``, ``, ``, ``, ]; chapterIds.forEach((_, i) => { manifest.push( ``, ); }); assetPaths.forEach((path, i) => { manifest.push( ``, ); }); const spine = [``, ``] .concat(chapterIds.map((_, i) => ``)) .join("\n "); return ` ${esc(identifier(book))} ${esc(meta.title || "Untitled")} ${esc(meta.author || "")} ${esc(meta.language || "en")} 2026-01-01T00:00:00Z ${manifest.join("\n ")} ${spine} `; } function navXhtml(book: Book): string { const items = book.chapters .map((chapter, i) => { const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled"; return `
  • ${esc(chapter.title || fallback)}
  • `; }) .join("\n"); return ` ${esc(book.metadata.title || "Untitled")} `; } function chapterXhtml(book: Book, index: number, paths: Map): string { const chapter = book.chapters[index]; const kind = chapterKind(chapter); const num = bodyNumber(book.chapters, index); const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled"); const eyebrow = kind === "body" ? `

    Chapter ${num}

    ` : ""; return ` ${esc(title)}
    ${eyebrow}

    ${esc(title)}

    ${chapterBody(chapter.content, paths)} `; } function styleCss(): string { return `@font-face { font-family: "Literata"; font-style: normal; font-weight: 300 700; src: url("fonts/Literata-VF.ttf"); } @font-face { font-family: "Literata"; font-style: italic; font-weight: 300 700; src: url("fonts/Literata-Italic-VF.ttf"); } html { font-family: "Literata", Georgia, serif; } .cover { margin: 0; padding: 0; text-align: center; page-break-after: always; } .cover img { max-width: 100%; max-height: 100vh; } body { margin: 0 5%; line-height: 1.5; } p { margin: 0; text-align: justify; hyphens: auto; -webkit-hyphens: auto; -epub-hyphens: auto; } p[data-indent="true"] { text-indent: 1.3em; } h1, h2, h3 { hyphens: none; line-height: 1.25; font-weight: 500; } h2, h3 { margin: 1.4em 0 0.6em; } .chapter-opener { text-align: center; margin: 3em 0 2.2em; } .eyebrow { text-transform: uppercase; letter-spacing: 0.28em; font-size: 0.72em; margin: 0 0 0.7em; } .chapter-opener h1 { font-size: 1.7em; margin: 0; } blockquote { margin: 1.2em 1.6em; font-style: italic; } blockquote p { text-indent: 0; text-align: left; } ul, ol { margin: 1em 0; padding-left: 1.6em; } li { text-align: left; } hr.scene-break { border: 0; margin: 1.6em 0; text-align: center; } hr.scene-break::before { content: "\\2042"; letter-spacing: 0.4em; } figure { margin: 1.4em auto; text-align: center; } figure img { max-width: 100%; } figcaption { font-size: 0.82em; font-style: italic; text-indent: 0; margin-top: 0.5em; } figure.placement-full-width img, figure.placement-full-page img { width: 100%; } figure.placement-full-page { margin: 0; page-break-before: always; page-break-after: always; } figure.placement-float-top { float: top; } `; } export function bookToEpub(book: Book): EpubFile[] { const { assets, paths } = extractFigures(book); const cover = buildCover(book); const chapterIds = book.chapters.map((c) => c.id); const assetPaths = Array.from(paths.values()); const files: EpubFile[] = [ { path: "mimetype", data: "application/epub+zip", encoding: "utf8" }, { path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" }, { path: "OEBPS/content.opf", data: contentOpf(book, chapterIds, assetPaths, cover), encoding: "utf8", }, { path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" }, { path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" }, { path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" }, cover.asset, ]; book.chapters.forEach((_, i) => { files.push({ path: `OEBPS/chapter-${i + 1}.xhtml`, data: chapterXhtml(book, i, paths), encoding: "utf8", }); }); files.push(...assets); return files; } export async function buildEpub(book: Book): Promise { const files = bookToEpub(book); const buf = await invoke("package_epub", { files }); return new Uint8Array(buf); }