/** * Typst compiler — spawns the Typst CLI to generate PDFs. * * Ported from pocket-press/lib/typst.ts. * Uses Bun.spawn() instead of Node execFile(). */ import { writeFile, readFile, readdir, mkdir, unlink, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import { randomUUID } from "node:crypto"; import type { ParsedDocument } from "./parse-document"; const TYPST_DIR = resolve(import.meta.dir, "typst"); export interface CompileOptions { document: ParsedDocument; formatId: string; } export interface CompileResult { pdf: Buffer; pageCount: number; } export interface PageImage { png: Buffer; widthPx: number; heightPx: number; } export interface CompilePagesResult { pages: PageImage[]; pageCount: number; } export async function compileDocument(options: CompileOptions): Promise { const { document, formatId } = options; const jobId = randomUUID(); const tmpDir = join("/tmp", `rpubs-${jobId}`); await mkdir(tmpDir, { recursive: true }); const dataPath = join(tmpDir, "data.json"); const driverPath = join(tmpDir, "main.typ"); const outputPath = join(tmpDir, "output.pdf"); try { // Write the content data as JSON await writeFile(dataPath, JSON.stringify(document, null, 2)); // Write the Typst driver file that imports format + template const formatImport = join(TYPST_DIR, "formats", `${formatId}.typ`); const templateImport = join(TYPST_DIR, "templates", "pocket-book.typ"); const driverContent = ` #import "${formatImport}": page-setup #show: page-setup #include "${templateImport}" `; await writeFile(driverPath, driverContent); // Compile with Typst CLI using Bun.spawn const proc = Bun.spawn( [ "typst", "compile", driverPath, outputPath, "--root", "/", "--input", `data-path=${dataPath}`, "--font-path", join(TYPST_DIR, "fonts"), ], { stdout: "pipe", stderr: "pipe", } ); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); throw new Error(`Typst compilation failed: ${stderr}`); } const pdf = Buffer.from(await readFile(outputPath)); // Count pages (rough: look for /Type /Page in PDF) const pdfStr = pdf.toString("latin1"); const pageCount = (pdfStr.match(/\/Type\s*\/Page[^s]/g) || []).length; return { pdf, pageCount }; } finally { // Clean up temp files await Promise.allSettled([ unlink(dataPath), unlink(driverPath), unlink(outputPath), ]).catch(() => {}); // Remove temp dir (best effort) import("node:fs/promises").then(({ rmdir }) => rmdir(tmpDir).catch(() => {})); } } /** * Compile a document to per-page PNG images. Used for fixed-layout EPUB. * Uses Typst's built-in PNG rasterizer — no extra deps. */ export async function compileDocumentToPages( options: CompileOptions & { ppi?: number }, ): Promise { const { document, formatId, ppi = 144 } = options; const jobId = randomUUID(); const tmpDir = join("/tmp", `rpubs-png-${jobId}`); await mkdir(tmpDir, { recursive: true }); const dataPath = join(tmpDir, "data.json"); const driverPath = join(tmpDir, "main.typ"); const outputPattern = join(tmpDir, "page-{n}.png"); try { await writeFile(dataPath, JSON.stringify(document, null, 2)); const formatImport = join(TYPST_DIR, "formats", `${formatId}.typ`); const templateImport = join(TYPST_DIR, "templates", "pocket-book.typ"); const driverContent = ` #import "${formatImport}": page-setup #show: page-setup #include "${templateImport}" `; await writeFile(driverPath, driverContent); const proc = Bun.spawn( [ "typst", "compile", driverPath, outputPattern, "--format", "png", "--ppi", String(ppi), "--root", "/", "--input", `data-path=${dataPath}`, "--font-path", join(TYPST_DIR, "fonts"), ], { stdout: "pipe", stderr: "pipe" }, ); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); throw new Error(`Typst PNG compilation failed: ${stderr}`); } // Collect rendered pages in numeric order. const files = (await readdir(tmpDir)) .filter((f) => f.startsWith("page-") && f.endsWith(".png")) .sort((a, b) => { const na = parseInt(a.replace(/[^0-9]/g, ""), 10); const nb = parseInt(b.replace(/[^0-9]/g, ""), 10); return na - nb; }); const pages: PageImage[] = []; for (const f of files) { const png = Buffer.from(await readFile(join(tmpDir, f))); const dims = readPngDimensions(png); pages.push({ png, widthPx: dims.width, heightPx: dims.height }); } return { pages, pageCount: pages.length }; } finally { await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } } /** Read width/height from a PNG IHDR chunk (bytes 16–23). */ function readPngDimensions(png: Buffer): { width: number; height: number } { if (png.length < 24 || png[0] !== 0x89 || png[1] !== 0x50) { return { width: 0, height: 0 }; } const width = png.readUInt32BE(16); const height = png.readUInt32BE(20); return { width, height }; }