spb/autoscholar
Public
1import { spawn } from "node:child_process";2import { access, mkdir, readdir, readFile } from "node:fs/promises";3import { join } from "node:path";4import { PDFDocument } from "pdf-lib";56export interface BuildResult {7 ok: boolean;8 pdfPath?: string;9 pages?: number;10 log: string;11 durationMs: number;12}1314function run(15 cmd: string,16 args: string[],17 cwd: string,18 timeoutMs: number,19 env: Record<string, string>,20): Promise<{ code: number | null; out: string }> {21 return new Promise((resolve) => {22 const child = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });23 let out = "";24 const cap = (d: Buffer) => {25 out += d.toString();26 if (out.length > 400_000) out = out.slice(-300_000);27 };28 child.stdout.on("data", cap);29 child.stderr.on("data", cap);30 const t = setTimeout(() => {31 child.kill("SIGKILL");32 out += "\n[timeout]";33 }, timeoutMs);34 child.on("close", (code) => {35 clearTimeout(t);36 resolve({ code, out });37 });38 child.on("error", (e) => {39 clearTimeout(t);40 resolve({ code: -1, out: `${out}\n[spawn error] ${e.message}` });41 });42 });43}4445/** Builds manuscript/main.tex with tectonic (self-contained engine; bundle cached under TECTONIC_CACHE_DIR). */46export async function buildLatex(47 manuscriptDir: string,48 opts: { tectonicBin?: string; timeoutMs?: number; cacheDir?: string; keepLogs?: boolean },49): Promise<BuildResult> {50 const started = Date.now();51 const bin = opts.tectonicBin ?? process.env.TECTONIC_BIN ?? "tectonic";52 const env: Record<string, string> = {53 PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",54 HOME: process.env.HOME ?? "/tmp",55 LANG: "en_US.UTF-8",56 SOURCE_DATE_EPOCH: "1700000000",57 };58 if (opts.cacheDir) env.TECTONIC_CACHE_DIR = opts.cacheDir;59 const args = ["-X", "compile", "--keep-logs", "--keep-intermediates", "--outdir", ".", "main.tex"];60 const { code, out } = await run(bin, args, manuscriptDir, opts.timeoutMs ?? 10 * 60_000, env);61 const pdfPath = join(manuscriptDir, "main.pdf");62 let pages: number | undefined;63 let ok = code === 0;64 try {65 await access(pdfPath);66 const doc = await PDFDocument.load(await readFile(pdfPath), {67 ignoreEncryption: true,68 updateMetadata: false,69 });70 pages = doc.getPageCount();71 } catch {72 ok = false;73 }74 let log = out;75 try {76 const l = await readFile(join(manuscriptDir, "main.log"), "utf8");77 const errors = l78 .split("\n")79 .filter((ln) => /^!|Error|Undefined control sequence|Missing|Overfull \\hbox \(\d{3,}/.test(ln))80 .slice(0, 80);81 log = `${out.slice(-6000)}\n--- main.log highlights ---\n${errors.join("\n")}`;82 } catch {83 /* no log */84 }85 return {86 ok,87 pdfPath: ok ? pdfPath : undefined,88 pages,89 log: log.slice(-20_000),90 durationMs: Date.now() - started,91 };92}9394/** Renders selected pages to PNG with pdftoppm (poppler) when available; returns created files. */95export async function renderPages(96 pdfPath: string,97 outDir: string,98 pages: number[],99 dpi = 80,100): Promise<string[]> {101 await mkdir(outDir, { recursive: true });102 const bins = ["/opt/homebrew/bin/pdftoppm", "/usr/local/bin/pdftoppm", "pdftoppm"];103 for (const bin of bins) {104 const files: string[] = [];105 let any = false;106 for (const p of pages) {107 const prefix = join(outDir, `page-${String(p).padStart(2, "0")}`);108 const r = await run(109 bin,110 ["-r", String(dpi), "-f", String(p), "-l", String(p), "-png", "-singlefile", pdfPath, prefix],111 outDir,112 60_000,113 { PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" },114 );115 if (r.code === 0) {116 files.push(`${prefix}.png`);117 any = true;118 } else if (r.out.includes("spawn error")) break;119 }120 if (any) return files;121 }122 return [];123}124125export async function listFiles(dir: string, base = dir, out: string[] = []): Promise<string[]> {126 let entries: import("node:fs").Dirent[];127 try {128 entries = (await readdir(dir, { withFileTypes: true })) as import("node:fs").Dirent[];129 } catch {130 return out;131 }132 for (const e of entries) {133 const name = String(e.name);134 const p = join(dir, name);135 if (e.isDirectory()) {136 if (137 [138 "node_modules",139 ".venv",140 "venv",141 "__pycache__",142 ".git",143 "home",144 ".claude",145 ".autoscholar",146 ".cache",147 "_build",148 ".tectonic",149 "_repro",150 ].includes(name)151 )152 continue;153 await listFiles(p, base, out);154 } else if (e.isFile()) out.push(p.slice(base.length + 1));155 }156 return out;157}158