#!/usr/bin/env node /** * generate-latex.mjs — Validate and compile a generated .tex CV file to PDF * * Usage: * node generate-latex.mjs [output.pdf] * node generate-latex.mjs [output.pdf] --compile-only * * Default: validates career-ops template structure (from templates/cv-template.tex). * --compile-only: skip template validation; compile any user-owned .tex (latex-tex mode). * --source : an extra fact-gate source, repeatable (latex-tex passes the user's own .tex). * --skip-fact-check: compile without the fact gate. * * Both modes run the CV fact gate (verify-cv-facts.mjs) on the .tex before * compiling, when the data root has a cv.md to check against — the same * condition generate-pdf.mjs uses. A blocked document is not compiled. * * Requires: a repo-local tectonic (`node install-latex-engine.mjs`, see * install-latex-engine.mjs), or tectonic / pdflatex 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'; import { fileURLToPath } from 'url'; import { isMainModule } from './lib/is-main-module.mjs'; import { validateFlags } from './lib/cli-flags.mjs'; import { getCareerOpsRoot } from './path-resolver.mjs'; const ROOT = dirname(fileURLToPath(import.meta.url)); const INSTALL_HINT = 'node install-latex-engine.mjs'; const MIN_SECTIONS = 4; const REQUIRED_COMMANDS = [ '\\\\resumeSubheading', '\\\\resumeItem', '\\\\resumeProjectHeading', ]; // Proper Unicode script test (not a hand-picked codepoint range) so // supplementary-plane ideographs (CJK Unified Ideographs Extension B and // later, e.g. U+20000+) are covered, not just the BMP. Needs the `u` flag -- // without it, \p{Script=...} throws, and a bare codepoint-range class only // ever sees UTF-16 surrogate halves for anything above U+FFFF, never the // real character. const CJK_RE = /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u; // xeCJK (Latin-doc CJK) or ctex (Chinese-doc-class CJK) means the .tex // already loads a CJK-capable font setup (see templates/cv-template.cjk.tex). // Matches xeCJK/ctex anywhere in a \usepackage package list (not just as the // sole argument, e.g. `\usepackage{fontspec,xeCJK}`), and ctex's own document // classes (`\documentclass{ctexart}` and friends), which auto-configure // xeCJK/LuaTeX-ja/CJK depending on engine without a separate \usepackage. const CJK_PACKAGE_RE = /\\usepackage(?:\[[^\]]*\])?\{[^}]*\b(?:xeCJK|ctex)\b[^}]*\}|\\documentclass(?:\[[^\]]*\])?\{ctex(?:art|rep|book)?\}/; /** * Where install-latex-engine.mjs puts tectonic: next to the scripts. * @param {string} [root] * @param {string} [platform] * @returns {string} */ export function localTectonicPath(root = ROOT, platform = process.platform) { return join(root, platform === 'win32' ? 'tectonic.exe' : 'tectonic'); } // The binary resolveLatexEngine() last succeeded with, so the compile runs the // executable it probed (the repo-local path, not whatever is on PATH). let resolvedEngineBin = null; /** * Resolve the LaTeX engine, preferring tectonic (XeTeX backend, supports CJK * via fontspec/xeCJK) over pdflatex (no CJK support). A repo-local tectonic * from install-latex-engine.mjs is tried before PATH. * @returns {string|null} */ export function resolveLatexEngine() { const local = localTectonicPath(); const candidates = [ ...(existsSync(local) ? [{ name: 'tectonic', bin: local }] : []), { name: 'tectonic', bin: 'tectonic' }, { name: 'pdflatex', bin: 'pdflatex' }, ]; for (const candidate of candidates) { try { execFileSync(candidate.bin, ['--version'], { stdio: 'pipe' }); resolvedEngineBin = candidate.bin; return candidate.name; } catch { /* not found */ } } resolvedEngineBin = null; return null; } /** * @param {string} content * @param {boolean} compileOnly * @param {string|null} [engine] - resolved LaTeX engine ('tectonic'/'pdflatex'/null); affects CJK handling * @returns {{ issues: string[], counts: object }} */ export function validateLatexContent(content, compileOnly, engine = null) { const issues = []; let resumeItemCount = 0; let subheadingCount = 0; let projectHeadingCount = 0; if (!content.includes('\\begin{document}')) { issues.push('Missing \\begin{document}'); } if (!content.includes('\\end{document}')) { issues.push('Missing \\end{document}'); } if (compileOnly) { return { issues, counts: { resumeItems: 0, subheadings: 0, projectHeadings: 0 }, }; } 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}`); } if (CJK_RE.test(content)) { const hasCjkPackage = CJK_PACKAGE_RE.test(content); if (engine === 'tectonic' && hasCjkPackage) { // tectonic's backend is XeTeX, so fontspec/xeCJK (loaded by // templates/cv-template.cjk.tex) can render CJK glyphs — no issue. } else if (engine === 'tectonic') { issues.push('CJK characters detected but no CJK package (xeCJK/ctex) is loaded. Generate from the CJK-aware template instead: `node build-cv-latex.mjs --template=cjk` (templates/cv-template.cjk.tex), or use `pdf` mode (HTML to PDF, which renders CJK) for these CVs.'); } else { issues.push(`CJK characters detected. This CJK-aware LaTeX path needs a XeTeX-based engine (fontspec/xeCJK) — pdfLaTeX cannot compile it. Install tectonic (\`${INSTALL_HINT}\`, one-time download) and regenerate from the CJK-aware template (\`--template=cjk\`), or use \`pdf\` mode (HTML to PDF, which renders CJK) for these CVs.`); } } for (const cmd of REQUIRED_COMMANDS) { if (!new RegExp(cmd).test(content)) { issues.push(`Missing command: ${cmd}`); } } const unresolvedMatch = content.match(/\{\{[A-Z_]+\}\}/g); if (unresolvedMatch) { issues.push(`Unresolved placeholders: ${[...new Set(unresolvedMatch)].join(', ')}`); } const lines = content.split('\n'); for (const line of lines) { if (/\\resumeItem\{/.test(line)) resumeItemCount++; if (/\\resumeSubheading(?!Continue)/.test(line)) subheadingCount++; if (/\\resumeProjectHeading/.test(line)) projectHeadingCount++; } if (!content.includes('\\pdfgentounicode=1')) { issues.push('Missing \\pdfgentounicode=1 (ATS compatibility)'); } return { issues, counts: { resumeItems: resumeItemCount, subheadings: subheadingCount, projectHeadings: projectHeadingCount, }, }; } /** * @param {string} absPath * @param {string} content * @param {string|null} outputPath * @param {boolean} compileOnly * @returns {Promise} */ export async function compileLatexFile(absPath, content, outputPath, compileOnly) { const engine = resolveLatexEngine(); const { issues, counts } = validateLatexContent(content, compileOnly, engine); const fileInfo = await stat(absPath); const sizeKB = (fileInfo.size / 1024).toFixed(1); const report = { file: basename(absPath), path: absPath, sizeKB: parseFloat(sizeKB), counts, issues, valid: issues.length === 0, compileOnly, }; if (issues.length > 0) { return report; } const texDir = dirname(absPath); const texBase = basename(absPath, '.tex'); const defaultPdf = join(texDir, `${texBase}.pdf`); const targetPdf = outputPath ? resolve(outputPath) : defaultPdf; const targetDir = dirname(targetPdf); if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } if (!engine) { report.compiled = false; report.compileError = `No LaTeX engine found. Run \`${INSTALL_HINT}\` (downloads tectonic once, ~10-22 MB) or install tectonic/pdflatex yourself.`; report.installHint = INSTALL_HINT; return report; } report.engine = engine; 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') { execFileSync(resolvedEngineBin || '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, ]; execFileSync('pdflatex', pdflatexArgs, { cwd: texDir, stdio: 'pipe', timeout: 120_000 }); 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 */ } report.compiled = false; report.compileError = latexError; } if (report.compiled) { 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}`; } 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(() => {}); } } return report; } /** * Run the CV fact gate on a .tex document before it is compiled (#5348). * * The latex and latex-tex modes hand the user a CV as sendable as the one * pdf mode renders, and until this ran here neither path checked it at all: * generate-pdf.mjs gates in code, this compiler did not. Same condition as * generate-pdf: no cv.md in the data root means there is nothing to check * against, so the gate is skipped rather than failing every claim. * * @param {string} content - the .tex source * @param {{root?: string, extraSources?: string[], label?: string}} [options] * extraSources: further evidence files, such as the user's own source .tex in * latex-tex mode, which is user-authored and may hold facts cv.md lacks. * @returns {Promise<{verdict: 'pass'|'warn'|'block'|'skipped', reason?: string, result?: object}>} */ export async function runLatexFactGate(content, { root = getCareerOpsRoot(), extraSources = [], label } = {}) { const cvPath = join(root, 'cv.md'); if (!existsSync(cvPath)) return { verdict: 'skipped', reason: `no cv.md in ${root}` }; // Imported lazily, as generate-pdf.mjs does: a test that copies this script // alone into a temp workspace has no cv.md and never reaches the import. const { assertFacts, stripMarkup } = await import('./verify-cv-facts.mjs'); const { voiceIssues, voiceTellMessage, voiceDashWarning, candidateOwnWords } = await import('./cv-voice.mjs'); // An explicit --source that does not exist is a mistyped path, not an empty // source. Read as empty, it would leave the claims it was passed to back // unsupported and blame the CV instead of the path. const extraPaths = extraSources.map(p => resolve(p)); const missing = extraPaths.filter(p => !existsSync(p)); if (missing.length) return { verdict: 'block', reason: `--source file not found: ${missing.join(', ')}` }; const sourcePaths = [cvPath, join(root, 'article-digest.md'), ...extraPaths]; // The voice check allows wording the candidate wrote: cv.md, any --source // file, voice-dna.md, and the cv-facts allow lists. article-digest.md // backs facts, not phrasing. // Strip each file on its own. Joined first, one \begin{document} in a // --source .tex file drops the cv.md lines that came before it. let voiceSource = ''; for (const sourcePath of [cvPath, ...extraPaths]) { const source = await readFile(sourcePath, 'utf-8'); voiceSource += `\n${stripMarkup(source, { keepLineBreaks: true })}`; } voiceSource += `\n${candidateOwnWords(root)}`; const voiceIssuesFound = voiceIssues(content, voiceSource); const voiceError = voiceTellMessage(voiceIssuesFound, label); if (voiceError) return { verdict: 'block', reason: voiceError }; const voiceWarning = voiceDashWarning(voiceIssuesFound, label); try { const result = assertFacts(content, { sourcePaths, configPath: join(root, 'config', 'cv-facts.json'), label }); if (result.verdict === 'block' || !voiceWarning) return { verdict: result.verdict, result }; return { verdict: 'warn', result, reason: voiceWarning, voiceWarning }; } catch (err) { return { verdict: 'block', reason: err.message }; } } // ── CLI flags + help ────────────────────────────────────── const KNOWN_FLAGS = ['--compile-only', '--skip-fact-check', '--source', '--help', '-h']; const VALUE_FLAGS = ['--source']; const USAGE = `Usage: node generate-latex.mjs [output.pdf] # fact-check, validate career-ops template structure, then compile node generate-latex.mjs [output.pdf] --compile-only # skip template validation; compile any user-owned .tex (latex-tex mode) node generate-latex.mjs [output.pdf] --source # add a fact-gate source (repeatable), e.g. the user's own .tex node generate-latex.mjs [output.pdf] --skip-fact-check node generate-latex.mjs --help|-h # print this usage block and exit The fact gate runs when the data root has a cv.md; a blocked document is not compiled. Requires a repo-local tectonic (${INSTALL_HINT}) or tectonic/pdflatex on PATH.`; /** * Split argv into positionals, --source values and boolean flags. * * @param {string[]} rawArgs * @returns {{positionals: string[], sources: string[], compileOnly: boolean, skipFactCheck: boolean}} */ export function parseLatexArgs(rawArgs) { const positionals = []; const sources = []; for (let i = 0; i < rawArgs.length; i++) { const arg = rawArgs[i]; if (arg === '--source') { if (rawArgs[i + 1] !== undefined) sources.push(rawArgs[++i]); } else if (arg.startsWith('--source=')) { sources.push(arg.slice('--source='.length)); } else if (arg !== '--compile-only' && arg !== '--skip-fact-check') { positionals.push(arg); } } return { positionals, sources, compileOnly: rawArgs.includes('--compile-only'), skipFactCheck: rawArgs.includes('--skip-fact-check'), }; } async function main() { const rawArgs = process.argv.slice(2); // Before the positional reads below: every argv token that is not // --compile-only is consumed as a PATH, so an unrecognized flag does not // fall through to a default, it silently becomes a filename. `--help` // landed in args[0] and was resolved as the input .tex ("Error reading // /…/--help: ENOENT"), and a mistyped `--compileonly` landed in args[1] // as the OUTPUT path while template validation it meant to skip ran anyway. // This is the defect lib/cli-flags.mjs was written for; see its header. validateFlags(rawArgs, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); const { positionals, sources, compileOnly, skipFactCheck } = parseLatexArgs(rawArgs); const inputPath = positionals[0]; const outputPath = positionals[1]; if (!inputPath) { console.error(USAGE); 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 factCheck = skipFactCheck ? { verdict: 'skipped', reason: '--skip-fact-check' } : await runLatexFactGate(content, { extraSources: sources, label: basename(absPath) }); if (factCheck.verdict === 'block') { // Nothing is compiled. A fact gate that let the PDF through and reported // afterwards would leave the sendable artifact on disk. console.error(factCheck.reason); console.log(JSON.stringify({ file: basename(absPath), path: absPath, issues: [factCheck.reason], valid: false, compileOnly, compiled: false, factCheck: { verdict: 'block', reason: factCheck.reason }, }, null, 2)); process.exit(1); } if (factCheck.result?.configMissing) { console.error('⚠️ No config/cv-facts.json — forbidden/advisory phrase checks did not run.'); } if (factCheck.voiceWarning) console.error(`⚠️ ${factCheck.voiceWarning}`); if (factCheck.verdict === 'warn' && factCheck.result) { const { printFactWarnings } = await import('./verify-cv-facts.mjs'); console.error(`⚠️ CV fact check warning: ${basename(absPath)}`); printFactWarnings(factCheck.result, console.error); } const report = await compileLatexFile(absPath, content, outputPath || null, compileOnly); report.factCheck = { verdict: factCheck.verdict, ...(factCheck.reason ? { reason: factCheck.reason } : {}), ...(factCheck.result ? { configMissing: factCheck.result.configMissing } : {}), }; console.log(JSON.stringify(report, null, 2)); process.exit(report.compiled ? 0 : (report.valid ? 1 : 1)); } if (isMainModule(import.meta.url)) { main(); }