SPB Git

spb/autoscholar

Public
4.6 KB · 158 lines typescript
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
Files · a78adfcb2b7368f5bc22c4935584f39e3929aa9a
Menu

Theme