generate-pdf.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* generate-pdf.mjs — HTML → PDF via Playwright
*
* Usage:
* node career-ops/generate-pdf.mjs <input.html> <output.pdf> [--format=letter|a4] [--country=XX] [--report=NNN] [--kind=cv|cover] [--allow-reorder] [--allow-nonchronological] [--max-pages=N] [--strict-pages] [--strict-fact-check] [--skip-fact-check]
* node career-ops/generate-pdf.mjs --batch=<manifest.json> [--format=letter|a4] [--country=XX] [--allow-reorder] [--allow-nonchronological] [--max-pages=N] [--strict-pages] [--strict-fact-check] [--skip-fact-check]
*
* --batch renders every document in a JSON manifest (an array of
* {input, output, format?, reportNum?}) through ONE shared Chromium instead of
* relaunching per document. One failing document is isolated and recorded; the
* rest still render. Results are written to <manifest>.results.json and the
* process exits non-zero if any document failed (#2384). Relative input and
* output paths in the manifest resolve against the manifest's folder, not
* process.cwd().
*
* A single-file relative path is different. It is taken from the current
* directory when that location is already inside the tracker workspace, and
* otherwise from the workspace root. `output/cv.html` then finds the file
* whether you launch from the repo or from a checkout whose data root lives
* elsewhere. Absolute paths stay absolute.
*
* --report links the generated PDF to its tracker/report number and records
* the linkage in data/pdf-index.tsv so downstream tools (e.g. the TUI
* dashboard's `d`/`D` hotkeys) can locate the exact PDF for an application.
* Without --report a manifest row is still written, just unkeyed.
*
* --allow-reorder downgrades the CV section-order guard from a thrown error
* to a console warning, for JDs where the section order was deliberately
* tailored (e.g. Projects moved ahead of Education for a technical-heavy
* role) rather than accidentally scrambled by an agent. Without this flag,
* any divergence from cv.md's section order still fails generation.
*
* --allow-nonchronological downgrades the work-experience ordering guard from
* a thrown error to a console warning. By default a CV whose experience entries
* are not newest-first fails generation: promoting "the most relevant role" to
* the top is a functional-resume technique that buries the candidate's most
* recent senior title and reads as concealment to ATS parsers and recruiters.
*
* --max-pages=N sets the preferred rendered CV length (default: 2 pages).
* The actual page count is read from the PDF Chromium produced, before anything
* is written to the output path. An overflow never lands there: the render goes
* to a sibling {output}.overflow.pdf draft for inspection, and the output path
* keeps whatever it held. By default that is a warning and the process still
* exits 0; --strict-pages turns it into a hard rejection. Either way the draft
* is never logged as a successful render or indexed in data/pdf-index.tsv.
*
* --strict-fact-check makes a `warn` verdict from the fact gate fatal, the same
* way --strict-pages makes a page-budget overflow fatal. The gate's `warn`
* covers findings that are advisory by nature — claims it could not read
* (`#5348`), a missing config/cv-facts.json — so a careful caller may still
* want them to stop a render instead of scrolling past them. The warnings are
* printed exactly as they are without the flag; only the exit changes. It does
* not make the gate RUN: --skip-fact-check, or a workspace with no cv.md, still
* means no gate, and combining the two is rejected rather than silently
* honouring one over the other. A `block` verdict already fails without it.
*
* Requires: @playwright/test (or playwright) installed.
* Uses Chromium headless to render the HTML and produce a clean, ATS-parseable PDF.
*/
import { resolve, dirname, relative, sep, isAbsolute, basename } from 'path';
import { readFile } from 'fs/promises';
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs';
import { fileURLToPath, pathToFileURL } from 'url';
import { randomUUID } from 'node:crypto';
import { getCareerOpsRoot, getProfilePath, hasProfileOverride } from './path-resolver.mjs';
import { readStyleTokens, injectThemeStyle, readCvSectionOrder, readLockedSections, readCvPageBudget } from './theme-style.mjs';
import { validateCvExperienceOrder } from './cv-experience-order.mjs';
import { resolvePdfIndexPath, resolveTrackerPath, resolveWorkspaceRootFor, writeFileAtomic } from './tracker-utils.mjs';
import { withPipelineLock } from './pipeline-lock.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { localToday } from './lib/local-today.mjs';
import { stripEmptyRenderedSections } from './cv-sections-core.mjs';
import { PAGE_CSS_SIZE, PAGE_FORMATS, normalizePageFormat, pageFormatForCountry, resolvePageFormat } from './lib/page-format.mjs';
import { browserLaunchOptions } from './lib/browser-launch.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const trackerPath = resolveTrackerPath(getCareerOpsRoot());
// Derive the workspace root from the uncanonicalized tracker path (#3169): a
// repo that symlinks only its data/ out (the #524 workaround) must still resolve
// cv.md, config/ and output/ inside the repo, not follow data/ to the symlink
// target. resolveWorkspaceRootFor canonicalizes the derived root itself, so the
// spelling the rest of the module compares against stays canonical as before.
const workspaceRoot = resolveWorkspaceRootFor(getCareerOpsRoot());
const PDF_PAGE_MARGIN = '0.6in';
/**
* The profile a render reads. An explicit root is the workspace the caller
* named, so the environment does not redirect it. Otherwise CAREER_OPS_PROFILE
* names the file outright, and without it the profile sits in the workspace
* (which CAREER_OPS_TRACKER can move off the data root, #2471).
*/
function workspaceProfilePath(explicitRoot) {
if (explicitRoot) return getProfilePath(explicitRoot);
return hasProfileOverride() ? getProfilePath() : getProfilePath(workspaceRoot);
}
// Canonical tracker workspace: realpath so a symlinked ancestor (e.g. macOS
// /var -> /private/var) is compared like-for-like by assertInsideWorkspace.
// When CAREER_OPS_TRACKER points at another workspace, that workspace—not the
// installed script directory—is the safe boundary for input and output.
//
// Derived at USE time, not frozen at import (#3162, root-caused occurrence 6):
// the root reads CAREER_OPS_TRACKER, and a sibling test that legitimately sets
// that variable for its own fixture — and correctly restores it afterwards —
// still poisoned this module for the whole process if the import happened to
// land inside that window. A `const` cannot un-read an env var, so the guard
// then compared perfectly valid repo paths against another test's temp dir and
// refused to write, intermittently and only under the full suite. Memoized on
// the variable's own value: same cost as the const while nothing changes, and
// self-correcting the moment it does. Same defect class as #3159.
let __rootCache = { key: null, root: null, canonical: null };
function refreshRootCache() {
// Derive the root on EVERY call and key the cache on the resolved value.
//
// The root can move three ways: CAREER_OPS_TRACKER, CAREER_OPS_ROOT /
// CAREER_OPS_DATA_DIR, and the .career-ops-data marker file. Keying on the
// environment alone misses the marker, which is a file on disk and can change
// while the environment does not, so the cache would keep serving a stale
// workspace. The resolved root is the one value that captures all three, and
// what is memoised is only realpathSync(), the syscall worth avoiding.
//
// getCareerOpsRoot(), not __dirname: the data root is the env vars, then a
// .career-ops-data marker, then the repo, and only the last of those is the
// script's own directory. With the user layer outside the checkout this
// derived the workspace from the CODE directory, so every path under the
// real data root read as an escape and the PDF was refused (#4389). Line 49
// already used getCareerOpsRoot(), so the two disagreed inside one module.
//
// Same #3169 derivation as the import-time const: resolveWorkspaceRootFor works
// from the uncanonicalized tracker so a symlinked data/ stays in the repo.
const root = resolveWorkspaceRootFor(getCareerOpsRoot());
if (__rootCache.key !== root) {
__rootCache = { key: root, root, canonical: realpathSync(root) };
}
return __rootCache;
}
// Two accessors on purpose, so each call site keeps the exact semantics it had:
// the containment guard compares canonical forms (a symlinked ancestor must not
// read as an escape), while the manifest/output helpers work in the lexical form
// the rest of the module and its callers use.
function currentWorkspaceRoot() { return refreshRootCache().root; }
function canonicalWorkspaceRoot() { return refreshRootCache().canonical; }
/**
* Assert that an already-resolved absolute path stays inside the tracker workspace,
* resolving symlinks first.
*
* A lexical relative(workspaceRoot, p) check accepts a path that stays lexically
* inside the repo but whose ancestor is a symlink escaping it. This canonicalizes
* p through realpath — the path itself when it exists, otherwise its nearest
* existing ancestor with the not-yet-created tail re-appended (the output PDF and
* its directory may not exist yet) — then checks containment against the
* realpathed workspace root (mirrors canonicalizeTrackerPath in tracker-utils.mjs).
*
* @param {string} absPath - Already-resolved absolute candidate path.
* @param {string} label - 'input' | 'output', used in the thrown message.
* @returns {string} absPath unchanged when contained.
* @throws {Error} when the canonical path escapes the tracker workspace.
*/
function assertInsideWorkspace(absPath, label) {
let probe = absPath;
const tail = [];
while (!existsSync(probe)) {
tail.unshift(basename(probe));
const parent = dirname(probe);
if (parent === probe) break; // reached the filesystem root
probe = parent;
}
let canonical;
try {
canonical = existsSync(probe) ? resolve(realpathSync(probe), ...tail) : absPath;
} catch (err) {
// Canonicalization failed (realpath raced away, permission error): containment
// is unprovable, so fail closed rather than fall back to a lexical form that a
// symlinked ancestor could slip past. Named as its own failure, not as an
// escape: an intermittent CI-only hit of this guard (#3162) was undiagnosable
// while both branches threw the same message — "escapes" points a reader at
// the path, when the actual event was realpath failing underneath it.
throw new Error(
`${label} could not be canonicalized against the tracker workspace`
+ ` (${/** @type {any} */ (err)?.code || 'realpath failed'} on ${probe}): ${absPath}`,
);
}
const workspace = canonicalWorkspaceRoot();
const rel = relative(workspace, canonical);
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
// #3162: an intermittent macOS-CI-only hit of this branch happens on paths
// that are lexically inside the sandbox, and canonicalization SUCCEEDS
// before it. Print both sides so the next occurrence names the disagreeing
// ancestor outright instead of asking a reader to reconstruct it.
throw new Error(
`${label} escapes the tracker workspace: ${absPath}`
+ ` (workspaceRoot=${workspace} canonical=${canonical} rel=${rel})`,
);
}
return absPath;
}
/**
* Resolve one single-file input or output argument.
*
* A relative path that already lands inside the workspace keeps the current
* directory, so launching from the repo is unchanged. A relative path that
* would land outside is read from the workspace root instead. That is the
* split-layout case: the shell is in the checkout, CAREER_OPS_ROOT points at
* the data directory, and `output/cv.html` means the workspace file. An
* absolute path is unchanged. The containment guard below still runs on the
* result, so a `..` segment cannot escape.
*
* @param {string} p
* @returns {string}
*/
function resolvePdfCliPath(p) {
if (isAbsolute(p)) return resolve(p);
const fromCwd = resolve(p);
try {
return assertInsideWorkspace(fromCwd, 'input');
} catch (err) {
if (!/escapes the tracker workspace/.test(err?.message || '')) throw err;
return resolve(workspaceRoot, p);
}
}
// Ensure output directory exists (fresh setup)
mkdirSync(resolve(workspaceRoot, 'output'), { recursive: true });
/**
* Say what normalizeTextForATS() rewrote, so a changed character in a document
* going to an employer is never a silent one.
*
* @param {Record<string, number>} replacements - Count per kind of rewrite.
* @param {(line: string) => void} [print] - Where the line goes.
* @returns {void}
*/
function reportAtsReplacements(replacements, print = console.log) {
const total = Object.values(replacements).reduce((a, b) => a + b, 0);
if (total === 0) return;
const breakdown = Object.entries(replacements).map(([k, v]) => `${k}=${v}`).join(', ');
print(`🧹 ATS normalization: ${total} replacements (${breakdown})`);
}
/**
* Normalize text for ATS compatibility by converting problematic Unicode.
*
* ATS parsers and legacy systems often fail on em-dashes, smart quotes,
* zero-width characters, and non-breaking spaces. These cause mojibake,
* parsing errors, or display issues. See issue #1.
*
* Only touches body text — preserves CSS, JS, tag attributes, and URLs.
* Returns { html, replacements } so the caller can log what was changed.
*
* In body text it also keeps each hyphenated word on one line (#4908).
* Chromium may break a line after a hard hyphen, and no CSS turns that off,
* so "go-to-market" lands in the PDF text layer as "go-" and "to-market" on
* separate lines: some extractors join the halves without the hyphen, others
* keep the line break, and either way the term an ATS matches on is gone. A
* white-space:nowrap span changes the line breaking only, never the text.
* The span covers the whole whitespace-delimited run ("30-40%", "$1-2M"), so
* its edges sit where the text already breaks: the fact gate turns tags into
* spaces, and a span around "30-40" alone would read as "40 %". CJK text has
* no spaces, so a run there is a whole clause: only the hyphenated Latin word
* inside it is wrapped, and CJK characters, which may break anywhere, never
* count as part of one.
*/
const WORD_CHAR = String.raw`[[\p{L}\p{N}]--[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}]]`;
const HYPHENATED_WORD_SOURCE = String.raw`(?<![${WORD_CHAR}\-])${WORD_CHAR}+(?:-${WORD_CHAR}+)+(?![${WORD_CHAR}\-])`;
const HYPHENATED_WORD = new RegExp(HYPHENATED_WORD_SOURCE, 'v');
const HYPHENATED_WORDS = new RegExp(HYPHENATED_WORD_SOURCE, 'gv');
const CJK = /[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}]/u;
// Longer runs (an identifier, a pasted slug) stay breakable rather than risk
// overflowing a narrow column.
const HYPHENATED_RUN_MAX = 40;
function normalizeTextForATS(html) {
const replacements = {};
const bump = (key, n) => { replacements[key] = (replacements[key] || 0) + n; };
const masks = [];
const masked = html.replace(
/<(style|script)\b[^>]*>[\s\S]*?<\/\1>/gi,
(match) => {
const token = `\u0000MASK${masks.length}\u0000`;
masks.push(match);
return token;
}
);
// The nowrap span is markup, so it must not land where text is plain
// (<title>, <textarea>, <option>, the raw-text elements) or foreign
// (<svg>, <math>).
let plainUntil = null;
const text = (t) => (plainUntil ? sanitizeText(t) : keepHyphenatedWordsWhole(sanitizeText(t)));
// A real tag ends at the first `>` outside a quoted attribute value, so a
// `title="a > b"` is not split into a tag and a run of "text". A quote
// opens a value only right after `=` (`title=don't` is unquoted), and
// comments and declarations are matched whole first. Each alternative
// starts differently, so a tag left open never backtracks.
const TAG = /<!--[\s\S]*?-->|<![^>]*>|<\/?[A-Za-z](?:[^>=]|=\s*"[^"]*"|=\s*'[^']*'|=(?!\s*["']))*>/y;
let out = '';
let i = 0;
while (i < masked.length) {
const lt = masked.indexOf('<', i);
if (lt === -1) { out += text(masked.slice(i)); break; }
out += text(masked.slice(i, lt));
TAG.lastIndex = lt;
const gt = TAG.test(masked) ? TAG.lastIndex - 1 : masked.indexOf('>', lt);
if (gt === -1) { out += masked.slice(lt); break; }
const tag = masked.slice(lt, gt + 1);
const name = /^<\/?([A-Za-z][\w:-]*)/.exec(tag)?.[1].toLowerCase();
// An <option> holds only text and its end tag is optional, so any tag
// after it closes it.
if (plainUntil && (plainUntil === 'option' || (tag[1] === '/' && name === plainUntil))) plainUntil = null;
if (!plainUntil && tag[1] !== '/' && !tag.endsWith('/>') && /^(?:title|textarea|option|svg|math|xmp|plaintext|noembed|noframes|iframe|noscript)$/.test(name || '')) {
plainUntil = name;
}
out += tag;
i = gt + 1;
}
const restored = out.replace(/\u0000MASK(\d+)\u0000/g, (_, n) => masks[Number(n)]);
return { html: restored, replacements };
function sanitizeText(text) {
if (!text) return text;
let t = text;
t = t.replace(/\u2014/g, () => { bump('em-dash', 1); return '-'; });
t = t.replace(/\u2013/g, () => { bump('en-dash', 1); return '-'; });
t = t.replace(/[\u201C\u201D\u201E\u201F]/g, () => { bump('smart-double-quote', 1); return '"'; });
t = t.replace(/[\u2018\u2019\u201A\u201B]/g, () => { bump('smart-single-quote', 1); return "'"; });
t = t.replace(/\u2026/g, () => { bump('ellipsis', 1); return '...'; });
t = t.replace(/[\u200B\u200C\u200D\u2060\uFEFF]/g, () => { bump('zero-width', 1); return ''; });
t = t.replace(/\u00A0/g, () => { bump('nbsp', 1); return ' '; });
// Arrows often stripped by PDF text extractors \u2014 replace with ASCII for ATS safety.
// Consume surrounding whitespace to avoid double-spacing in output.
t = t.replace(/\s*\u2192\s*/g, () => { bump('right-arrow', 1); return ' to '; });
t = t.replace(/\s*\u2190\s*/g, () => { bump('left-arrow', 1); return ' from '; });
t = t.replace(/\s*[\u2191\u2193]\s*/g, () => { bump('vert-arrow', 1); return ' '; });
// Middle dot and bullet glyphs garble in some extractors \u2014 replace with pipe.
// Catalan l·l is a letter pair inside a word (intel·ligència), not a separator.
t = t.replace(/\s*\u00B7\s*/g, (m, at, all) => {
if (m === '\u00B7' && /[lL]/.test(all[at - 1] ?? '') && /[lL]/.test(all[at + 1] ?? '')) return m;
bump('middot', 1);
return ' | ';
});
t = t.replace(/\s*\u2022\s*/g, () => { bump('bullet', 1); return ' | '; });
// Currency symbols sometimes stripped by font-subsetted PDFs \u2014 spell out
// the unambiguous ones. \u00A5 is intentionally NOT converted: it maps to both
// Japanese Yen (JPY) and Chinese Yuan (CNY), so any spelled-out code would be
// wrong for half of users \u2014 better to leave the glyph than emit bad data.
t = t.replace(/\u20AC/g, () => { bump('euro', 1); return 'EUR '; });
t = t.replace(/\u00A3/g, () => { bump('pound', 1); return 'GBP '; });
// Markdown bold from tailored CV builders (SUMMARY_TEXT uses **…**).
t = t.replace(/\*\*([^*]+?)\*\*/g, (_, inner) => {
bump('markdown-bold', 1);
return `<strong>${inner}</strong>`;
});
// Markdown italic, as cv.md writes titles (_Game Title_). Both underscores
// must sit on a word boundary, and a path separator is not one, so
// snake_case, a_b and docs/_draft_/cv.md stay literal. Text-only elements
// (<title>, <textarea>, ...) would show <em> as literal text, so skip them.
if (!plainUntil) {
t = t.replace(/(?<![\p{L}\p{N}_/\\])_(?=[^_\s])([^_\n]*?[^_\s])_(?![\p{L}\p{N}_/\\])/gu, (match, inner, at, whole) => {
// Underscores inside an email address are the address. Underscores
// around the whole address enclose its @, and are italics.
const word = /\S*$/.exec(whole.slice(0, at))[0] + /^\S*/.exec(whole.slice(at))[0];
if (word.includes('@') && !inner.includes('@')) return match;
bump('markdown-italic', 1);
return `<em>${inner}</em>`;
});
}
return t;
}
function keepHyphenatedWordsWhole(text) {
if (!text || !text.includes('-')) return text;
const wrap = (s) => {
bump('hyphen-nowrap', 1);
return `<span style="white-space:nowrap">${s}</span>`;
};
// sanitizeText may have inserted <strong> markup, and a masked <script> or
// <style> is a placeholder token; only wrap the text runs between them.
return text.split(/(<[^>]*>|\u0000MASK\d+\u0000)/).map((part, n) => (n % 2 ? part : part.replace(/\S+/g, (run) => {
if (!HYPHENATED_WORD.test(run)) return run;
if (CJK.test(run)) return run.replace(HYPHENATED_WORDS, (w) => (w.length > HYPHENATED_RUN_MAX ? w : wrap(w)));
return run.length > HYPHENATED_RUN_MAX ? run : wrap(run);
}))).join('');
}
}
/**
* Strip diacritics so a heading is recognized regardless of how it was typed.
*
* Rendered Polish headings are not always spelled with their diacritics —
* "Wykształcenie" and "Wyksztalcenie" both occur in already-generated CVs.
* NFD splits most Polish letters into a base plus a combining mark we drop;
* ł (U+0142) has no canonical decomposition, so it needs its own pass.
*
* Only used for alias lookup — display titles keep their diacritics.
*/
function foldDiacritics(text) {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/ł/g, 'l');
}
/**
* Heading spelling -> canonical section key.
*
* Polish (modes/pl) and Chinese (modes/zh-TW, modes/zh) are here because without
* these aliases the rendered non-English titles match nothing derived from the
* English cv.md: validateCvSectionOrder() finds fewer than two comparable
* sections and silently returns, leaving the section-order guard disabled on
* every CV rendered in those modes, and cv.sections resolves no block at all.
*
* Keys are folded on construction so authored diacritics match stripped input.
*/
const SECTION_ALIASES = new Map([
// English — cv.md is the source of truth and is written in English.
['summary', 'summary'],
['professional summary', 'summary'],
['competencies', 'competencies'],
['core competencies', 'competencies'],
['experience', 'experience'],
['work experience', 'experience'],
['professional experience', 'experience'],
['projects', 'projects'],
['selected projects', 'projects'],
['personal projects', 'projects'],
['education', 'education'],
['education & certifications', 'education'],
['certifications', 'certifications'],
['awards', 'awards'],
['honors', 'awards'],
['honours', 'awards'],
['awards & honors', 'awards'],
['awards and honors', 'awards'],
['honors & awards', 'awards'],
['awards & honours', 'awards'],
['skills', 'skills'],
['technical skills', 'skills'],
['interests', 'interests'],
// Polish — the vocabulary documented in modes/pl/README.md, plus the word-order
// variants that turn up in practice (both "Kompetencje kluczowe" and
// "Kluczowe kompetencje" are used for the same section).
['podsumowanie', 'summary'],
['podsumowanie zawodowe', 'summary'],
['profil zawodowy', 'summary'],
['kompetencje', 'competencies'],
['kompetencje kluczowe', 'competencies'],
['kluczowe kompetencje', 'competencies'],
['doświadczenie', 'experience'],
['doświadczenie zawodowe', 'experience'],
['przebieg kariery', 'experience'],
['projekty', 'projects'],
['kluczowe projekty', 'projects'],
['wybrane projekty', 'projects'],
['wykształcenie', 'education'],
['edukacja', 'education'],
['wykształcenie i certyfikaty', 'education'],
['certyfikaty', 'certifications'],
['certyfikaty i szkolenia', 'certifications'],
['szkolenia i certyfikaty', 'certifications'],
['nagrody', 'awards'],
['wyróżnienia', 'awards'],
['nagrody i wyróżnienia', 'awards'],
['umiejętności', 'skills'],
['umiejętności techniczne', 'skills'],
// Spanish — the same failure again: with no entries here, a Spanish CV
// rendered in the documented modes/pdf.md order (Experiencia before Formación)
// was rejected against a cv.md listing Formación first, and --allow-reorder was
// the only way through. The vocabulary is what generated Spanish CVs render
// (Perfil Profesional, Competencias Clave, Experiencia Profesional, Formación,
// Certificaciones, Habilidades) plus each section's everyday synonyms. Keys are
// folded through foldDiacritics below, so accented and unaccented spellings
// both resolve.
['perfil', 'summary'],
['perfil profesional', 'summary'],
['resumen', 'summary'],
['resumen profesional', 'summary'],
['competencias', 'competencies'],
['competencias clave', 'competencies'],
['competencias principales', 'competencies'],
['experiencia', 'experience'],
['experiencia profesional', 'experience'],
['experiencia laboral', 'experience'],
['trayectoria profesional', 'experience'],
['proyectos', 'projects'],
['proyectos destacados', 'projects'],
['proyectos personales', 'projects'],
['proyectos y laboratorios', 'projects'],
['formación', 'education'],
['formación académica', 'education'],
['educación', 'education'],
['estudios', 'education'],
['certificaciones', 'certifications'],
['certificados', 'certifications'],
['premios', 'awards'],
['reconocimientos', 'awards'],
['premios y reconocimientos', 'awards'],
['habilidades', 'skills'],
['habilidades técnicas', 'skills'],
['conocimientos técnicos', 'skills'],
['herramientas e idiomas', 'skills'],
['intereses', 'interests'],
// Chinese — the same failure the Polish block above fixes, for the two Chinese
// markets this repo ships modes for: Traditional (modes/zh-TW) and Simplified
// (modes/zh), rendered through templates/cv-template.zh-minimal.html. Both
// scripts are listed against every key because a CV written in either renders
// through this one alias table. The vocabulary is the repo's own: the
// `sections` payload in tests/zh-minimal-template.test.mjs (个人简介, 核心能力,
// 工作经历, 精选项目, 教育经历, 认证, 技术栈) and the modes' wording
// (e.g. "專業摘要" in modes/zh-TW/oferta.md), plus the everyday synonyms of
// each — the titles have no single canonical spelling because they come from
// a user-supplied `sections` override, not from DEFAULT_SECTION_TITLES.
['專業摘要', 'summary'],
['专业摘要', 'summary'],
['摘要', 'summary'],
['個人簡介', 'summary'],
['个人简介', 'summary'],
['簡介', 'summary'],
['简介', 'summary'],
['核心能力', 'competencies'],
['核心競爭力', 'competencies'],
['核心竞争力', 'competencies'],
['工作經歷', 'experience'],
['工作经历', 'experience'],
['工作經驗', 'experience'],
['工作经验', 'experience'],
['專業經歷', 'experience'],
['专业经历', 'experience'],
['專案', 'projects'],
['项目', 'projects'],
['專案經驗', 'projects'],
['项目经验', 'projects'],
['專案經歷', 'projects'],
['项目经历', 'projects'],
['專案成就', 'projects'],
['项目成就', 'projects'],
['精選專案', 'projects'],
['精选项目', 'projects'],
['學歷', 'education'],
['学历', 'education'],
['教育背景', 'education'],
['教育經歷', 'education'],
['教育经历', 'education'],
['證照', 'certifications'],
['证照', 'certifications'],
['證書', 'certifications'],
['证书', 'certifications'],
['專業證照', 'certifications'],
['专业证书', 'certifications'],
['認證', 'certifications'],
['认证', 'certifications'],
['資格認證', 'certifications'],
['资格认证', 'certifications'],
['獲獎', 'awards'],
['获奖', 'awards'],
['獎項', 'awards'],
['奖项', 'awards'],
['榮譽', 'awards'],
['荣誉', 'awards'],
['技能', 'skills'],
['專長', 'skills'],
['专长', 'skills'],
['技術能力', 'skills'],
['技术能力', 'skills'],
['技術棧', 'skills'],
['技术栈', 'skills'],
['興趣', 'interests'],
['兴趣', 'interests'],
].map(([alias, key]) => [foldDiacritics(alias), key]));
function normalizeSectionTitle(text) {
return text
.replace(/<[^>]+>/g, ' ')
.replace(/\{\{[^}]+\}\}/g, ' ')
.replace(/&/g, '&')
.replace(/[*_`~]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function sectionKey(text) {
const normalized = foldDiacritics(normalizeSectionTitle(text));
return SECTION_ALIASES.get(normalized) ?? normalized;
}
function extractRenderedSectionOrder(html) {
const titleMatches = [...html.matchAll(/class=["'][^"']*\bsection-title\b[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/gi)];
const sections = [];
for (const match of titleMatches) {
const text = normalizeSectionTitle(match[1]);
if (!text) continue;
sections.push({ key: sectionKey(text), title: text });
}
return sections;
}
function extractSourceSectionOrder(markdown) {
const sections = [];
for (const line of markdown.split(/\r?\n/)) {
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
if (!heading) continue;
const text = normalizeSectionTitle(heading[2]);
if (!text) continue;
sections.push({ key: sectionKey(text), title: text });
}
return sections;
}
/**
* The section order `modes/pdf.md` documents under "Section order (optimized
* '6-second recruiter scan')" — Header, Professional Summary, Core
* Competencies, Work Experience, Projects, Education & Certifications,
* Skills — mapped through sectionKey()/SECTION_ALIASES the same way a
* rendered heading or a cv.md heading is. "Education & Certifications" is
* that document's own heading text and already folds to 'education' via the
* alias table (line above: `['education & certifications', 'education']`),
* so no separate normalization is needed for it. `certifications`, `awards`
* and `interests` are not named by modes/pdf.md's list — they are optional
* sections the shipped template (templates/cv-template.html) renders between
* Education and Skills — so they are included here in that template's own
* position to keep a real generated CV (which renders Education and
* Certifications as separate section-title elements) fully comparable
* against this order rather than only against the six pdf.md names it
* explicitly lists. See #3640.
*/
const CANONICAL_TAILORED_ORDER = [
'summary', 'competencies', 'experience', 'projects',
'education', 'certifications', 'awards', 'interests', 'skills',
];
const CANONICAL_TAILORED_POSITIONS = new Map(
CANONICAL_TAILORED_ORDER.map((key, index) => [key, index]),
);
/**
* First index in `comparableSections` whose key sits earlier in `positions`
* than the section right before it — i.e. the first place the rendered order
* violates the relative order `positions` encodes. -1 if no such index exists
* (the rendered order is consistent with `positions`).
*/
function findOrderDivergence(comparableSections, positions) {
for (let i = 1; i < comparableSections.length; i++) {
const previous = comparableSections[i - 1];
const current = comparableSections[i];
if (positions.get(current.key) < positions.get(previous.key)) return i;
}
return -1;
}
/**
* @param {string} html
* @param {string} cvMarkdown
* @param {{ allowReorder?: boolean }} [options] - `allowReorder` downgrades a
* detected divergence from a thrown error to a console warning, for JDs
* where the section order was deliberately tailored (e.g. Projects moved
* ahead of Education for a technical-heavy role) rather than accidentally
* scrambled by an agent. See #1646.
*
* A rendered order is accepted whenever it preserves the relative order of
* EITHER cv.md's own headings OR the canonical modes/pdf.md tailoring order
* above — the documented default workflow always moves Education after
* Experience/Projects, which diverges from a typical cv.md on every
* standard-compliant generation, so cv.md's order alone can't be the only
* accepted target without making `allowReorder` mandatory rather than an
* opt-in for genuine edge cases. See #3640.
*/
export function validateCvSectionOrder(html, cvMarkdown, { allowReorder = false } = {}) {
const rendered = extractRenderedSectionOrder(html);
const source = extractSourceSectionOrder(cvMarkdown);
if (rendered.length < 2 || source.length < 2) return;
const sourcePositions = new Map(source.map((section, index) => [section.key, index]));
const renderedComparable = rendered.filter(section => sourcePositions.has(section.key));
if (renderedComparable.length < 2) return;
if (findOrderDivergence(renderedComparable, sourcePositions) === -1) return;
// Diverges from cv.md — but that alone isn't damning: it's also what every
// CV tailored per modes/pdf.md's documented order looks like. Only treat it
// as a real problem if it ALSO fails to match that canonical order.
const canonicalComparable = rendered.filter(section => CANONICAL_TAILORED_POSITIONS.has(section.key));
if (canonicalComparable.length >= 2
&& findOrderDivergence(canonicalComparable, CANONICAL_TAILORED_POSITIONS) === -1) {
return;
}
const renderedOrder = renderedComparable.map(section => section.title).join(' -> ');
const sourceOrder = source
.filter(section => renderedComparable.some(renderedSection => renderedSection.key === section.key))
.map(section => section.title)
.join(' -> ');
const message = `CV section order diverges from cv.md: rendered ${renderedOrder}; cv.md ${sourceOrder}`;
if (allowReorder) {
console.warn(`⚠️ ${message} (proceeding — --allow-reorder set)`);
return;
}
throw new Error(message);
}
function normalizeForComparison(text) {
if (!text) return '';
return text
.replace(/<[^>]+>/g, ' ')
.replace(/&#?[a-z0-9]+;/gi, ' ')
.replace(/[#*`_~|•·]/g, ' ') // Strip markdown and bullet chars
.replace(/[^\p{L}\p{M}\p{N}\s]/gu, ' ') // Strip punctuation to be safe against ATS transformations
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
/**
* Excerpts of two normalized section bodies around the first place they differ,
* so a locked-section failure shows what changed rather than an entire section.
* A whole Experience section runs to thousands of characters, which the web UI
* would cut off before the changed text. Equal-length windows of `context`
* characters either side of the divergence; an ellipsis marks each cut end.
* @param {string} expected
* @param {string} found
* @param {number} [context]
* @returns {[string, string]}
*/
export function lockedSectionExcerpts(expected, found, context = 80) {
let i = 0;
const max = Math.min(expected.length, found.length);
while (i < max && expected[i] === found[i]) i++;
const start = Math.max(0, i - context);
const cut = (text) => {
const end = Math.min(text.length, i + context);
return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`;
};
return [cut(expected), cut(found)];
}
/**
* Validate that sections marked as locked in config/profile.yml are identical
* (ignoring formatting) between the source cv.md and the rendered HTML.
* @param {string} html
* @param {string} cvMarkdown
* @param {string[]} lockedKeys
*/
export function validateLockedSections(html, cvMarkdown, lockedKeys) {
if (!lockedKeys || lockedKeys.length === 0) return;
const htmlSections = html.matchAll(/<[^>]+class=["'][^"']*\bsection-title\b[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/gi);
const renderedMap = new Map();
let lastKey = null;
let lastIndex = 0;
for (const match of htmlSections) {
if (lastKey) {
renderedMap.set(lastKey, html.substring(lastIndex, match.index));
}
lastKey = sectionKey(match[1]);
lastIndex = match.index + match[0].length;
}
if (lastKey) {
renderedMap.set(lastKey, html.substring(lastIndex));
}
// Each heading's section runs to the next heading of the same or a higher
// level, measured from that heading itself: a cv.md that opens with a
// `# Name` title must not swallow the `##` sections below it. When two
// headings fold to the same key, the shallower (then the first) one wins.
const sourceLines = cvMarkdown.split(/\r?\n/);
const headings = [];
sourceLines.forEach((line, index) => {
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
if (heading) headings.push({ index, level: heading[1].length, key: sectionKey(heading[2]) });
});
const sourceMap = new Map();
const sourceLevels = new Map();
headings.forEach((heading, i) => {
if (sourceLevels.has(heading.key) && sourceLevels.get(heading.key) <= heading.level) return;
const next = headings.slice(i + 1).find(other => other.level <= heading.level);
const end = next ? next.index : sourceLines.length;
const body = sourceLines.slice(heading.index + 1, end);
sourceMap.set(heading.key, body.length ? body.join('\n') + '\n' : '');
sourceLevels.set(heading.key, heading.level);
});
for (const key of lockedKeys) {
if (!sourceMap.has(key)) {
const availableKeys = Array.from(sourceMap.keys()).map(k => `'${k}'`).join(', ');
throw new Error(`Configured locked_section '${key}' not found in cv.md. Available sections: ${availableKeys}`);
}
const sourceNorm = normalizeForComparison(sourceMap.get(key));
const renderedRaw = renderedMap.get(key) || '';
const renderedNorm = normalizeForComparison(renderedRaw);
if (sourceNorm !== renderedNorm) {
const [expectedExcerpt, foundExcerpt] = lockedSectionExcerpts(sourceNorm, renderedNorm);
throw new Error(`Locked section '${key}' was modified during tailoring.\nExpected (normalized): ${expectedExcerpt}\nFound (normalized): ${foundExcerpt}`);
}
}
}
/**
* Every canonical section key the alias table can produce, in template order.
* Derived from the table rather than restated so the two cannot drift.
*/
export const CV_SECTION_KEYS = [...new Set(SECTION_ALIASES.values())];
// The all-caps comments the templates use to delimit sections, matched exactly
// as cv-sections-core.mjs matches them when stripping empty sections.
const SECTION_MARKER_RE = /<!--\s+[A-Z][A-Z ]*-->/g;
const SECTION_TITLE_RE = /class=["'][^"']*\bsection-title\b[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/gi;
const DISPLAY_TITLE_MAX = 60;
/**
* A rendered section title, made safe to quote back in a console warning.
*
* The title comes out of the CV, and a warning goes to a terminal or a log, so
* it is untrusted output rather than untrusted input: C0/C1 controls are
* dropped (an ANSI escape here would let a CV repaint the operator's console or
* forge a line of output), and the result is truncated, since nothing stops a
* heading being thousands of characters long.
*
* @param {string} raw - Inner markup of the title element.
* @returns {string}
*/
function displayTitle(raw) {
const text = raw
.replace(/<[^>]+>/g, ' ')
// C0/C1 controls, then the bidi overrides and isolates. Both let text
// rewrite a terminal line rather than merely occupy it: an escape repaints
// it, and U+202E reverses what follows, so a title can appear to be output
// the tool produced. Stripped rather than escaped — a section heading has
// no legitimate use for either.
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
.replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '')
.replace(/\s+/g, ' ')
.trim();
// Truncate by code point, not UTF-16 unit, so the cap can't end on half a
// surrogate pair; then step back off any trailing combining marks so it
// can't end on a mark whose base character was just removed. Marks *within*
// the cap are left alone — they are ordinary in many scripts, and the length
// bound is what keeps a stacked run from running away.
const points = [...text];
if (points.length <= DISPLAY_TITLE_MAX) return text;
const kept = points.slice(0, DISPLAY_TITLE_MAX - 1);
while (kept.length > 0 && /\p{M}/u.test(kept[kept.length - 1])) kept.pop();
return `${kept.join('')}…`;
}
// Application portals reported to reject longer values (#5629). Advisory only.
const PORTAL_LENGTH_LIMIT = 64;
const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', hellip: '…', ndash: '–', mdash: '—' };
// Plain text of the first <div> whose class list holds the whole token
// `header-title`, in any attribute order. Tags inside it are skipped by a
// character scan and entities are decoded in one pass, so no text is rebuilt
// by repeated pattern replacement.
function headlineText(html) {
for (const m of html.matchAll(/<div\b([^>]*)>/gi)) {
const cls = /(?:^|\s)class\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(m[1]);
const tokens = (cls?.[1] ?? cls?.[2] ?? '').split(/\s+/);
if (!tokens.includes('header-title')) continue;
const start = m.index + m[0].length;
// The matching </div>: count nested <div> opens and closes from here.
let depth = 1;
let end = html.length;
for (const t of html.slice(start).matchAll(/<(\/?)div\b[^>]*>/gi)) {
depth += t[1] ? -1 : 1;
if (depth === 0) { end = start + t.index; break; }
}
const inner = html.slice(start, end);
let text = '';
let inTag = false;
for (const ch of inner) {
if (ch === '<') inTag = true;
else if (ch === '>' && inTag) inTag = false;
else if (!inTag) text += ch;
}
return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (whole, ref) => {
if (ref[0] === '#') {
const code = ref[1].toLowerCase() === 'x' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
}
return NAMED_ENTITIES[ref.toLowerCase()] ?? whole;
}).trim();
}
return '';
}
/**
* Warn, never fail, when the CV headline or the output PDF filename is longer
* than some application portals accept (#5629). Reads the headline from the
* rendered header-title element, so it judges what actually prints.
*
* @param {string} html - The CV HTML about to be rendered.
* @param {string} outputPath - Destination PDF path (only the basename counts).
* @param {number} [limit] - Length above which a warning is printed.
* @returns {string[]} The warning messages printed (empty when none).
*/
export function warnLongPortalFields(html, outputPath, limit = PORTAL_LENGTH_LIMIT) {
const warnings = [];
const plain = headlineText(html);
if (plain) {
const len = [...plain].length;
if (len > limit) {
warnings.push(`CV headline is ${len} characters (limit ${limit}): "${displayTitle(plain)}". Some application portals reject longer values; aim for a job title plus two or three keywords.`);
}
}
const name = basename(outputPath || '');
const nameLen = [...name].length;
if (nameLen > limit) {
warnings.push(`Output filename is ${nameLen} characters (limit ${limit}): "${displayTitle(name)}". Some application portals reject longer file names; use a shorter name.`);
}
for (const w of warnings) console.warn(`⚠️ ${w}`);
return warnings;
}
/**
* The text of the first real section title in html[start, end), skipping any
* that sits in raw text (a heading quoted inside a script names nothing).
*
* @param {string} html
* @param {number} start
* @param {number} end
* @param {[number, number][]} inert
* @returns {string|null}
*/
function findSectionTitle(html, start, end, inert) {
// Matched against the slice, not the whole document: a marker with no title
// under it would otherwise send the engine looking as far as the next title
// anywhere in the file, which is quadratic across many markers. The slices
// partition the document, so this is linear overall.
const within = html.slice(start, end);
SECTION_TITLE_RE.lastIndex = 0;
let match;
while ((match = SECTION_TITLE_RE.exec(within)) !== null) {
if (!isInRanges(inert, start + match.index)) return match[1];
}
return null;
}
// Elements that never have a closing tag, so they must not open a nesting level.
const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr',
]);
// Elements whose content is text, not markup. `<style>.x { content: "</div>" }`
// closes nothing, and counting that as a close tag would end a section early.
// The legacy entries (xmp, plaintext, noembed, noframes) and iframe are here
// because a parser doesn't build elements from their contents either; leaving
// one out means markup-shaped text inside it is mistaken for structure.
const RAW_TEXT_ELEMENTS = new Set([
'script', 'style', 'textarea', 'title',
'xmp', 'plaintext', 'iframe', 'noembed', 'noframes',
]);
/**
* Index of the `>` that ends the tag opening at `from`, or -1.
*
* Quote-aware, because `>` is legal inside an attribute value:
* `<span data-x="> <div>">` is one tag, not a tag plus a stray `<div>`. Reading
* it as the latter miscounts the depth — and since a forged count can be made
* to balance, it would let a section pass validation and then be moved into
* markup it doesn't belong to.
*
* @param {string} html
* @param {number} from - Index of the `<`.
* @returns {number}
*/
function tagEnd(html, from) {
let quote = null;
for (let i = from + 1; i < html.length; i++) {
const char = html[i];
if (quote) {
if (char === quote) quote = null;
} else if (char === '"' || char === "'") {
quote = char;
} else if (char === '>') {
return i;
}
}
return -1;
}
/**
* Ranges of html holding raw text rather than markup — the contents of
* `<script>`, `<style>`, `<textarea>` and `<title>`.
*
* Needed from the start of the document, not from a section: a marker comment
* quoted inside a script string (`const fixture = '<!-- SKILLS -->…'`) is text,
* but a search that begins at that marker has no way to know it. Treating it as
* a section would move part of a script body around the document.
*
* @param {string} html
* @returns {[number, number][]}
*/
function rawTextRanges(html) {
const ranges = [];
let index = 0;
while (index < html.length) {
const open = html.indexOf('<', index);
if (open === -1) break;
if (html.startsWith('<!--', open)) {
const close = html.indexOf('-->', open + 4);
index = close === -1 ? html.length : close + 3;
continue;
}
const close = tagEnd(html, open);
if (close === -1) break;
const inner = html.slice(open + 1, close);
index = close + 1;
if (inner.startsWith('/')) continue;
// No self-closing check: `<script/>` opens raw text, it does not stand alone.
const name = /^([a-zA-Z][\w:-]*)/.exec(inner);
if (!name || !RAW_TEXT_ELEMENTS.has(name[1].toLowerCase())) continue;
// <plaintext> has no end tag at all: everything after it is text to the end
// of the document, so a literal `</plaintext>` closes nothing.
if (name[1].toLowerCase() === 'plaintext') {
ranges.push([index, html.length]);
break;
}
const closeTag = new RegExp(`</${name[1]}\\s*>`, 'gi');
closeTag.lastIndex = index;
const end = closeTag.exec(html);
ranges.push([index, end ? end.index : html.length]);
index = end ? end.index + end[0].length : html.length;
}
return ranges;
}
/**
* Whether `position` falls inside one of the ranges, which are ascending and
* disjoint by construction. Binary search rather than a scan: this is asked
* once per marker and once per candidate title, and a linear answer makes the
* pair quadratic on a document with many of both.
*
* @param {[number, number][]} ranges
* @param {number} position
* @returns {boolean}
*/
function isInRanges(ranges, position) {
let low = 0;
let high = ranges.length - 1;
while (low <= high) {
const mid = (low + high) >> 1;
if (position < ranges[mid][0]) high = mid - 1;
else if (position >= ranges[mid][1]) low = mid + 1;
else return true;
}
return false;
}
/**
* Walk html[from, to) and report what it does to the nesting depth.
*
* A forward scan built on indexOf rather than a regex tokenizer: a pattern like
* `<!--[\s\S]*?-->` restarts its search from every `<!--`, which is quadratic on
* a run of unterminated comments (measured 728ms for 32k of them) and
* exponential when nested inside a repetition. Every step here consumes input.
*
* @param {string} html
* @param {number} from
* @param {number} to
* @returns {{depth: number, exitAt: number}|null} `depth` is the net change at
* `to`; `exitAt` is where a close tag first tried to rise above the starting
* level, or -1. null means the markup could not be read at all (an
* unterminated comment, tag or raw-text element), which callers treat as
* "don't touch this".
*/
function scanNesting(html, from, to) {
let index = from;
let depth = 0;
while (index < to) {
const open = html.indexOf('<', index);
if (open === -1 || open >= to) break;
if (html.startsWith('<!--', open)) {
const close = html.indexOf('-->', open + 4);
if (close === -1 || close + 3 > to) return null;
index = close + 3;
continue;
}
const close = tagEnd(html, open);
if (close === -1 || close >= to) return null;
const inner = html.slice(open + 1, close);
index = close + 1;
if (inner.startsWith('/')) {
if (!/^\/[a-zA-Z][\w:-]*\s*$/.test(inner)) continue; // not a close tag
if (depth === 0) return { depth, exitAt: open };
depth--;
continue;
}
const name = /^([a-zA-Z][\w:-]*)/.exec(inner);
if (!name) continue; // <!DOCTYPE …>, <?xml …>, or a stray '<'
const tag = name[1].toLowerCase();
// Checked before the self-closing branch: trailing-slash syntax has no
// effect on an HTML element, so `<script/>` opens raw text rather than
// standing alone, and treating it as self-closing would read the script
// body as markup.
if (RAW_TEXT_ELEMENTS.has(tag)) {
// <plaintext> never ends, so a slice containing one can't be read as
// markup at all — refuse rather than guess where it stops.
if (tag === 'plaintext') return null;
const closeTag = new RegExp(`</${tag}\\s*>`, 'gi');
closeTag.lastIndex = index;
const end = closeTag.exec(html);
if (!end || end.index + end[0].length > to) return null;
index = end.index + end[0].length; // consumed whole, depth unchanged
continue;
}
if (inner.endsWith('/') || VOID_ELEMENTS.has(tag)) continue;
depth++;
}
return { depth, exitAt: -1 };
}
/**
* True when html[from, to) is only whitespace, comments and closing tags —
* the shape of a document tail (`</div></body></html>`) and of nothing else.
*
* @param {string} html
* @param {number} from
* @returns {boolean}
*/
function isStructuralTail(html, from) {
let index = from;
while (index < html.length) {
const open = html.indexOf('<', index);
if (open === -1) break;
if (html.slice(index, open).trim() !== '') return false; // stray text
if (html.startsWith('<!--', open)) {
const close = html.indexOf('-->', open + 4);
if (close === -1) return false;
index = close + 3;
continue;
}
const close = tagEnd(html, open);
if (close === -1) return false;
if (!/^\/[a-zA-Z][\w:-]*\s*$/.test(html.slice(open + 1, close))) return false;
index = close + 1;
}
return html.slice(index).trim() === '';
}
/**
* Locate each CV section in a rendered document as a [start, end) slice.
*
* A section runs from its marker comment to the next one — the extent comes
* from the markers, never from matching tags. That matters more than it looks:
* when extent is derived by pairing tags, any markup the scanner reads wrongly
* yields a *plausible but wrong* extent, and moving it truncates the CV. Here a
* misread can only make a section fail the balance check below, and a section
* that fails is left alone. Mistakes cost the feature, not the document.
*
* It also means a section is whatever sits between two markers, so a template
* that marks sections with a bare heading —
*
* <!-- EDUCATION -->
* <h2 class="section-title">Education</h2>
* <div class="edu-item">BSc</div>
*
* — moves its heading and body together, rather than being refused.
*
* Each slice must be balanced: as many elements closed as opened, never rising
* above the level it started at. That single check does double duty. It rejects
* a slice that would leave markup unclosed, and it establishes that every
* accepted section sits at the same nesting level as its neighbours, so filling
* one section's place with another can't move it into a container of its own
* (`<div class="education-layout"><!-- EDUCATION -->…</div>` fails, because its
* slice closes a div it never opened).
*
* Identity comes from the rendered section title, resolved through the same
* alias table validateCvSectionOrder() uses, so the reorder and the guard
* cannot disagree about what a section is. A marker with no `.section-title`
* under it (`<!-- HEADER -->`) is not a section — which is also what leaves a
* cover letter untouched.
*
* @param {string} html
* @returns {{
* blocks: {key: string, start: number, end: number, title: string}[],
* ambiguous: Set<string>,
* unrecognized: Map<string, string>,
* }} `blocks` are the movable sections, each carrying its title as rendered (see
* displayTitle). `ambiguous` holds keys whose extent could not be established.
* `unrecognized` maps key -> title for sections the alias table cannot name,
* which are movable but can never match a configured name.
*/
function extractSectionBlocks(html) {
// A marker quoted inside a script or style is text, not a section boundary.
const inert = rawTextRanges(html);
const markers = [...html.matchAll(SECTION_MARKER_RE)]
.filter(marker => !isInRanges(inert, marker.index));
const blocks = [];
const ambiguous = new Set();
for (let i = 0; i < markers.length; i++) {
const start = markers[i].index;
const next = i + 1 < markers.length ? markers[i + 1].index : html.length;
// A title quoted inside a script is text too, so it cannot name a section:
// taking it would label this block from markup that only looks like a
// heading, and apply the user's ordering to the wrong section.
const title = findSectionTitle(html, start, next, inert);
if (!title) continue;
const text = normalizeSectionTitle(title);
if (!text) continue;
const key = sectionKey(text);
const scan = scanNesting(html, start, next);
if (!scan) {
ambiguous.add(key);
continue;
}
// The last section has no following marker to bound it, so it ends where
// its content first tries to close an element it doesn't own — the parent's
// closing tag. Everything after that must be document tail: anything else
// means the scan stopped in the wrong place (raw text it misread, say), and
// trusting it would move a fragment.
const end = scan.exitAt === -1 ? next : scan.exitAt;
const bounded = scan.exitAt === -1 ? scan : scanNesting(html, start, end);
if (!bounded || bounded.depth !== 0 || (end !== next && !isStructuralTail(html, end))) {
ambiguous.add(key);
continue;
}
blocks.push({ key, start, end, title: displayTitle(title) });
}
// Titles the alias table doesn't cover. sectionKey() falls back to the
// normalized title, so such a block is real and movable but can never match a
// configured name — and without this, a CV rendered with titles outside
// SECTION_ALIASES makes cv.sections do nothing at all, silently.
const unrecognized = new Map();
for (const block of blocks) {
if (!CV_SECTION_KEYS.includes(block.key)) unrecognized.set(block.key, block.title);
}
return { blocks, ambiguous, unrecognized };
}
/**
* Render the CV's sections in the order declared by `cv.sections` in
* config/profile.yml (#2533).
*
* The named sections are permuted among the slots they already occupy;
* everything else stays exactly where the template put it. So a list is a local
* statement ("these sections, in this order") rather than a full table of
* contents the user has to keep in sync — a section added by a later release
* lands where upstream put it instead of breaking the config. Moving a section
* past its neighbours is therefore a rotation: name the sections it displaces
* too, in the order they should end up in.
*
* Runs before validateCvSectionOrder(), so the guard still judges what will
* actually be printed — this satisfies the guard rather than bypassing it, which
* is what separates it from --allow-reorder.
*
* No `cv.sections` → the input string is returned unchanged.
*
* @param {string} html
* @param {string[]} order - Canonical section keys, e.g. ['skills', 'education'].
* @returns {string}
*/
export function reorderCvSections(html, order) {
if (!Array.isArray(order) || order.length === 0) return html;
if (order.length < 2) {
// Not an error, but not a no-op worth staying quiet about either: one name
// states no relationship, so there is nothing to apply and the user would
// otherwise be left thinking the setting took effect.
console.warn(`⚠️ config/profile.yml cv.sections lists only "${displayTitle(order[0])}". An order needs at least two sections — one name states no relationship, so nothing was changed.`);
return html;
}
// No early return on an empty block list: a CV whose sections are all
// ambiguous produces none, and silently doing nothing is the failure mode
// this feature exists to remove. The per-name loop below reports first.
const { blocks, ambiguous, unrecognized } = extractSectionBlocks(html);
const byKey = new Map();
for (const block of blocks) {
if (!byKey.has(block.key)) byKey.set(block.key, block);
}
const chosen = [];
const unresolved = [];
const seen = new Set();
for (const name of order) {
// `name` comes from a config file and is quoted straight into console
// output, so it gets the same treatment as a rendered title: displayTitle()
// strips C0/C1 controls and bidi overrides (either can repaint a terminal
// line so a warning appears to say something the tool never printed) and
// bounds the length so one long entry cannot bury the message.
const shown = displayTitle(name);
if (seen.has(name)) {
console.warn(`⚠️ config/profile.yml cv.sections lists "${shown}" more than once — keeping its first position.`);
continue;
}
seen.add(name);
if (!CV_SECTION_KEYS.includes(name)) {
console.warn(`⚠️ config/profile.yml cv.sections lists "${shown}", which is not a CV section — ignoring it. Recognized: ${CV_SECTION_KEYS.join(', ')}.`);
continue;
}
const block = byKey.get(name);
if (block) {
chosen.push(block);
} else if (ambiguous.has(name)) {
// Present, but its markup doesn't stand on its own — it opens or closes
// elements outside itself, so moving it would leave tags unbalanced.
// Reported rather than skipped quietly: the section stays where the
// template put it, and the user would otherwise see a setting that
// silently does nothing.
console.warn(`⚠️ config/profile.yml cv.sections lists "${shown}", but this CV's markup does not enclose that section on its own — leaving it in place, because moving it would leave tags unbalanced.`);
} else {
// Recognized and unambiguous but with no block. Ordinarily that just means
// the section isn't in this CV (an optional one with no entries is
// stripped before the PDF step), which is not worth a warning on its own.
unresolved.push(name);
}
}
// It is worth one when the CV also renders titles the alias table can't name,
// because then "not in this CV" may be a misreading: the section could be
// sitting right there under a heading nothing could identify.
//
// Whether that is what happened is not knowable here — an unresolved name may
// equally be an optional section with no entries. So the report states only
// what is checkable (this name matched nothing; these titles are unidentified)
// and leaves the conclusion to the reader. Deliberately not conditioned on
// whether the document changed: a name that did nothing did nothing, whether
// or not its neighbours moved, and treating "output identical" as the failure
// signal misreads an order that was already satisfied.
const reportUnresolved = () => {
if (unresolved.length === 0 || unrecognized.size === 0) return;
const names = unresolved.map(name => `"${displayTitle(name)}"`).join(', ');
const titles = [...unrecognized.values()].map(title => `"${title}"`).join(', ');
console.warn(`⚠️ config/profile.yml cv.sections names ${names}, which matched no section in this CV. The CV also renders ${unrecognized.size} section title(s) the section-order alias table doesn't recognize (${titles}) — if one of those is the section you meant, its title needs an entry in SECTION_ALIASES in generate-pdf.mjs. Otherwise the name has no effect and can be dropped.`);
};
if (chosen.length < 2) {
reportUnresolved();
return html;
}
// The slots are the named sections' own positions, in document order; the
// sections fill them in the configured order. No separate sibling check is
// needed: every accepted block is balanced, so they all sit at the same
// nesting level by construction.
const slots = [...chosen].sort((a, b) => a.start - b.start);
let out = '';
let cursor = 0;
for (let i = 0; i < slots.length; i++) {
out += html.slice(cursor, slots[i].start);
out += html.slice(chosen[i].start, chosen[i].end);
cursor = slots[i].end;
}
const reordered = out + html.slice(cursor);
reportUnresolved();
return reordered;
}
/**
* Decide whether a rendered CV fits its configured page budget.
*
* This is deliberately separate from rendering: page count comes from the
* PDF Chromium actually produced, and the renderer never changes layout to
* force content under the limit.
*
* @param {number} pageCount - Actual pages in the rendered PDF.
* @param {{ maxPages?: number, strictPages?: boolean, kind?: string }} [options]
* @returns {void}
*/
export function enforcePageBudget(pageCount, { maxPages = 2, strictPages = false, kind = 'cv' } = {}) {
if (!Number.isInteger(pageCount) || pageCount < 1) {
throw new Error(`Could not determine the rendered PDF page count (received ${pageCount}).`);
}
assertValidMaxPages(maxPages);
if (pageCount <= maxPages) return;
const actualLabel = 'pages';
const allowedLabel = maxPages === 1 ? 'page' : 'pages';
const isCover = kind === 'cover';
const message =
`${isCover ? 'Cover letter' : 'CV'} is ${pageCount} ${actualLabel}; the allowed maximum is ${maxPages} ${allowedLabel}. ` +
(isCover
? 'Shorten the opening, the profile intro, or the achievements, then regenerate.'
: 'Trim lower-priority bullets, older roles, secondary projects, or the competencies strip, then regenerate.');
if (strictPages) {
throw new Error(`${message} (strict page budget: ${isCover ? '--strict-pages' : '--strict-pages or cv.strict_pages'})`);
}
console.warn(`⚠️ ${message} Overflow is warning-only by default; use --strict-pages to reject it.`);
}
/**
* Write an over-budget render to a sibling draft next to the output path, so
* the output path only ever holds a PDF inside its page budget (#6325, #6377).
*
* The first free name is taken, so the draft never replaces a file already
* there: a CV can legitimately be named cv.overflow.pdf, or another render may
* be writing it. 'wx' creates the file or fails with EEXIST in one step, so
* two concurrent renders cannot both claim the same name.
*
* @param {string} outputPath - The PDF path the caller asked for.
* @param {Buffer} pdfBuffer - The rendered PDF.
* @returns {string} The draft path that was written.
*/
function writeOverflowDraft(outputPath, pdfBuffer) {
const draftBase = outputPath.replace(/\.pdf$/i, '');
for (let n = 1; ; n++) {
const candidate = n === 1 ? `${draftBase}.overflow.pdf` : `${draftBase}.overflow-${n}.pdf`;
try {
writeFileSync(candidate, pdfBuffer, { flag: 'wx' });
return candidate;
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
}
/**
* Throw unless a page budget is a positive integer. enforcePageBudget() checks
* it after a render; renderInPage() checks it before one, so a bad value never
* reaches the output path.
*
* @param {unknown} maxPages
* @returns {void}
*/
function assertValidMaxPages(maxPages) {
if (!Number.isInteger(maxPages) || maxPages < 1) {
throw new Error(`Invalid page budget "${maxPages}". Use a positive integer.`);
}
}
/**
* Read the page count from the PDF catalog's root /Pages dictionary.
*
* Following the catalog reference keeps page-like text in content streams or
* metadata from being mistaken for an actual page object.
*
* @param {Buffer} pdfBuffer - PDF bytes returned by Chromium.
* @returns {number}
*/
function countRenderedPdfPages(pdfBuffer) {
const pdf = pdfBuffer.toString('latin1');
const objects = new Map();
const objectPattern = /(?:^|[\r\n])(\d+)\s+(\d+)\s+obj\b([\s\S]*?)\bendobj\b/g;
for (const match of pdf.matchAll(objectPattern)) {
const streamIndex = match[3].search(/\bstream(?:\r?\n|\r)/);
const dictionary = streamIndex === -1 ? match[3] : match[3].slice(0, streamIndex);
objects.set(`${match[1]} ${match[2]}`, dictionary);
}
const catalog = [...objects.values()].find((body) => /\/Type\s*\/Catalog\b/.test(body));
const pagesRef = catalog?.match(/\/Pages\s+(\d+)\s+(\d+)\s+R\b/);
const pages = pagesRef ? objects.get(`${pagesRef[1]} ${pagesRef[2]}`) : null;
const count = pages && /\/Type\s*\/Pages\b/.test(pages)
? pages.match(/\/Count\s+(\d+)\b/)
: null;
const pageCount = count ? Number(count[1]) : 0;
if (!Number.isInteger(pageCount) || pageCount < 1) {
throw new Error('Could not determine the rendered PDF page count from its page tree.');
}
return pageCount;
}
/**
* Convert a path to a workspace-relative manifest entry, or blank if it is
* unknown or outside the tracker-owned workspace.
*
* @param {string} pathValue - Absolute or cwd-relative filesystem path.
* @param {string} [rootDir] - Workspace root used as the manifest base.
* @returns {string} Workspace-relative path using forward slashes, or an empty string.
*/
export function workspaceRelativeManifestPath(pathValue, rootDir = currentWorkspaceRoot()) {
if (!pathValue) return '';
const rel = relative(rootDir, resolve(pathValue));
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return '';
return rel.split(sep).join('/');
}
/** @deprecated Use workspaceRelativeManifestPath. */
export function repoRelativeManifestPath(pathValue, rootDir = currentWorkspaceRoot()) {
return workspaceRelativeManifestPath(pathValue, rootDir);
}
function nearestExistingPath(pathValue) {
let candidate = pathValue;
while (true) {
try {
lstatSync(candidate);
return candidate;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
const parent = dirname(candidate);
if (parent === candidate) throw error;
candidate = parent;
}
}
}
/**
* Confirm a PDF destination is lexically and canonically inside its workspace.
* Resolving the nearest existing ancestor catches output directories that are
* symlinks to another location before Chromium writes through them.
*/
export function isWorkspaceOutputPath(pathValue, rootDir = currentWorkspaceRoot()) {
const root = resolve(rootDir);
const candidate = resolve(pathValue);
const lexical = relative(root, candidate);
if (lexical === '' || lexical === '..' || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) {
return false;
}
try {
const canonicalRoot = realpathSync(root);
const canonicalAnchor = realpathSync(nearestExistingPath(candidate));
const canonical = relative(canonicalRoot, canonicalAnchor);
return canonical === '' || (canonical !== '..' && !canonical.startsWith(`..${sep}`) && !isAbsolute(canonical));
} catch {
return false;
}
}
export function injectPrintPageCss(html, format, country) {
// The only place the sheet size is set: page.pdf() below runs with
// preferCSSPageSize, so this @page rule IS the paper. It resolves through
// lib/page-format.mjs so a caller that passes nothing gets the user's
// configured size instead of a fallback private to this file.
const pageSize = PAGE_CSS_SIZE[resolvePageFormat(format, {
profilePath: workspaceProfilePath(),
country,
})];
// Read --page-margin (set by the template's own :root default, and overridden
// by injectThemeStyle's block when style.margin is configured) instead of
// hardcoding PDF_PAGE_MARGIN outright — this @page rule is injected last, so a
// hardcoded value would silently win the cascade and make style.margin
// ineffective (#1837 review). PDF_PAGE_MARGIN is only the fallback for a
// template that never declares --page-margin at all.
const pageStyle = `<style id="career-ops-page-setup">\n@page { size: ${pageSize}; margin: var(--page-margin, ${PDF_PAGE_MARGIN}); }\n</style>`;
if (/<\/head>/i.test(html)) {
// Replacer function, matching the <html> branch just below. `pageStyle`
// interpolates only internal constants today, so this is hardening rather
// than a live bug — but the two branches sat in one function disagreeing
// about it, and the safe spelling is the one that stays correct if a
// configurable value is ever interpolated here (#2596).
return html.replace(/<\/head>/i, () => `${pageStyle}\n</head>`);
}
if (/<html\b[^>]*>/i.test(html)) {
return html.replace(/<html\b[^>]*>/i, match => `${match}\n<head>\n${pageStyle}\n</head>`);
}
return `${pageStyle}\n${html}`;
}
/** The artifact kinds data/pdf-index.tsv keys on, alongside the report number. */
export const ARTIFACT_KINDS = ['cv', 'cover'];
/**
* Decide which artifact a render produces, for the manifest's `kind` column.
*
* Resolved here rather than in the CLI because every path converges on
* renderInPage(): the CLI, the --batch manifest, generate-cover-letter.mjs, and
* the ad-hoc scripts that call the exported renderHtmlToPdf() directly. A
* caller that forgets to declare a cover letter is the normal case, not the
* exceptional one, so the default has to be derived rather than assumed to be
* a CV (#3887).
*
* Precedence, most specific first:
* 1. An explicit kind (the --kind flag, a batch entry's `kind`, opts.kind).
* 2. A `cv-` prefix, the CV convention every generator writes. It wins over
* the cover check so a company whose own name starts with "Cover" keeps
* its CV filed as a CV.
* 3. `cover` as the leading or trailing token: both real conventions, the
* `cover-...` prefix and the `{company}-{role}-cover.pdf` suffix that
* generate-cover-letter.mjs writes.
* 4. 'cv', the overwhelmingly common render.
*
* Anchored to the ends of the name on purpose: an unanchored "cover" would read
* a company or role carrying the word mid-name as a cover letter, and --kind is
* the escape hatch for a name that signals nothing either way.
*
* @param {string|undefined} explicit - A caller-declared kind, if any.
* @param {string} [outputPath] - Destination PDF path, used for inference.
* @returns {{kind: 'cv'|'cover'|null, source: 'declared'|'name'|'default'}}
* `kind` is null when `explicit` is not a recognized kind, so the caller can
* reject it rather than silently rendering something else.
*/
export function resolveArtifactKind(explicit, outputPath) {
if (explicit !== undefined && explicit !== null && String(explicit).trim() !== '') {
const declared = String(explicit).trim().toLowerCase();
return ARTIFACT_KINDS.includes(declared)
? { kind: declared, source: 'declared' }
: { kind: null, source: 'declared' };
}
const name = basename(outputPath || '').toLowerCase().replace(/\.[^.]*$/, '');
if (name.startsWith('cv-')) return { kind: 'cv', source: 'name' };
if (/^cover([-_]|$)/.test(name) || /[-_]cover$/.test(name)) {
return { kind: 'cover', source: 'name' };
}
return { kind: 'cv', source: 'default' };
}
/**
* Infer the report number from the output or input file name when --report is
* missing. The number is a run of exactly three digits bounded by `-` or `_`
* in the file's basename (`cv-ana-042-acme-2026-10-30.pdf` -> `042`); the
* output name is tried first, then the input name. Directories are ignored.
*
* An ATS variant (an `ats` token in the name) is never inferred: modes/pdf.md
* renders it without --report on purpose, because an indexed ATS PDF would
* replace the styled CV the dashboard opens for that report.
*
* @param {string} [outputPath] - Destination PDF path.
* @param {string} [inputPath] - Source HTML path.
* @returns {{reportNum: string, from: string}|null} The number and the path it
* came from, or null when neither name carries one.
*/
export function inferReportNumber(outputPath, inputPath) {
for (const p of [outputPath, inputPath]) {
const name = basename(p || '');
if (!name) continue;
if (/(^|[-_])ats([-_.]|$)/i.test(name)) return null;
const m = name.match(/[-_](\d{3})[-_]/);
if (m) return { reportNum: m[1], from: p };
}
return null;
}
/**
* Merge one generated-PDF row into the manifest's existing data lines.
*
* The manifest holds one row per report number AND artifact kind, so a CV and
* a cover letter for the same application coexist (#3887). Superseding on the
* report number alone meant whichever document was generated last deleted the
* other, and readers that resolve "the PDF for report N" then returned a cover
* letter as the CV.
*
* @param {string[]} existingLines - Current manifest lines, comments included.
* @param {{reportNum: string, pdf: string, html: string, format: string, date: string, kind?: 'cv'|'cover'}} row
* @returns {string[]} Data lines to write, the incoming row last.
*/
export function applyManifestRow(existingLines, row) {
// "008" and "8" are the same report - zero-padded report-link form vs
// unpadded tracker-# form. Normalize so replacement rows match.
const normKey = (s) => (s || '').trim().replace(/^0+(?=\d)/, '');
const incomingKind = row.kind === 'cover' ? 'cover' : 'cv';
const kept = existingLines.filter((line) => {
if (!line.trim() || line.startsWith('#')) return false;
const fields = line.split('\t');
if (fields[1] === row.pdf) return false;
if (!row.reportNum || normKey(fields[0]) !== normKey(row.reportNum)) return true;
// Same report: the incoming row supersedes only its own kind's slot. A
// legacy row carries no kind, so an incoming CV claims it (the manifest
// must not grow a duplicate every time an untagged row's CV is
// regenerated) while an incoming cover letter leaves it alone rather than
// guessing that an unmarked row was a cover.
const existingKind = (fields[5] || '').trim();
return incomingKind === 'cover'
? existingKind !== 'cover'
: existingKind === 'cover';
});
kept.push([row.reportNum || '', row.pdf, row.html, row.format, row.date, incomingKind].join('\t'));
return kept;
}
/**
* Record a generated PDF in data/pdf-index.tsv so tools can map a tracker
* report number to the exact PDF (and its source HTML for regeneration).
*
* Columns: report \t pdf \t html \t format \t date \t kind - paths relative to
* the tracker workspace with forward slashes. One row per PDF path, and one row
* per (report number, kind) so a report's CV and its cover letter coexist while
* a regenerated document still supersedes its own stale entry. kind is appended
* last because find.mjs, outcome.mjs and the web reader all parse this file
* positionally; a reordered header would break them silently. The file is
* gitignored: it references gitignored output/ artifacts and is meaningless on
* another machine.
*/
async function updatePDFManifest(reportNum, pdfPath, htmlPath, format, kind) {
const manifestPath = resolvePdfIndexPath(trackerPath);
const toRel = (p) => relative(workspaceRoot, p).split(sep).join('/');
const relPDF = toRel(pdfPath);
const relHTML = workspaceRelativeManifestPath(htmlPath, workspaceRoot);
const date = localToday();
mkdirSync(dirname(manifestPath), { recursive: true });
// Read, supersede and write under one lock, and publish atomically. Every
// render is its own process (batch-runner.sh --parallel N starts N workers,
// each running generate-pdf.mjs), and this is a read-modify-write of one
// shared file: two renders that read the same manifest each wrote back their
// own row plus the old rows, so whichever wrote last erased the other's row.
return withPipelineLock(manifestPath, () => {
const existing = existsSync(manifestPath)
? readFileSync(manifestPath, 'utf-8').split('\n')
: [];
const lines = applyManifestRow(existing, {
reportNum,
pdf: relPDF,
html: relHTML,
format,
date,
kind,
});
writeFileAtomic(
manifestPath,
'# report\tpdf\thtml\tformat\tdate\tkind - written by generate-pdf.mjs, do not edit\n' +
lines.join('\n') + '\n'
);
return relPDF;
});
}
/**
* Fact gate shared by the single-file and --batch paths.
*
* generate-cover-letter.mjs already blocks on assertFacts before importing
* Playwright, on the reasoning that a failed gate must not leave a misleading
* artifact behind. A tailored CV is the same class of document and carries the
* numbers a reader acts on, but the CV path enforced the gate only as an
* instructed step in the mode prompts — so a programmatic caller (a bridge, a
* script, a batch run) rendered inflated metrics in silence. One function for
* both paths, because the batch run is exactly that caller and for a while it
* was the path with no gate at all.
*
* Callers pass the normalized HTML and the HTML from before ATS normalization.
* They skip the call when --skip-fact-check is set or the workspace has no cv.md.
*
* @param {string} html - Normalized HTML, as it will be printed.
* @param {string} label - Document name for the error and warning lines.
* @param {string} [rawHtml] - HTML before ATS normalization, so an em dash is still visible.
* @param {{strictFactCheck?: boolean}} [options] - `strictFactCheck` makes a `warn` verdict fatal.
* @returns {Promise<void>} Resolves when the document passes, or only warns without strict mode.
* @throws {Error} When the voice check or the fact gate rejects the document, or a warning is fatal under strict mode.
*/
async function runFactGate(html, label, rawHtml = html, { strictFactCheck = false } = {}) {
// Imported lazily, by the callers' guard. A static import is resolved at module
// load whether or not the gate runs, and the page-budget/batch suites copy
// generate-pdf.mjs alone into a temp workspace — a static import of a sibling
// that isn't copied made every one of those suites die with
// ERR_MODULE_NOT_FOUND before reaching the behaviour under test. Those
// fixtures also ship no cv.md, so the gate is never entered there. If the
// module is genuinely missing in a real workspace this throws and the render
// fails, which is the correct direction to fail for a fact gate.
const { assertFacts, printFactWarnings } = await import('./verify-cv-facts.mjs');
const { voiceIssues, voiceTellMessage, voiceDashWarning, candidateOwnWords } = await import('./cv-voice.mjs');
const inputs = factGateInputs();
// The voice check allows wording the CV, voice-dna.md, and the cv-facts
// allow lists already have. article-digest.md backs facts, not phrasing.
const cvPath = resolve(workspaceRoot, 'cv.md');
const source = existsSync(cvPath) ? readFileSync(cvPath, 'utf-8') : '';
const found = voiceIssues(rawHtml, `${source}\n${candidateOwnWords(workspaceRoot)}`);
const voiceError = voiceTellMessage(found, label);
if (voiceError) throw new Error(voiceError);
const dashWarning = voiceDashWarning(found, label);
if (dashWarning) console.warn(`⚠️ ${dashWarning}`);
const factCheck = assertFacts(html, { label, ...inputs });
// Ahead of the verdict, because it qualifies it: with no config the phrase
// lists are empty, so a "passed" below covers metrics and facts only.
if (factCheck.configMissing) {
console.warn('⚠️ No config/cv-facts.json — forbidden/advisory phrase checks did not run.');
}
if (factCheck.verdict === 'warn') {
console.warn(`⚠️ CV fact check warning: ${label}`);
printFactWarnings(factCheck, console.warn);
// Same output either way; only the exit differs. Thrown AFTER the warnings
// print, so a CI log carries the reason the render stopped.
if (strictFactCheck) {
throw new Error(`Fact check failed for ${label} (--strict-fact-check: a warning is fatal)`);
}
} else {
console.log(`✅ Fact check passed: ${label}`);
}
}
/**
* The files the fact gate checks a CV against, from the same workspace this
* render reads cv.md and the profile from.
*
* verify-cv-facts defaults to the DATA root's cv.md, article-digest.md and
* config/cv-facts.json. This module reads cv.md (the probe that decides
* whether the gate runs, and the section-order and locked-section guards) and
* config/profile.yml from workspaceRoot, which is a different directory
* whenever CAREER_OPS_TRACKER points outside the data root (#2471, #3169). With
* the defaults one render read two cv.md files: the guards and the run-or-skip
* decision used the workspace's, the fact check the data root's, so a number
* absent from the CV this render validated could still pass.
*
* @returns {{sourcePaths: string[], configPath: string}}
*/
function factGateInputs() {
return {
sourcePaths: [resolve(workspaceRoot, 'cv.md'), resolve(workspaceRoot, 'article-digest.md')],
configPath: resolve(workspaceRoot, 'config', 'cv-facts.json'),
};
}
/**
* Say that the fact gate did not run, rather than letting a render with no
* "Fact check" line read like one that passed. `--skip-fact-check` is the
* explicit opt-out and stays quiet.
*/
function warnFactGateNotRun() {
console.warn(`⚠️ Fact check did not run: no cv.md at ${resolve(workspaceRoot, 'cv.md')} — this CV was not checked against your sources.`);
}
/**
* CLI entrypoint that reads an HTML file, applies ATS-safe normalization, and
* renders the PDF while preserving report/source metadata for the manifest.
*
* @returns {Promise<{outputPath: string, pageCount: number, size: number}>}
*/
async function generatePDF() {
const args = process.argv.slice(2);
let skipFactCheck = false;
let strictFactCheck = false;
// Parse arguments
// Empty, not 'cv': an omitted --kind must fall through to inference from the
// output filename rather than pinning every render to a CV.
// Supplied and value are separate questions: `--kind=` yields '', which is
// falsy, so a guard reading the value alone waves the flag through.
let kindFlag = '', kindSupplied = false, reportSupplied = false;
// No flag seen yet: null, not a paper size. The default belongs to
// lib/page-format.mjs, which ranks it below the user's config/profile.yml.
let inputPath, outputPath, format = null, country = null, reportNum = '', allowReorder = false, allowNonChronological = false;
let maxPages = 2, maxPagesInput = '2', maxPagesSupplied = false, strictPages = false, batchManifestPath = null;
for (const arg of args) {
if (arg.startsWith('--format=')) {
format = arg.split('=')[1].toLowerCase();
} else if (arg.startsWith('--country=')) {
country = arg.slice('--country='.length);
} else if (arg === '--country') {
// A flag missing its operand, not an absent flag: left alone it falls through
// to the positional arms and the render succeeds on the default sheet.
country = '';
} else if (arg.startsWith('--report=')) {
reportSupplied = true;
reportNum = arg.split('=')[1].trim();
} else if (arg === '--report') {
// Same missing-operand case as --kind below. Swallowed, the render writes
// no manifest row for the report the caller named. The value is CLEARED as
// well as marked supplied: the last occurrence of a flag is the caller's
// final intent, and this one carries no operand, so `--report=7 --report`
// must not keep the 7.
reportSupplied = true;
reportNum = '';
} else if (arg === '--kind') {
// A flag missing its operand, not an absent flag. Left unhandled it falls
// through to the positional arms below and is dropped silently, so the
// render infers a kind the caller never asked for. Marked supplied AND
// cleared, so `--kind=cover --kind` cannot reuse the earlier operand.
kindSupplied = true;
kindFlag = '';
} else if (arg.startsWith('--kind=')) {
kindSupplied = true;
kindFlag = arg.slice('--kind='.length).trim();
} else if (arg.startsWith('--batch=')) {
batchManifestPath = arg.slice('--batch='.length);
} else if (arg === '--max-pages') {
// A flag missing its operand, not an absent flag. Left alone it falls
// through to the positional arms, and cv.max_pages from the profile would
// then set the budget the caller meant to set here (#6325). Marked
// supplied and made invalid, so the check below rejects it by name.
maxPagesSupplied = true;
maxPagesInput = '';
maxPages = NaN;
} else if (arg.startsWith('--max-pages=')) {
maxPagesSupplied = true;
maxPagesInput = arg.slice('--max-pages='.length);
maxPages = Number(maxPagesInput);
} else if (arg === '--allow-reorder') {
allowReorder = true;
} else if (arg === '--allow-nonchronological') {
allowNonChronological = true;
} else if (arg === '--strict-pages') {
strictPages = true;
} else if (arg === '--strict-fact-check') {
strictFactCheck = true;
} else if (arg === '--skip-fact-check') {
skipFactCheck = true;
} else if (!inputPath) {
inputPath = arg;
} else if (!outputPath) {
outputPath = arg;
}
}
// A --kind that was passed and is unusable is a hard error: silently filing a
// cover letter as a CV is the failure the flag exists to prevent. Checked
// before any rendering work starts.
// resolveArtifactKind treats an empty value as "not supplied" and falls back
// to filename inference, which is what the per-entry batch path needs. So an
// empty value cannot be caught by its return, and the CLI has to reject it
// here: the caller typed the flag, and silently inferring instead is the
// substitution the flag exists to prevent.
if (reportSupplied && !reportNum) {
console.error('Invalid --report "". Use a numeric report number, e.g. --report=42.');
process.exit(1);
}
if (kindSupplied && (!kindFlag || !resolveArtifactKind(kindFlag).kind)) {
console.error(`Invalid --kind "${kindFlag}". Use: ${ARTIFACT_KINDS.join(', ')}`);
process.exit(1);
}
if (!Number.isInteger(maxPages) || maxPages < 1) {
console.error(`Invalid --max-pages "${maxPagesInput}". Use a positive integer, e.g. --max-pages=1 or --max-pages=2.`);
process.exit(1);
}
// Contradictory pair, rejected for the same reason --kind is rejected with
// --batch: one flag asks for the gate to run and be fatal, the other asks for
// it not to run at all. Letting either win silently turns a CI gate into a
// no-op the day someone adds --skip-fact-check to a shared invocation.
if (strictFactCheck && skipFactCheck) {
console.error('--strict-fact-check cannot be combined with --skip-fact-check: one runs the fact gate, the other skips it.');
process.exit(1);
}
// #6325: cv.max_pages / cv.strict_pages in config/profile.yml, read once and
// applied per document by pageBudgetFor().
const flagBudget = { maxPages, maxPagesSupplied, strictPages };
const profileBudget = readCvPageBudget(workspaceProfilePath());
// Resolve the format before the batch branch, so a batch and a single render
// inherit the same configured size and a bad --format fails the same way in
// both. An explicit flag is still rejected loudly: falling through to the
// profile would print a typo on whatever size happened to be configured.
if (format !== null) {
const normalized = normalizePageFormat(format);
if (!normalized) {
console.error(`Invalid format "${format}". Use: ${[...PAGE_FORMATS].join(', ')}`);
process.exit(1);
}
format = normalized;
}
// --country only matters under `page_format: auto`. It is validated like
// --format, because a typo that quietly fell back to the default would print a
// German employer's CV on Letter.
if (country !== null) {
const sheet = pageFormatForCountry(country);
if (!sheet) {
console.error(`Invalid country "${country}". Use a two-letter ISO 3166-1 code, e.g. --country=DE.`);
process.exit(1);
}
country = country.trim().toUpperCase();
}
// The pre-resolution flag travels to the batch so an entry's own country can
// still steer `auto`; the resolved size below would already have settled it.
const flagFormat = format;
format = resolvePageFormat(format, { profilePath: workspaceProfilePath(), country });
// Batch mode (#2384): render every document in the manifest through one
// Chromium. Applies the global --max-pages/--strict-pages/--allow-reorder/
// --allow-nonchronological to all entries (a CV entry with no page flag
// takes the profile's cv.max_pages/cv.strict_pages, #6325); each entry supplies its own
// input/output and may override format/reportNum. Takes no positional
// input/output.
if (batchManifestPath) {
// --report keys a single PDF to one tracker row; a batch renders N distinct
// CVs, so one global --report would mislabel them all. Per-entry "reportNum"
// in the manifest is the correct channel — reject the global flag here rather
// than silently ignore it (fails before the batch path returns).
if (reportNum) {
console.error('--report is not valid with --batch. Set "reportNum" per entry in the manifest instead.');
process.exit(1);
}
// Same reasoning for --kind, and the same consequence if it is ignored
// instead. runBatchFromManifest is not given the flag, so a global
// --kind=cover leaves every entry on filename inference; one whose name is
// not cover-shaped is filed as a CV and its row supersedes the report's real
// CV slot in pdf-index.tsv. Per-entry "kind" is the channel that works.
if (kindSupplied) {
console.error('--kind is not valid with --batch. Set "kind" per entry in the manifest instead.');
process.exit(1);
}
return runBatchFromManifest(batchManifestPath, { format, flagFormat, country, flagBudget, profileBudget, allowReorder, allowNonChronological, skipFactCheck, strictFactCheck });
}
if (!inputPath || !outputPath) {
console.error('Usage: node generate-pdf.mjs <input.html> <output.pdf> [--format=letter|a4] [--country=XX] [--report=NNN] [--kind=cv|cover] [--allow-reorder] [--allow-nonchronological] [--max-pages=N] [--strict-pages] [--strict-fact-check] [--skip-fact-check]');
console.error(' or: node generate-pdf.mjs --batch=<manifest.json> [--format=letter|a4] [--country=XX] [--allow-reorder] [--allow-nonchronological] [--max-pages=N] [--strict-pages] [--strict-fact-check] [--skip-fact-check]');
console.error('');
console.error('Batch mode renders every document in the JSON manifest (an array of');
console.error('{input, output, format?, country?, reportNum?, kind?}) through one shared Chromium and writes');
console.error('<manifest>.results.json; it exits non-zero if any document fails.');
console.error('Relative input and output paths in the manifest resolve against the');
console.error("manifest's own folder, not the current directory.");
console.error('');
console.error('This script only converts an already-built HTML file to PDF.');
console.error('The input HTML is produced by the pdf mode: the agent fills cv-template.html');
console.error('with content tailored to the specific job (see modes/pdf.md) — there is no');
console.error('mechanical markdown-to-HTML step by design. Run `/career-ops pdf` in your AI');
console.error('CLI to drive the full flow end to end.');
process.exit(1);
}
// No --report: take the 3-digit report number from the file names, the way
// modes/pdf.md names them (output/cv-{candidate}-{report}-{company}-...).
// Only for a single render; a batch carries reportNum per entry. Printed, so
// a wrong guess is visible; --report always wins.
if (!reportSupplied) {
const inferred = inferReportNumber(outputPath, inputPath);
if (inferred) {
reportNum = inferred.reportNum;
console.warn(`⚠️ No --report given: using report ${inferred.reportNum} from the file name "${basename(inferred.from)}". Pass --report=NNN to set it explicitly.`);
}
}
if (reportNum && !/^\d+$/.test(reportNum)) {
console.error(`Invalid --report "${reportNum}". Use the numeric tracker/report number, e.g. --report=018`);
process.exit(1);
}
inputPath = resolvePdfCliPath(inputPath);
outputPath = resolvePdfCliPath(outputPath);
// Path-traversal guard: keep the PDF write inside the tracker-owned workspace
// so a crafted output argument cannot escape into another user's directory.
// Anchored to the workspace root, not process.cwd(): running the script
// from outside the repo used to falsely refuse in-repo outputs — and, worse,
// would have allowed writes anywhere under an arbitrary cwd.
try {
assertInsideWorkspace(inputPath, 'input');
} catch (err) {
console.error(`Refusing to write the PDF outside the tracker workspace: ${err.message}`);
process.exit(1);
}
if (!isWorkspaceOutputPath(outputPath, workspaceRoot)) {
console.error(`Refusing to write the PDF outside the tracker workspace: ${outputPath}`);
process.exit(1);
}
console.log(`📄 Input: ${inputPath}`);
console.log(`📁 Output: ${outputPath}`);
console.log(`📏 Format: ${format.toUpperCase()}`);
let budget;
try {
budget = pageBudgetFor(resolveArtifactKind(kindFlag, outputPath).kind, flagBudget, profileBudget);
} catch (err) {
console.error(err.message);
process.exit(1);
}
const budgetSource = budget.fromProfile.length > 0 ? ` — ${budget.fromProfile.join(' and ')} from config/profile.yml` : '';
console.log(`📐 Page budget: ${budget.maxPages}${budget.strictPages ? ' (strict)' : ' (warning only)'}${budgetSource}`);
let html = await readFile(inputPath, 'utf-8');
let cvMarkdown = '';
try {
cvMarkdown = await readFile(resolve(workspaceRoot, 'cv.md'), 'utf-8');
} catch (err) {
if (err?.code !== 'ENOENT') throw err;
}
// Drop the optional sections that came in as a bare header — a title with
// nothing under it (#3986). The builders already strip these from the payload
// side, but neither builder is on every path here: the web pdf flow has the
// agent emit finished HTML, which reaches this script with the empty wrappers
// still in place. Deciding from the rendered content covers both, and running
// it before the reorder and the guard means they judge the document that will
// actually be printed. A CV with nothing empty comes through unchanged.
html = stripEmptyRenderedSections(html);
// Apply the user's declared section order (config/profile.yml `cv.sections`)
// before the guard runs, so the guard judges the document that will be
// printed. Anchored to workspaceRoot, NOT __dirname: readStyleTokens() reads
// the same file from workspaceRoot, and cv.md is read from there too, so an
// __dirname anchor made CAREER_OPS_TRACKER split one logical profile across
// two files — style from the workspace, section order from the checkout —
// and validated the workspace's CV against the checkout's declared order.
html = reorderCvSections(html, readCvSectionOrder(workspaceProfilePath()));
validateCvSectionOrder(html, cvMarkdown, { allowReorder });
validateCvExperienceOrder(html, { allowNonChronological });
validateLockedSections(html, cvMarkdown, readLockedSections(workspaceProfilePath(), sectionKey));
const rawHtml = html;
// Normalize text for ATS compatibility (issue #1)
const normalized = normalizeTextForATS(html);
html = normalized.html;
// After normalization, so the length is that of the text that prints.
warnLongPortalFields(html, outputPath);
reportAtsReplacements(normalized.replacements);
// Fact gate, on the normalized HTML: the document that actually prints.
if (!skipFactCheck) {
if (cvMarkdown) await runFactGate(html, basename(inputPath), rawHtml, { strictFactCheck });
else warnFactGateNotRun();
}
return renderHtmlToPdf(html, outputPath, {
format,
baseDir: dirname(inputPath),
reportNum,
kind: kindFlag,
inputPath,
maxPages: budget.maxPages,
strictPages: budget.strictPages,
styleTokens: readStyleTokens(workspaceProfilePath()),
});
}
/**
* The page budget for one document (#6325). A flag always wins. With none, a
* CV takes cv.max_pages / cv.strict_pages from config/profile.yml, so a
* one-page preference holds on every render path: agents, batch workers and
* the web UI all spawn this CLI, and none of them could remember the flag. A
* cover letter keeps the flag-or-default budget, since a cv.* key says nothing
* about it. Exported for tests.
*
* Throws when a CV would read a key that is set but unusable: a typo in a
* stated budget must not quietly print a 2-page CV. A flag that covers the
* key, or a cover letter, never reads it, so neither is stopped.
*
* @param {string|null} kind - From resolveArtifactKind().
* @param {{maxPages: number, maxPagesSupplied: boolean, strictPages: boolean}} flags
* @param {{maxPages?: number, strictPages?: boolean, invalid?: {key: string, message: string}[]}} profile - From readCvPageBudget().
* @returns {{maxPages: number, strictPages: boolean, fromProfile: string[]}}
*/
export function pageBudgetFor(kind, flags, profile = {}) {
const budget = { maxPages: flags.maxPages, strictPages: flags.strictPages, fromProfile: [] };
if (kind !== 'cv') return budget;
const read = (profile.invalid || []).filter((p) =>
(p.key === 'max_pages' && !flags.maxPagesSupplied) || (p.key === 'strict_pages' && !flags.strictPages));
if (read.length > 0) {
throw new Error(
`Invalid page budget in config/profile.yml: ${read.map((p) => p.message).join('; ')}. ` +
'Fix the value, or pass --max-pages / --strict-pages for this render.',
);
}
if (!flags.maxPagesSupplied && profile.maxPages !== undefined) {
budget.maxPages = profile.maxPages;
budget.fromProfile.push('cv.max_pages');
}
if (!flags.strictPages && profile.strictPages === true) {
budget.strictPages = true;
budget.fromProfile.push('cv.strict_pages');
}
return budget;
}
/**
* Drive batch mode (#2384) from a JSON manifest.
*
* The manifest is a JSON array of {input, output, format?, reportNum?}. Each
* entry is validated and its HTML read + ATS-normalized independently: a bad
* entry (missing file, bad format, escaping output path, scrambled section
* order, a metric or fact the sources do not back) is recorded as a failure and
* skipped so it can never sink the rest of
* the batch. Surviving entries render through one shared Chromium (renderBatch).
*
* A results manifest is always written next to the input manifest as
* `<manifest>.results.json` — an ordered array mirroring the input, each item
* `{outputPath, ok, ...}`. The process exits non-zero if ANY entry failed (or
* the browser died), so cron/batch-tailor callers never mistake a partial run
* for success; it exits zero only when every document rendered.
*
* @param {string} manifestPath - Path to the JSON manifest.
* @param {{format: string, flagFormat?: string|null, country?: string|null, flagBudget: {maxPages: number, maxPagesSupplied: boolean, strictPages: boolean}, profileBudget: {maxPages?: number, strictPages?: boolean}, allowReorder: boolean, allowNonChronological: boolean, skipFactCheck: boolean, strictFactCheck?: boolean}} globals
* @returns {Promise<{ok: number, failed: number, results: Array}>}
*/
async function runBatchFromManifest(manifestPath, globals) {
const resolvedManifest = resolve(manifestPath);
const manifestDir = dirname(resolvedManifest);
// Contain the manifest itself before any filesystem access: batch manifests are
// machine-generated workspace-internal artifacts, so a --batch path pointing outside
// the project marks a malformed/tampered invocation. Reject before reading the
// manifest or writing its <manifest>.results.json sibling below.
try {
assertInsideWorkspace(resolvedManifest, 'batch manifest');
} catch (err) {
console.error(`❌ ${err.message}`);
process.exit(1);
}
let raw;
try {
raw = await readFile(resolvedManifest, 'utf-8');
} catch (err) {
console.error(`❌ Cannot read batch manifest: ${resolvedManifest} (${err.message})`);
process.exit(1);
}
let manifest;
try {
manifest = JSON.parse(raw);
} catch (err) {
console.error(`❌ Batch manifest is not valid JSON: ${err.message}`);
process.exit(1);
}
if (!Array.isArray(manifest) || manifest.length === 0) {
console.error('❌ Batch manifest must be a non-empty JSON array of {input, output, format?, reportNum?}.');
process.exit(1);
}
const results = new Array(manifest.length).fill(null);
const entries = [];
// Prepare each entry. Preserve input order in the results by carrying the
// manifest index through renderBatch; prep failures land at their own index.
let cvMarkdown = '';
try {
cvMarkdown = await readFile(resolve(workspaceRoot, 'cv.md'), 'utf-8');
} catch (err) {
if (err?.code !== 'ENOENT') throw err;
}
// Once for the batch, not once per entry.
if (!globals.skipFactCheck && !cvMarkdown) warnFactGateNotRun();
// Read one workspace profile for the batch. The working directory must not
// choose a different theme from the single-document render.
const profilePath = workspaceProfilePath();
const cvSectionOrder = readCvSectionOrder(profilePath);
const styleTokens = readStyleTokens(profilePath);
const lockedKeys = readLockedSections(profilePath, sectionKey);
for (let i = 0; i < manifest.length; i++) {
const spec = manifest[i];
try {
if (!spec || typeof spec.input !== 'string' || typeof spec.output !== 'string') {
throw new Error('each entry needs a string "input" and "output"');
}
if (spec.format && !normalizePageFormat(spec.format)) {
throw new Error(`invalid format "${spec.format}" (use: ${[...PAGE_FORMATS].join(', ')})`);
}
if (spec.country != null && !pageFormatForCountry(spec.country)) {
throw new Error(`invalid country "${spec.country}" (use a two-letter ISO 3166-1 code)`);
}
const entryFormat = resolvePageFormat(spec.format || globals.flagFormat, {
profilePath,
country: spec.country ?? globals.country,
});
// An OMITTED kind falls through to inference from the entry's output name.
// A kind that is present but empty is a different thing: the manifest
// author asked for one, and inferring instead can file a cover letter as a
// CV and replace the report's CV row. Same distinction the --kind flag
// draws above, on the path that actually renders the batch.
const entryKindSupplied = Object.prototype.hasOwnProperty.call(spec, 'kind');
const entryKind = (spec.kind ?? '').toString().trim();
if (entryKindSupplied && (!entryKind || !resolveArtifactKind(entryKind).kind)) {
throw new Error(`invalid kind "${entryKind}" (use: ${ARTIFACT_KINDS.join(', ')})`);
}
// Present but empty is a supplied value, same as kind above: reportNum is
// what keys the manifest row, so accepting '' writes the render nowhere.
const entryReportSupplied = Object.prototype.hasOwnProperty.call(spec, 'reportNum');
const entryReport = (spec.reportNum ?? '').toString().trim();
if (entryReportSupplied && (!entryReport || !/^\d+$/.test(entryReport))) {
throw new Error(`invalid reportNum "${entryReport}" (use the numeric report number)`);
}
// Resolve manifest-supplied input/output relative to the manifest's own
// directory, not process.cwd(), so a manifest renders identically wherever
// the batch is launched from. Absolute paths in the manifest still win
// (resolve() ignores the base when the tail is absolute).
const entryInput = resolve(manifestDir, spec.input);
const entryOutput = resolve(manifestDir, spec.output);
// Path-containment guards (realpath-based): keep the read and write inside
// the tracker workspace even through a symlinked ancestor. A batch
// manifest that escapes the workspace is malformed/tampered and is
// recorded as a per-entry failure rather than read or written.
assertInsideWorkspace(entryInput, 'input');
if (!isWorkspaceOutputPath(entryOutput, workspaceRoot)) {
throw new Error(`output escapes the tracker workspace: ${entryOutput}`);
}
let html = await readFile(entryInput, 'utf-8');
// Same order as the single render: strip the bare-header sections, then
// reorder, so the guard judges the document that will actually be
// printed. Without this the batch path rendered N CVs with cv.sections
// silently inert.
html = stripEmptyRenderedSections(html);
html = reorderCvSections(html, cvSectionOrder);
validateCvSectionOrder(html, cvMarkdown, { allowReorder: globals.allowReorder });
validateCvExperienceOrder(html, { allowNonChronological: globals.allowNonChronological });
validateLockedSections(html, cvMarkdown, lockedKeys);
const rawHtml = html;
html = normalizeTextForATS(html).html;
warnLongPortalFields(html, entryOutput);
// Same gate, same guard and same place as the single render. A blocked
// entry throws into the catch below: it is recorded as failed and never
// reaches Chromium, and the rest of the batch still renders.
if (!globals.skipFactCheck && cvMarkdown) await runFactGate(html, basename(entryInput), rawHtml, { strictFactCheck: globals.strictFactCheck === true });
const entryBudget = pageBudgetFor(resolveArtifactKind(entryKind, entryOutput).kind, globals.flagBudget, globals.profileBudget);
entries.push({
_idx: i,
html,
outputPath: entryOutput,
format: entryFormat,
baseDir: dirname(entryInput),
reportNum: entryReport,
kind: entryKind,
inputPath: entryInput,
maxPages: entryBudget.maxPages,
strictPages: entryBudget.strictPages,
styleTokens,
});
} catch (err) {
console.error(`❌ Skipping batch entry ${i} (${spec?.output ?? '?'}): ${err.message}`);
// Record outputPath with the same manifest-dir-resolved absolute convention
// successful entries use, so consumers see one path shape across ok/failed
// results; null stays null when the entry named no output.
const failedOutput = spec && typeof spec.output === 'string' ? resolve(manifestDir, spec.output) : null;
results[i] = { outputPath: failedOutput, ok: false, error: err.message };
}
}
if (entries.length > 0) {
console.log(`📦 Batch: rendering ${entries.length} document(s) in one Chromium…`);
const rendered = await renderBatch(entries);
rendered.forEach((r, k) => { results[entries[k]._idx] = r; });
}
const ok = results.filter((r) => r && r.ok).length;
const failed = results.length - ok;
const resultsPath = `${resolvedManifest}.results.json`;
try {
// resolvedManifest is already contained, so this sibling is too; assert
// explicitly so the write can never land outside the workspace.
assertInsideWorkspace(resultsPath, 'batch results');
writeFileSync(resultsPath, JSON.stringify(results, null, 2) + '\n');
console.log(`🔗 Batch results: ${resultsPath}`);
} catch (err) {
console.error(`⚠️ Could not write batch results manifest: ${err.message}`);
// A failed results write is itself a batch failure: consumers rely on the
// manifest, so exit non-zero even when every entry rendered fine.
process.exitCode = 1;
}
console.log(`📦 Batch complete: ${ok} ok, ${failed} failed`);
// Signal failure via exitCode, not process.exit(1): the latter can truncate
// buffered stdout (the summary + results-path logs above) before it flushes.
// Returning normally lets in-process callers read the full breakdown and the
// process still exits non-zero once the event loop drains.
if (failed > 0) process.exitCode = 1;
return { ok, failed, results };
}
/**
* Inline url('./fonts/...') references as base64 data: URLs.
*
* Chromium refuses to load file:// subresources from a setContent() page
* (the document stays at about:blank), so fonts referenced by path are
* silently dropped and PDFs fall back to system fonts. data: URLs carry
* no origin restriction, so they load from any page. See #951.
*
* Missing font files keep their original reference and log a warning.
*
* Encoded font data: URLs are memoized across calls (keyed by resolved absolute
* path, not name — two fonts can share a display name but not a path) so a
* batch render does not re-read and re-base64 the same font once per document.
* The cache holds only successful encodings; a missing font is re-checked each
* call so a font added mid-run is picked up. The bytes are deterministic, so a
* cache hit returns the exact same data: URL — output stays byte-identical.
*
* Cache lifetime and invalidation: the cache lives only for the duration of a
* single CLI run (a module-level Map, never persisted). It is keyed by path and
* is deliberately NOT invalidated when a font file changes or is deleted mid-run
* — once a path is cached, that path serves the first-read bytes for the rest of
* the run even if the file is later edited or removed on disk (no mtime/size
* stat check). This is intentional: a batch renders from a fixed set of fonts and
* relies on the stale-on-deletion behavior to stay byte-identical; the next CLI
* run starts with an empty cache and re-reads from disk.
*
* @param {string} html - HTML that may reference url('./fonts/<file>').
* @returns {Promise<string>} HTML with local font references inlined.
*/
const _fontDataUrlCache = new Map();
export async function inlineLocalFonts(html) {
const FONT_REF = /url\(\s*(['"]?)\.\/fonts\/([^'")\s]+)\1\s*\)/g;
const MIME = { woff2: 'font/woff2', woff: 'font/woff', otf: 'font/otf', ttf: 'font/ttf' };
const fontsDir = resolve(__dirname, 'fonts');
const names = [...new Set([...html.matchAll(FONT_REF)].map((m) => m[2]))];
const dataUrls = new Map();
for (const name of names) {
// Containment check: ".." segments and absolute names (./fonts//etc/passwd)
// would otherwise resolve outside fonts/.
const fontPath = resolve(fontsDir, name);
const rel = relative(fontsDir, fontPath);
if (rel.startsWith('..') || isAbsolute(rel)) {
console.warn(`⚠️ Font reference escapes fonts/, keeping original reference: ${name}`);
continue;
}
if (_fontDataUrlCache.has(fontPath)) {
dataUrls.set(name, _fontDataUrlCache.get(fontPath));
continue;
}
try {
const buf = await readFile(fontPath);
const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
const dataUrl = `url('data:${MIME[ext] || 'application/octet-stream'};base64,${buf.toString('base64')}')`;
_fontDataUrlCache.set(fontPath, dataUrl);
dataUrls.set(name, dataUrl);
} catch (err) {
if (err?.code !== 'ENOENT') throw err;
console.warn(`⚠️ Font file not found, keeping original reference: fonts/${name}`);
}
}
return html.replace(FONT_REF, (match, _quote, name) => dataUrls.get(name) || match);
}
// Keep the PDF module's existing launch API while delegating the executable
// choice to the same resolver every other browser-backed feature uses.
export async function launchChromium(options = {}) {
const { chromium } = await import('playwright');
const { projectRoot, ...launchOptions } = options;
return chromium.launch(browserLaunchOptions(launchOptions, { projectRoot }));
}
/**
* Render an HTML string to a PDF file via headless Chromium.
*
* Writes the HTML to a temporary file in the baseDir and loads it via
* page.goto() to give the page a file:// origin. This allows relative
* resources (images, fonts) to load — setContent() runs from about:blank
* and Chromium blocks file:// subresource loads from non-file origins.
*
* Local url('./fonts/...') references are inlined as data: URLs first so
* fonts also survive the ATS normalization pass (which may strip font refs).
*
* @param {string} html - Full HTML document to render.
* @param {string} outputPath - Absolute path to write the PDF to.
* @param {{
* format?: 'a4'|'letter',
* baseDir?: string,
* reportNum?: string,
* inputPath?: string,
* workspaceRoot?: string,
* maxPages?: number,
* strictPages?: boolean,
* launchBrowser?: (options: {headless: boolean}) => Promise<import('playwright').Browser>
* }} [opts]
* @returns {Promise<{outputPath: string, pageCount: number, size: number, draftPath?: string}>}
* `draftPath` is set when the render ran over a warn-only page budget: the
* PDF is at that sibling draft and nothing was written to outputPath.
*/
export async function renderHtmlToPdf(html, outputPath, opts = {}) {
// Checked before the shared painter as well as in renderInPage(): the request
// reaches the painter as JSON, which turns NaN into null, and the painter
// would read that as no budget, paint with the default 2 and replace the PDF.
assertValidMaxPages(opts.maxPages ?? 2);
// A passed launchBrowser is a test double and always paints in this process.
// The shared painter is only for the batch, which opts in with the env var,
// and the painter process itself must not dial its own socket.
if (!opts.launchBrowser
&& process.env.CAREER_OPS_PDF_SHARED === '1'
&& process.env.CAREER_OPS_PDF_SERVER_PROCESS !== '1') {
try {
const { renderViaSharedPdfServer } = await import('./lib/pdf-server.mjs');
const result = await renderViaSharedPdfServer(
html,
outputPath,
{ ...opts, workspaceRoot: opts.workspaceRoot || workspaceRoot },
fileURLToPath(import.meta.url),
getCareerOpsRoot(),
);
// The painter's own output goes nowhere, so repeat it here. A strict
// overflow already came back as an error. A warn-only one comes back
// with the draft it wrote instead of the output path (#6377); the
// warning is repeated on the page count alone, so a painter still
// running from before drafts existed is reported honestly too.
enforcePageBudget(result.pageCount, { maxPages: opts.maxPages ?? 2, strictPages: false, kind: resolveArtifactKind(opts.kind, outputPath).kind ?? 'cv' });
if (result.draftPath) {
console.warn(`⚠️ Overflow draft: ${result.draftPath} (nothing was written to the output path). Trim and rerun to write ${result.outputPath}.`);
console.log(`📊 Pages: ${result.pageCount}`);
return result;
}
console.log(`✅ PDF generated: ${result.outputPath}`);
console.log(`📊 Pages: ${result.pageCount}`);
console.log(`📦 Size: ${(result.size / 1024).toFixed(1)} KB`);
return result;
} catch (err) {
if (err?.code !== 'PDF_SERVER_UNAVAILABLE') throw err;
console.warn(`⚠️ Shared PDF browser unavailable, painting locally: ${err.message}`);
}
}
const launchBrowser = opts.launchBrowser || ((options) => launchChromium({
...options,
projectRoot: opts.workspaceRoot || getCareerOpsRoot(),
}));
let browser = null;
try {
browser = await launchBrowser({ headless: true });
return await renderInPage(browser, html, outputPath, opts);
} finally {
if (browser) {
await browser.close().catch((err) => {
console.warn(`⚠️ Browser cleanup failed: ${err.message}`);
});
}
}
}
/**
* Render one already-normalized HTML document to a PDF on an already-launched
* browser. This is the page-level half of the render — it owns the per-document
* work (theme/print/font injection, temp file, page, PDF, page-budget, manifest)
* but NOT the browser lifecycle. Both the single-CV path (renderHtmlToPdf) and
* the batch path (renderBatch) call this exact function, which is what keeps a
* single-CV render byte-identical whether it runs alone or inside a batch (#2384).
*
* The page and the temp HTML file are always cleaned up in a finally, so a
* throw here (e.g. a strict page-budget overflow) never leaks a page into the
* shared browser — the caller's remaining documents keep their own fresh pages.
*
* @param {import('playwright').Browser} browser - An open browser to render on.
* @param {string} html - Full HTML document to render.
* @param {string} outputPath - Absolute path to write the PDF to.
* @param {{
* format?: 'a4'|'letter',
* baseDir?: string,
* reportNum?: string,
* inputPath?: string,
* maxPages?: number,
* strictPages?: boolean,
* styleTokens?: object
* }} [opts]
* @returns {Promise<{outputPath: string, pageCount: number, size: number}>}
*/
async function renderInPage(browser, html, outputPath, opts = {}) {
const outputRoot = opts.workspaceRoot || workspaceRoot;
const format = resolvePageFormat(opts.format, { profilePath: workspaceProfilePath(opts.workspaceRoot), country: opts.country });
const requestedBaseDir = resolve(opts.baseDir || outputRoot);
// Temporary HTML is an output too: never let an external input path or
// caller-supplied baseDir choose an arbitrary directory. If the requested
// directory is outside the tracker workspace (or escapes through a symlink),
// keep the render workspace-owned while still allowing the input itself to
// be read.
const baseDir = isWorkspaceOutputPath(
resolve(requestedBaseDir, '.career-ops-render-anchor'),
outputRoot,
) ? requestedBaseDir : resolve(outputRoot);
const reportNum = opts.reportNum || '';
const inputPath = opts.inputPath || '';
// Every render path converges here, so the manifest's kind is decided here
// too rather than in each caller (#3887). A null kind means the caller
// declared one this manifest cannot key on: the CLI and the batch loop
// validate before rendering, but a direct renderHtmlToPdf() caller has
// nothing else in front of it, and applyManifestRow() reads null as 'cv' —
// so an unrecognized kind would evict the report's real CV row and hand the
// apply flow the wrong document. Rejected here, before the page renders, so
// a mislabelled artifact never reaches disk either.
const { kind: artifactKind } = resolveArtifactKind(opts.kind, outputPath);
if (!artifactKind) {
throw new Error(`Invalid artifact kind "${opts.kind}". Use: ${ARTIFACT_KINDS.join(', ')}`);
}
// The page budget is checked here for the same reason. The CLI, the batch
// loop and the profile reader validate it, but a direct caller does not, and
// an unusable value would skip the strict-overflow branch below, replace the
// PDF at outputPath, and only then be rejected by enforcePageBudget().
const budget = { maxPages: opts.maxPages ?? 2, strictPages: opts.strictPages ?? false, kind: artifactKind };
assertValidMaxPages(budget.maxPages);
// Reject an escaping destination before creating directories, launching
// Chromium, or writing any renderer temporary files (#2844).
if (!isWorkspaceOutputPath(outputPath, outputRoot)) {
throw new Error(`Refusing to write the PDF outside the tracker workspace: ${outputPath}`);
}
mkdirSync(dirname(outputPath), { recursive: true });
// Inject the user's theme tokens (config/profile.yml `style:`) as CSS custom
// properties so the templates' var(--x, <default>) reads pick them up (#1837).
// No `style:` block → no tokens → byte-identical output. Both the CV path and
// the cover-letter path flow through here, so both are themed from one place.
const styleTokens = opts.styleTokens ?? readStyleTokens(workspaceProfilePath(opts.workspaceRoot));
html = injectThemeStyle(html, styleTokens);
html = injectPrintPageCss(html, format, opts.country);
html = await inlineLocalFonts(html);
// Write HTML to a temp file in baseDir so page.goto() gives a file://
// origin that can load local images, fonts, and other resources.
const tmpHtmlPath = resolve(baseDir, `.career-ops-render-${randomUUID()}.html`);
const { writeFile, unlink } = await import('fs/promises');
await writeFile(tmpHtmlPath, html, 'utf-8');
let page = null;
let context = null;
try {
// A CV is static markup, so the renderer needs neither scripts nor the network.
// Both are denied because this HTML is not fully trusted: it is built from
// cv.md, the job posting and the evaluation report, and postings are untrusted
// input (AGENTS.md). With JS off an injected <script> cannot run; with
// non-local requests aborted an injected <img src="https://…"> cannot beacon
// out. file:/data: subresources still load, which is all a template needs.
//
// newContext(), not newPage(): javaScriptEnabled is a Playwright context
// option with no per-page equivalent, and a fresh context per document also
// keeps each render isolated from its siblings within one shared browser
// (#2384). Guarded so an injected test double that only implements newPage()
// still works.
context = browser.newContext
? await browser.newContext({ javaScriptEnabled: false })
: null;
page = context ? await context.newPage() : await browser.newPage();
if (page.route) {
await page.route('**/*', (route) => {
const url = route.request().url();
return url.startsWith('file:') || url.startsWith('data:')
? route.continue()
: route.abort();
});
}
// Load from file:// so the page origin allows local subresources
await page.goto(pathToFileURL(tmpHtmlPath).href, {
waitUntil: 'load',
});
// Wait for fonts and images to settle
await page.evaluate(() => document.fonts.ready);
// Generate PDF
const pdfBuffer = await page.pdf({
printBackground: true,
margin: {
top: '0',
right: '0',
bottom: '0',
left: '0',
},
preferCSSPageSize: true,
});
// Read the root page-tree count so page-like text in streams is ignored.
const pageCount = countRenderedPdfPages(pdfBuffer);
// An overflow never reaches outputPath, strict or not. A caller that
// restores its own inputs on failure (CV Studio restores the edited HTML)
// would otherwise hold an HTML and a PDF from two revisions, and
// cv.strict_pages makes strict renders reachable from the web UI (#6325).
// In warn-only mode the agent-driven pdf flow trims and re-renders after the
// warning, so a PDF at the final name was one the tool had already flagged,
// and the manifest pointed the dashboard at it (#6377). The render goes to a
// sibling draft for inspection either way; strict then fails, warn-only
// returns before success logs and manifest publication, exit code 0.
if (pageCount > budget.maxPages) {
const hadPrevious = existsSync(outputPath);
const draftPath = writeOverflowDraft(outputPath, pdfBuffer);
if (budget.strictPages) {
try {
enforcePageBudget(pageCount, budget);
} catch (err) {
err.message += ` Rejected draft: ${draftPath} (${hadPrevious ? 'the existing PDF was left unchanged' : 'nothing was written to the output path'}).`;
throw err;
}
}
enforcePageBudget(pageCount, budget);
// One line a caller can read back: the web layer treats this render as
// not delivered, since it only sees the exit code and stderr.
console.warn(`⚠️ Overflow draft: ${draftPath} (${hadPrevious ? 'the existing PDF at the output path was left unchanged' : 'nothing was written to the output path'}). Trim and rerun to write ${outputPath}.`);
console.log(`📊 Pages: ${pageCount}`);
return { outputPath, draftPath, pageCount, size: pdfBuffer.length };
}
// Write PDF only after rendering has completed. Renderer cleanup still runs
// if an injected browser fails before producing a buffer. The atomic write
// keeps the previous PDF intact if this write fails partway.
writeFileAtomic(outputPath, pdfBuffer);
console.log(`✅ PDF generated: ${outputPath}`);
console.log(`📊 Pages: ${pageCount}`);
console.log(`📦 Size: ${(pdfBuffer.length / 1024).toFixed(1)} KB`);
try {
await updatePDFManifest(reportNum, outputPath, inputPath, format, artifactKind);
console.log(`🔗 Manifest: data/pdf-index.tsv updated${reportNum ? ` (report ${reportNum})` : ' (no --report given)'}`);
} catch (err) {
// The PDF itself succeeded — never fail the run over manifest bookkeeping.
console.error(`⚠️ Manifest update failed: ${err.message}`);
}
return { outputPath, pageCount, size: pdfBuffer.length };
} finally {
// Close the page so a batch does not accumulate pages into the shared
// browser (leak → OOM). Optional-chained: the single path's browser.close()
// already reclaims the page, and minimal test doubles may omit close().
if (page && typeof page.close === 'function') {
await page.close().catch((err) => {
console.warn(`⚠️ Page cleanup failed: ${err.message}`);
});
}
// Close the per-document context too, so the JS-disabled context created
// above does not accumulate in the shared browser across a batch (#2384).
if (context && typeof context.close === 'function') {
await context.close().catch((err) => {
console.warn(`⚠️ Context cleanup failed: ${err.message}`);
});
}
// Clean up temp file
await unlink(tmpHtmlPath).catch((err) => {
if (err?.code !== 'ENOENT') {
console.warn(`⚠️ Temporary HTML cleanup failed: ${err.message}`);
}
});
}
}
/**
* Render many already-normalized HTML documents through ONE shared Chromium.
*
* Maintainer conditions (#2384): the browser is launched once via the same
* opts.launchBrowser seam the single path uses, and closed in a finally at the
* batch boundary — it never outlives the batch and is torn down even if a
* document throws. Each entry renders on its own page (renderInPage), and a
* failing entry is captured as `{ok:false, error}` without stopping the rest.
*
* The browser is owned here: renderBatch closes whatever launchBrowser returns.
* launchBrowser is a launch *factory*, not a caller-owned handle, so this does
* not break test injection — the stub returns a fresh browser to be closed.
*
* Rendering is intentionally serial: a shared module-level font cache and one
* long-lived browser make parallel rendering a determinism/memory hazard that
* this change does not take on.
*
* @param {Array<{html: string, outputPath: string, format?: string, baseDir?: string,
* reportNum?: string, inputPath?: string, maxPages?: number, strictPages?: boolean}>} entries
* @param {{launchBrowser?: (options: {headless: boolean}) => Promise<import('playwright').Browser>}} [opts]
* @returns {Promise<Array<{outputPath: string, ok: boolean, pageCount?: number, size?: number, draftPath?: string, error?: string}>>}
* `draftPath` is set on an entry that rendered over a warn-only page budget:
* it is ok, but the PDF is at the draft, and nothing was written to outputPath.
*/
export async function renderBatch(entries, opts = {}) {
const launchBrowser = opts.launchBrowser || launchChromium;
const results = [];
let browser = null;
try {
try {
browser = await launchBrowser({ headless: true });
} catch (err) {
// A shared-browser launch failure is fatal to every document, but it must
// NOT escape as an uncaught throw: record a failed result for each entry so
// runBatchFromManifest still writes a complete results manifest and takes
// the existing non-zero exit path, instead of crashing before either (#2384).
console.error(`❌ Batch browser launch failed: ${err.message}`);
for (const entry of entries) {
results.push({ outputPath: entry.outputPath, ok: false, error: `browser launch failed: ${err.message}` });
}
return results;
}
for (const entry of entries) {
try {
const r = await renderInPage(browser, entry.html, entry.outputPath, entry);
// A warn-only overflow is not a failure, but its PDF is the draft, not
// outputPath, and the results manifest says so (#6377).
results.push({ outputPath: entry.outputPath, ok: true, pageCount: r.pageCount, size: r.size, ...(r.draftPath ? { draftPath: r.draftPath } : {}) });
} catch (err) {
console.error(`❌ Batch entry failed (${entry.outputPath}): ${err.message}`);
results.push({ outputPath: entry.outputPath, ok: false, error: err.message });
}
}
return results;
} finally {
if (browser) {
await browser.close().catch((err) => {
console.warn(`⚠️ Browser cleanup failed: ${err.message}`);
});
}
}
}
async function runPdfServer() {
const { startPdfServer } = await import('./lib/pdf-server.mjs');
const server = await startPdfServer({
rootDir: __dirname,
launchBrowser: (options) => launchChromium({
...options,
projectRoot: process.env.CAREER_OPS_PDF_BROWSER_ROOT || getCareerOpsRoot(),
}),
render: (browser, html, outputPath, opts) => renderInPage(browser, html, outputPath, opts),
});
if (server.alreadyRunning) return;
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
server.close().then(() => process.exit(0), () => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
await server.closed;
}
const isMain = isMainModule(import.meta.url);
if (isMain && process.argv[2] === '--pdf-server') {
runPdfServer().then(() => process.exit(0), (err) => {
console.error(`❌ PDF browser failed: ${err.message}`);
process.exit(1);
});
} else if (isMain) {
generatePDF().catch((err) => {
console.error('❌ PDF generation failed:', err.message);
process.exit(1);
});
}
export { normalizeTextForATS, reportAtsReplacements, sectionKey };