import { spawn } from "node:child_process"; import { access, mkdir, readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { PDFDocument } from "pdf-lib"; export interface BuildResult { ok: boolean; pdfPath?: string; pages?: number; log: string; durationMs: number; } function run( cmd: string, args: string[], cwd: string, timeoutMs: number, env: Record, ): Promise<{ code: number | null; out: string }> { return new Promise((resolve) => { const child = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); let out = ""; const cap = (d: Buffer) => { out += d.toString(); if (out.length > 400_000) out = out.slice(-300_000); }; child.stdout.on("data", cap); child.stderr.on("data", cap); const t = setTimeout(() => { child.kill("SIGKILL"); out += "\n[timeout]"; }, timeoutMs); child.on("close", (code) => { clearTimeout(t); resolve({ code, out }); }); child.on("error", (e) => { clearTimeout(t); resolve({ code: -1, out: `${out}\n[spawn error] ${e.message}` }); }); }); } /** Builds manuscript/main.tex with tectonic (self-contained engine; bundle cached under TECTONIC_CACHE_DIR). */ export async function buildLatex( manuscriptDir: string, opts: { tectonicBin?: string; timeoutMs?: number; cacheDir?: string; keepLogs?: boolean }, ): Promise { const started = Date.now(); const bin = opts.tectonicBin ?? process.env.TECTONIC_BIN ?? "tectonic"; const env: Record = { PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp", LANG: "en_US.UTF-8", SOURCE_DATE_EPOCH: "1700000000", }; if (opts.cacheDir) env.TECTONIC_CACHE_DIR = opts.cacheDir; const args = ["-X", "compile", "--keep-logs", "--keep-intermediates", "--outdir", ".", "main.tex"]; const { code, out } = await run(bin, args, manuscriptDir, opts.timeoutMs ?? 10 * 60_000, env); const pdfPath = join(manuscriptDir, "main.pdf"); let pages: number | undefined; let ok = code === 0; try { await access(pdfPath); const doc = await PDFDocument.load(await readFile(pdfPath), { ignoreEncryption: true, updateMetadata: false, }); pages = doc.getPageCount(); } catch { ok = false; } let log = out; try { const l = await readFile(join(manuscriptDir, "main.log"), "utf8"); const errors = l .split("\n") .filter((ln) => /^!|Error|Undefined control sequence|Missing|Overfull \\hbox \(\d{3,}/.test(ln)) .slice(0, 80); log = `${out.slice(-6000)}\n--- main.log highlights ---\n${errors.join("\n")}`; } catch { /* no log */ } return { ok, pdfPath: ok ? pdfPath : undefined, pages, log: log.slice(-20_000), durationMs: Date.now() - started, }; } /** Renders selected pages to PNG with pdftoppm (poppler) when available; returns created files. */ export async function renderPages( pdfPath: string, outDir: string, pages: number[], dpi = 80, ): Promise { await mkdir(outDir, { recursive: true }); const bins = ["/opt/homebrew/bin/pdftoppm", "/usr/local/bin/pdftoppm", "pdftoppm"]; for (const bin of bins) { const files: string[] = []; let any = false; for (const p of pages) { const prefix = join(outDir, `page-${String(p).padStart(2, "0")}`); const r = await run( bin, ["-r", String(dpi), "-f", String(p), "-l", String(p), "-png", "-singlefile", pdfPath, prefix], outDir, 60_000, { PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" }, ); if (r.code === 0) { files.push(`${prefix}.png`); any = true; } else if (r.out.includes("spawn error")) break; } if (any) return files; } return []; } export async function listFiles(dir: string, base = dir, out: string[] = []): Promise { let entries: import("node:fs").Dirent[]; try { entries = (await readdir(dir, { withFileTypes: true })) as import("node:fs").Dirent[]; } catch { return out; } for (const e of entries) { const name = String(e.name); const p = join(dir, name); if (e.isDirectory()) { if ( [ "node_modules", ".venv", "venv", "__pycache__", ".git", "home", ".claude", ".autoscholar", ".cache", "_build", ".tectonic", "_repro", ].includes(name) ) continue; await listFiles(p, base, out); } else if (e.isFile()) out.push(p.slice(base.length + 1)); } return out; }