generate-latex.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* generate-latex.mjs — Validate and compile a generated .tex CV file to PDF
*
* Usage:
* node generate-latex.mjs <input.tex> [output.pdf]
* node generate-latex.mjs <input.tex> [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 <path>: 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 <input.json> <output.tex> --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<object>}
*/
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 <input.tex> [output.pdf] # fact-check, validate career-ops template structure, then compile
node generate-latex.mjs <input.tex> [output.pdf] --compile-only # skip template validation; compile any user-owned .tex (latex-tex mode)
node generate-latex.mjs <input.tex> [output.pdf] --source <path> # add a fact-gate source (repeatable), e.g. the user's own .tex
node generate-latex.mjs <input.tex> [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();
}