194 lines
5.0 KiB
TypeScript
194 lines
5.0 KiB
TypeScript
/**
|
||
* 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<CompileResult> {
|
||
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<CompilePagesResult> {
|
||
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 };
|
||
}
|