#!/usr/bin/env node /** * generate-latex.mjs — Validate and compile a generated .tex CV file to PDF * * Usage: * node generate-latex.mjs [output.pdf] * * Reads the .tex file, validates structure, compiles to PDF via tectonic or pdflatex. * If output.pdf is omitted, writes to the same directory as input with .pdf extension. * The .tex file is generated by the AI agent using templates/cv-template.tex. * * Requires: tectonic (preferred, brew install tectonic) or pdflatex (MiKTeX / TeX Live) on PATH. */ import { readFile, writeFile, stat, copyFile, rm } from 'fs/promises'; import { resolve, basename, dirname, join } from 'path'; import { execFileSync } from 'child_process'; import { existsSync, mkdirSync } from 'fs'; // The template emits 4 sections (Education, Work Experience, Personal // Projects, Technical Skills). We count \section{} blocks rather than match // the English titles, so a localized CV (e.g. "Educación", "学歴") still // validates instead of failing with a spurious "Missing section". const MIN_SECTIONS = 4; const REQUIRED_COMMANDS = [ '\\\\resumeSubheading', '\\\\resumeItem', '\\\\resumeProjectHeading', ]; // CJK (Japanese/Chinese/Korean) ranges: Hiragana, Katakana, CJK ideographs, // compatibility ideographs, halfwidth katakana, and Hangul. The template is a // pdfLaTeX/Computer-Modern setup with no CJK font, so these glyphs cannot // render under pdflatex or tectonic — detect them and fail with guidance // instead of emitting a broken PDF / cryptic compile log. const CJK_RE = /[぀-ヿ㐀-鿿豈-﫿ヲ-゚가-힯ᄀ-ᇿ]/; async function main() { const inputPath = process.argv[2]; const outputPath = process.argv[3]; // optional if (!inputPath) { console.error('Usage: node generate-latex.mjs [output.pdf]'); process.exit(1); } const absPath = resolve(inputPath); let content; try { content = await readFile(absPath, 'utf-8'); } catch (err) { console.error(`Error reading ${absPath}: ${err.message}`); process.exit(1); } const issues = []; // Check section count (language-agnostic — see MIN_SECTIONS). const sectionCount = (content.match(/\\section\{/g) || []).length; if (sectionCount < MIN_SECTIONS) { issues.push(`Expected at least ${MIN_SECTIONS} \\section{} blocks (Education, Work Experience, Projects, Skills — or localized equivalents), found ${sectionCount}`); } // The template cannot render CJK; fail with guidance instead of a broken PDF. if (CJK_RE.test(content)) { issues.push('CJK characters detected. The LaTeX template does not support Japanese/Chinese/Korean yet (pdfLaTeX setup with no CJK font). Use `pdf` mode (HTML to PDF, which renders CJK) for these CVs.'); } // Check required commands are used for (const cmd of REQUIRED_COMMANDS) { if (!new RegExp(cmd).test(content)) { issues.push(`Missing command: ${cmd}`); } } // Check document structure if (!content.includes('\\begin{document}')) { issues.push('Missing \\begin{document}'); } if (!content.includes('\\end{document}')) { issues.push('Missing \\end{document}'); } // Check for unresolved placeholders const unresolvedMatch = content.match(/\{\{[A-Z_]+\}\}/g); if (unresolvedMatch) { issues.push(`Unresolved placeholders: ${[...new Set(unresolvedMatch)].join(', ')}`); } // Check for common unescaped special chars in text (heuristic) const lines = content.split('\n'); let resumeItemCount = 0; let subheadingCount = 0; let projectHeadingCount = 0; for (const line of lines) { if (/\\resumeItem\{/.test(line)) resumeItemCount++; // Negative lookahead (not [^C]) so a line ending exactly at the macro // name still counts; only the \resumeSubheadingContinue variant is excluded. if (/\\resumeSubheading(?!Continue)/.test(line)) subheadingCount++; if (/\\resumeProjectHeading/.test(line)) projectHeadingCount++; } // Check pdfgentounicode if (!content.includes('\\pdfgentounicode=1')) { issues.push('Missing \\pdfgentounicode=1 (ATS compatibility)'); } const fileInfo = await stat(absPath); const sizeKB = (fileInfo.size / 1024).toFixed(1); // Output report as JSON const report = { file: basename(absPath), path: absPath, sizeKB: parseFloat(sizeKB), counts: { resumeItems: resumeItemCount, subheadings: subheadingCount, projectHeadings: projectHeadingCount, }, issues, valid: issues.length === 0, }; // If validation fails, report and exit if (issues.length > 0) { console.log(JSON.stringify(report, null, 2)); process.exit(1); } // --- Compile .tex → .pdf --- const texDir = dirname(absPath); const texBase = basename(absPath, '.tex'); const defaultPdf = join(texDir, `${texBase}.pdf`); const targetPdf = outputPath ? resolve(outputPath) : defaultPdf; // Ensure output directory exists const targetDir = dirname(targetPdf); if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } // Detect available engine: prefer tectonic, fall back to pdflatex let engine = null; for (const candidate of ['tectonic', 'pdflatex']) { try { execFileSync(candidate, ['--version'], { stdio: 'pipe' }); engine = candidate; break; } catch { /* not found */ } } if (!engine) { report.compiled = false; report.compileError = 'No LaTeX engine found. Install tectonic (brew install tectonic) or pdflatex.'; console.log(JSON.stringify(report, null, 2)); process.exit(1); } report.engine = engine; // For tectonic: strip pdflatex-only primitives that cause crashes let compilePath = absPath; if (engine === 'tectonic') { const patched = content .replace(/\\pdfgentounicode\s*=\s*\d+[^\n]*\n?/g, '') .replace(/\\input\{glyphtounicode\}[^\n]*\n?/g, ''); compilePath = join(texDir, `${texBase}._tectonic.tex`); await writeFile(compilePath, patched, 'utf-8'); } try { if (engine === 'tectonic') { // Tectonic handles multi-pass automatically; --outdir sets output location execFileSync('tectonic', ['--outdir', texDir, compilePath], { cwd: texDir, stdio: 'pipe', timeout: 120_000, }); } else { const pdflatexArgs = [ '-no-shell-escape', '-interaction=nonstopmode', '-halt-on-error', `-output-directory=${texDir}`, absPath, ]; // First pass execFileSync('pdflatex', pdflatexArgs, { cwd: texDir, stdio: 'pipe', timeout: 120_000 }); // Second pass (resolves referenceS)) execFileSync('pdflatex', pdflatexArgs, { cwd: texDir, stdio: 'pipe', timeout: 120_000 }); } report.compiled = true; } catch (err) { const logPath = join(texDir, `${texBase}.log`); let latexError = err.message; try { const log = await readFile(logPath, 'utf-8'); const errorLines = log.split('\n').filter(l => l.startsWith('!')); if (errorLines.length > 0) { latexError = errorLines.join('\n'); } } catch { /* no log file */ } report.compiled = false; report.compileError = latexError; } // Post-compile: move PDF and clean up (separate from compile errors) if (report.compiled) { // Tectonic outputs PDF named after the patched temp file const compileBase = basename(compilePath, '.tex'); const compiledPdf = join(texDir, `${compileBase}.pdf`); try { await copyFile(compiledPdf, targetPdf); if (resolve(compiledPdf) !== resolve(targetPdf)) { await rm(compiledPdf).catch(() => {}); } const pdfStat = await stat(targetPdf); report.pdf = { path: targetPdf, sizeKB: parseFloat((pdfStat.size / 1024).toFixed(1)), }; } catch (err) { report.postCompileError = `Failed to finalize PDF: ${err.message}`; } // Clean up auxiliary files and tectonic temp .tex (best-effort) const auxExts = ['.aux', '.log', '.out', '.fls', '.fdb_latexmk', '.synctex.gz']; for (const ext of auxExts) { await rm(join(texDir, `${compileBase}${ext}`)).catch(() => {}); } if (engine === 'tectonic') { await rm(compilePath).catch(() => {}); } } console.log(JSON.stringify(report, null, 2)); process.exit(report.compiled ? 0 : 1); } main();