/** * EPUB3 generator — reflowable (from markdown) + fixed-layout (from Typst PNGs). * * Produces valid EPUB3 packages using JSZip (already a dep). No external tools. * * Reflowable mode: markdown sections → styled xhtml chapters. Resizable text, * tiny file, reads well on any device. * * Fixed-layout mode: each page of the compiled Typst PDF is embedded as a PNG * image in a pre-paginated xhtml page — preserves the pocket-book design 1:1. */ import JSZip from "jszip"; import { marked } from "marked"; import { randomUUID } from "node:crypto"; import type { ParsedDocument, DocumentSection, DocumentBlock } from "./parse-document"; import type { PageImage } from "./typst-compile"; import { getFormat } from "./formats"; // ── Shared helpers ── function esc(s: string): string { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function slug(s: string): string { return s .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60) || "doc"; } function containerXml(): string { return ` `; } interface ManifestItem { id: string; href: string; mediaType: string; properties?: string; inSpine?: boolean; spineProperties?: string; } function buildPackageOpf(opts: { uuid: string; title: string; author: string; language: string; description?: string; items: ManifestItem[]; fixedLayout?: boolean; coverImageId?: string; }): string { const { uuid, title, author, language, description, items, fixedLayout, coverImageId } = opts; const now = new Date().toISOString().replace(/\.\d+Z$/, "Z"); const manifest = items .map((i) => { const attrs = [ `id="${esc(i.id)}"`, `href="${esc(i.href)}"`, `media-type="${esc(i.mediaType)}"`, ]; if (i.properties) attrs.push(`properties="${esc(i.properties)}"`); return ` `; }) .join("\n"); const spine = items .filter((i) => i.inSpine) .map((i) => { const attrs = [`idref="${esc(i.id)}"`]; if (i.spineProperties) attrs.push(`properties="${esc(i.spineProperties)}"`); return ` `; }) .join("\n"); const layoutMeta = fixedLayout ? ` pre-paginated auto auto` : ""; const coverMeta = coverImageId ? `\n ` : ""; return ` urn:uuid:${esc(uuid)} ${esc(title)} ${esc(author)} ${esc(language)} ${description ? `${esc(description)}` : ""} ${now}${layoutMeta}${coverMeta} ${manifest} ${spine} `; } function buildNav(items: { href: string; title: string }[], language: string): string { return ` Contents `; } function buildNcx(opts: { uuid: string; title: string; items: { href: string; title: string }[] }): string { const { uuid, title, items } = opts; return ` ${esc(title)} ${items .map( (it, i) => ` ${esc(it.title)} `, ) .join("\n")} `; } async function finalizeZip(zip: JSZip): Promise { const data = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 9 }, mimeType: "application/epub+zip", }); return data as Buffer; } function seedEpubSkeleton(): JSZip { const zip = new JSZip(); // mimetype MUST be first and uncompressed. zip.file("mimetype", "application/epub+zip", { compression: "STORE" }); zip.folder("META-INF")!.file("container.xml", containerXml()); return zip; } // ── Reflowable ── function renderBlockToHtml(block: DocumentBlock): string { switch (block.type) { case "paragraph": return `

${marked.parseInline(block.text) as string}

`; case "quote": { const inner = marked.parseInline(block.text) as string; const attrib = block.attribution ? `
— ${esc(block.attribution)}
` : ""; return `

${inner}

${attrib}
`; } case "list": { const tag = block.ordered ? "ol" : "ul"; const items = block.items .map((i) => `
  • ${marked.parseInline(i) as string}
  • `) .join(""); return `<${tag}>${items}`; } case "code": { const lang = block.language ? ` class="language-${esc(block.language)}"` : ""; return `
    ${esc(block.code)}
    `; } case "separator": return `
    `; } } function renderSectionXhtml(section: DocumentSection, language: string): string { const body = [ section.heading ? `${esc(section.heading)}` : "", ...section.blocks.map(renderBlockToHtml), ] .filter(Boolean) .join("\n"); return ` ${esc(section.heading || "Section")} ${body} `; } const REFLOWABLE_CSS = `@namespace epub "http://www.idpf.org/2007/ops"; html, body { margin: 0; padding: 0; } body { font-family: Georgia, "Times New Roman", serif; line-height: 1.55; padding: 0 1em; } h1, h2, h3, h4 { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; line-height: 1.25; margin: 1.2em 0 0.5em; } h1 { font-size: 1.6em; } h2 { font-size: 1.35em; } h3 { font-size: 1.15em; } p { margin: 0.6em 0; text-align: justify; hyphens: auto; -webkit-hyphens: auto; } blockquote { margin: 1em 1.5em; font-style: italic; color: #444; border-left: 2px solid #999; padding-left: 0.8em; } blockquote footer { font-style: normal; font-size: 0.9em; color: #666; margin-top: 0.3em; } pre { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.85em; background: #f4f4f4; padding: 0.6em 0.8em; border-radius: 3px; overflow-x: auto; white-space: pre-wrap; } code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; } ul, ol { margin: 0.6em 0 0.6em 1.5em; } li { margin: 0.2em 0; } hr { border: none; border-top: 1px solid #ccc; margin: 2em auto; width: 40%; } img { max-width: 100%; height: auto; } .title-page { text-align: center; padding-top: 20%; } .title-page h1 { font-size: 2.2em; margin-bottom: 0.3em; } .title-page .author { font-style: italic; color: #555; margin-top: 1em; }`; export async function buildReflowableEpub(opts: { document: ParsedDocument; language?: string; description?: string; }): Promise { const { document, language = "en", description } = opts; const uuid = randomUUID(); const zip = seedEpubSkeleton(); const oebps = zip.folder("OEBPS")!; oebps.folder("styles")!.file("book.css", REFLOWABLE_CSS); // Title page const titlePage = ` ${esc(document.title)}

    ${esc(document.title)}

    ${document.author ? `
    ${esc(document.author)}
    ` : ""}
    `; oebps.file("title.xhtml", titlePage); const navItems: { href: string; title: string }[] = [ { href: "title.xhtml", title: document.title }, ]; const manifestItems: ManifestItem[] = [ { id: "css", href: "styles/book.css", mediaType: "text/css" }, { id: "title", href: "title.xhtml", mediaType: "application/xhtml+xml", inSpine: true }, ]; document.sections.forEach((section, idx) => { const href = `sections/section-${String(idx + 1).padStart(3, "0")}.xhtml`; oebps.folder("sections")!.file( `section-${String(idx + 1).padStart(3, "0")}.xhtml`, renderSectionXhtml(section, language), ); manifestItems.push({ id: `sec${idx + 1}`, href, mediaType: "application/xhtml+xml", inSpine: true, }); if (section.heading) navItems.push({ href, title: section.heading }); }); // nav.xhtml (EPUB3) + toc.ncx (back-compat) oebps.file("nav.xhtml", buildNav(navItems, language)); manifestItems.push({ id: "nav", href: "nav.xhtml", mediaType: "application/xhtml+xml", properties: "nav", }); oebps.file("toc.ncx", buildNcx({ uuid, title: document.title, items: navItems })); manifestItems.push({ id: "ncx", href: "toc.ncx", mediaType: "application/x-dtbncx+xml" }); oebps.file( "content.opf", buildPackageOpf({ uuid, title: document.title, author: document.author || "Unknown", language, description, items: manifestItems, }), ); return finalizeZip(zip); } // ── Fixed-layout ── const FIXED_CSS = `@namespace epub "http://www.idpf.org/2007/ops"; html, body { margin: 0; padding: 0; overflow: hidden; } .page { position: absolute; inset: 0; width: 100%; height: 100%; } .page img { width: 100%; height: 100%; object-fit: contain; display: block; }`; function renderFixedPageXhtml(opts: { imagePath: string; width: number; height: number; pageNum: number; language: string; }): string { const { imagePath, width, height, pageNum, language } = opts; return ` Page ${pageNum}
    Page ${pageNum}
    `; } export async function buildFixedLayoutEpub(opts: { document: ParsedDocument; pages: PageImage[]; formatId: string; language?: string; description?: string; }): Promise { const { document, pages, formatId, language = "en", description } = opts; if (pages.length === 0) { throw new Error("No pages to package"); } const uuid = randomUUID(); const zip = seedEpubSkeleton(); const oebps = zip.folder("OEBPS")!; oebps.folder("styles")!.file("page.css", FIXED_CSS); const manifestItems: ManifestItem[] = [ { id: "css", href: "styles/page.css", mediaType: "text/css" }, ]; const navItems: { href: string; title: string }[] = []; pages.forEach((page, i) => { const num = i + 1; const pad = String(num).padStart(4, "0"); const imgHref = `images/page-${pad}.png`; const xhtmlHref = `pages/page-${pad}.xhtml`; oebps.folder("images")!.file(`page-${pad}.png`, page.png); oebps.folder("pages")!.file( `page-${pad}.xhtml`, renderFixedPageXhtml({ imagePath: `../${imgHref}`, width: page.widthPx, height: page.heightPx, pageNum: num, language, }), ); manifestItems.push({ id: `img-${pad}`, href: imgHref, mediaType: "image/png", properties: i === 0 ? "cover-image" : undefined, }); manifestItems.push({ id: `page-${pad}`, href: xhtmlHref, mediaType: "application/xhtml+xml", inSpine: true, spineProperties: i === 0 ? undefined : num % 2 === 0 ? "page-spread-left" : "page-spread-right", }); if (i === 0) navItems.push({ href: xhtmlHref, title: document.title }); }); // Supplemental TOC entries for long docs if (pages.length > 4) { navItems.push({ href: `pages/page-${String(Math.floor(pages.length / 2)).padStart(4, "0")}.xhtml`, title: "Middle", }); navItems.push({ href: `pages/page-${String(pages.length).padStart(4, "0")}.xhtml`, title: "End", }); } oebps.file("nav.xhtml", buildNav(navItems, language)); manifestItems.push({ id: "nav", href: "nav.xhtml", mediaType: "application/xhtml+xml", properties: "nav", }); oebps.file("toc.ncx", buildNcx({ uuid, title: document.title, items: navItems })); manifestItems.push({ id: "ncx", href: "toc.ncx", mediaType: "application/x-dtbncx+xml" }); const fmt = getFormat(formatId); const fmtDesc = fmt ? `${fmt.name} fixed-layout EPUB from rPubs` : undefined; oebps.file( "content.opf", buildPackageOpf({ uuid, title: document.title, author: document.author || "Unknown", language, description: description || fmtDesc, items: manifestItems, fixedLayout: true, coverImageId: "img-0001", }), ); return finalizeZip(zip); }