#!/usr/bin/env node /** * generate-pdf.mjs — HTML → PDF via Playwright * * Usage: * node career-ops/generate-pdf.mjs [--format=letter|a4] [--report=NNN] [--allow-reorder] * * --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. * * Requires: @playwright/test (or playwright) installed. * Uses Chromium headless to render the HTML and produce a clean, ATS-parseable PDF. */ import { chromium } from 'playwright'; import { resolve, dirname, relative, sep, isAbsolute } from 'path'; import { readFile } from 'fs/promises'; import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'; import { fileURLToPath, pathToFileURL } from 'url'; import { randomUUID } from 'node:crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PDF_PAGE_MARGIN = '0.6in'; // Ensure output directory exists (fresh setup) mkdirSync(resolve(__dirname, 'output'), { recursive: true }); /** * 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. */ 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; } ); let out = ''; let i = 0; while (i < masked.length) { const lt = masked.indexOf('<', i); if (lt === -1) { out += sanitizeText(masked.slice(i)); break; } out += sanitizeText(masked.slice(i, lt)); const gt = masked.indexOf('>', lt); if (gt === -1) { out += masked.slice(lt); break; } out += masked.slice(lt, gt + 1); 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. t = t.replace(/\s*\u00B7\s*/g, () => { 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 `${inner}`; }); return t; } } const SECTION_ALIASES = new Map([ ['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'], ['skills', 'skills'], ['technical skills', 'skills'], ]); function normalizeSectionTitle(text) { return text .replace(/<[^>]+>/g, ' ') .replace(/\{\{[^}]+\}\}/g, ' ') .replace(/&/g, '&') .replace(/[*_`~]/g, '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function sectionKey(text) { const normalized = 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; } /** * @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. */ 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; for (let i = 1; i < renderedComparable.length; i++) { const previous = renderedComparable[i - 1]; const current = renderedComparable[i]; if (sourcePositions.get(current.key) < sourcePositions.get(previous.key)) { 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); } } } /** * Convert a path to a repo-relative manifest entry, or blank if it is unknown * or outside the career-ops repository. * * @param {string} pathValue - Absolute or cwd-relative filesystem path. * @returns {string} Repo-relative path using forward slashes, or an empty string. */ export function repoRelativeManifestPath(pathValue) { if (!pathValue) return ''; const rel = relative(__dirname, resolve(pathValue)); if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return ''; return rel.split(sep).join('/'); } export function injectPrintPageCss(html, format = 'a4') { const normalizedFormat = String(format || 'a4').toLowerCase(); const pageSize = normalizedFormat === 'letter' ? 'Letter' : 'A4'; const pageStyle = ``; if (/<\/head>/i.test(html)) { return html.replace(/<\/head>/i, `${pageStyle}\n`); } if (/]*>/i.test(html)) { return html.replace(/]*>/i, match => `${match}\n\n${pageStyle}\n`); } return `${pageStyle}\n${html}`; } /** * 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 — paths relative to the * career-ops root with forward slashes. One row per PDF path; when a report * number is given, older rows for that report are dropped too (regenerated * CVs supersede stale entries). The file is gitignored: it references * gitignored output/ artifacts and is meaningless on another machine. */ function updatePDFManifest(reportNum, pdfPath, htmlPath, format) { const manifestPath = resolve(__dirname, 'data', 'pdf-index.tsv'); const toRel = (p) => relative(__dirname, p).split(sep).join('/'); const relPDF = toRel(pdfPath); const relHTML = repoRelativeManifestPath(htmlPath); const date = new Date().toISOString().slice(0, 10); // "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)/, ''); let lines = []; if (existsSync(manifestPath)) { lines = readFileSync(manifestPath, 'utf-8').split('\n').filter((line) => { if (!line.trim() || line.startsWith('#')) return false; const fields = line.split('\t'); if (fields[1] === relPDF) return false; if (reportNum && normKey(fields[0]) === normKey(reportNum)) return false; return true; }); } lines.push([reportNum || '', relPDF, relHTML, format, date].join('\t')); mkdirSync(dirname(manifestPath), { recursive: true }); writeFileSync( manifestPath, '# report\tpdf\thtml\tformat\tdate — written by generate-pdf.mjs, do not edit\n' + lines.join('\n') + '\n' ); return relPDF; } /** * 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); // Parse arguments let inputPath, outputPath, format = 'a4', reportNum = '', allowReorder = false; for (const arg of args) { if (arg.startsWith('--format=')) { format = arg.split('=')[1].toLowerCase(); } else if (arg.startsWith('--report=')) { reportNum = arg.split('=')[1].trim(); } else if (arg === '--allow-reorder') { allowReorder = true; } else if (!inputPath) { inputPath = arg; } else if (!outputPath) { outputPath = arg; } } if (!inputPath || !outputPath) { console.error('Usage: node generate-pdf.mjs [--format=letter|a4] [--report=NNN] [--allow-reorder]'); 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); } if (reportNum && !/^\d+$/.test(reportNum)) { console.error(`Invalid --report "${reportNum}". Use the numeric tracker/report number, e.g. --report=018`); process.exit(1); } inputPath = resolve(inputPath); outputPath = resolve(outputPath); // Path-traversal guard: keep the PDF write inside the project directory so a // crafted output argument (e.g. "../../etc/cron.d/x") can't escape the repo. // Anchored to the repo root (__dirname), 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. const relOut = relative(__dirname, outputPath); if (relOut === '' || relOut.startsWith('..') || isAbsolute(relOut)) { console.error(`Refusing to write the PDF outside the project directory: ${outputPath}`); process.exit(1); } // Validate format const validFormats = ['a4', 'letter']; if (!validFormats.includes(format)) { console.error(`Invalid format "${format}". Use: ${validFormats.join(', ')}`); process.exit(1); } console.log(`📄 Input: ${inputPath}`); console.log(`📁 Output: ${outputPath}`); console.log(`📏 Format: ${format.toUpperCase()}`); let html = await readFile(inputPath, 'utf-8'); let cvMarkdown = ''; try { cvMarkdown = await readFile(resolve(__dirname, 'cv.md'), 'utf-8'); } catch (err) { if (err?.code !== 'ENOENT') throw err; } validateCvSectionOrder(html, cvMarkdown, { allowReorder }); // Normalize text for ATS compatibility (issue #1) const normalized = normalizeTextForATS(html); html = normalized.html; const totalReplacements = Object.values(normalized.replacements).reduce((a, b) => a + b, 0); if (totalReplacements > 0) { const breakdown = Object.entries(normalized.replacements).map(([k, v]) => `${k}=${v}`).join(', '); console.log(`🧹 ATS normalization: ${totalReplacements} replacements (${breakdown})`); } return renderHtmlToPdf(html, outputPath, { format, baseDir: dirname(inputPath), reportNum, inputPath }); } /** * 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. * * @param {string} html - HTML that may reference url('./fonts/'). * @returns {Promise} HTML with local font references inlined. */ 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; } try { const buf = await readFile(fontPath); const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase(); dataUrls.set(name, `url('data:${MIME[ext] || 'application/octet-stream'};base64,${buf.toString('base64')}')`); } 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); } /** * 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, * launchBrowser?: (options: {headless: boolean}) => Promise * }} [opts] * @returns {Promise<{outputPath: string, pageCount: number, size: number}>} */ export async function renderHtmlToPdf(html, outputPath, opts = {}) { const format = opts.format || 'a4'; const baseDir = opts.baseDir || process.cwd(); const reportNum = opts.reportNum || ''; const inputPath = opts.inputPath || ''; mkdirSync(dirname(outputPath), { recursive: true }); html = injectPrintPageCss(html, format); 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'); const launchBrowser = opts.launchBrowser || ((options) => chromium.launch(options)); let browser = null; try { browser = await launchBrowser({ headless: true }); const page = await browser.newPage(); // 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, }); // Write PDF await writeFile(outputPath, pdfBuffer); // Count pages (approximate from PDF structure) const pdfString = pdfBuffer.toString('latin1'); const pageCount = (pdfString.match(/\/Type\s*\/Page[^s]/g) || []).length; console.log(`✅ PDF generated: ${outputPath}`); console.log(`📊 Pages: ${pageCount}`); console.log(`📦 Size: ${(pdfBuffer.length / 1024).toFixed(1)} KB`); try { updatePDFManifest(reportNum, outputPath, inputPath, format); 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 { if (browser) { await browser.close().catch((err) => { console.warn(`⚠️ Browser 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}`); } }); } } const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); if (isMain) { generatePDF().catch((err) => { console.error('❌ PDF generation failed:', err.message); process.exit(1); }); } export { normalizeTextForATS };