rspace-online/modules/rpubs/typst-compile.ts

194 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 1623). */
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 };
}