init
Release Please / release-please (push) Failing after 4m2s
Web CI / web typecheck + build (push) Successful in 3m39s
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 1h1m49s
CodeQL Analysis / Analyze (go) (push) Failing after 1h1m49s

This commit is contained in:
redlineo committed 2026-07-13 10:14:00 +03:00
commit d295c7f139
1527 files changed
+270726

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
#!/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]
*
* 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 <input.tex> [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();