init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,466 @@
|
||||
#!/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] [--report=NNN]
|
||||
*
|
||||
* --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.
|
||||
*
|
||||
* 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 '; });
|
||||
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;
|
||||
}
|
||||
|
||||
function validateCvSectionOrder(html, cvMarkdown) {
|
||||
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(' -> ');
|
||||
throw new Error(`CV section order diverges from cv.md: rendered ${renderedOrder}; cv.md ${sourceOrder}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `<style id="career-ops-page-setup">\n@page { size: ${pageSize}; margin: ${PDF_PAGE_MARGIN}; }\n</style>`;
|
||||
|
||||
if (/<\/head>/i.test(html)) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '';
|
||||
|
||||
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 (!inputPath) {
|
||||
inputPath = arg;
|
||||
} else if (!outputPath) {
|
||||
outputPath = arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error('Usage: node generate-pdf.mjs <input.html> <output.pdf> [--format=letter|a4] [--report=NNN]');
|
||||
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);
|
||||
|
||||
// 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/<file>').
|
||||
* @returns {Promise<string>} 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}} [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 browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
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 {
|
||||
await browser.close();
|
||||
// Clean up temp file
|
||||
await unlink(tmpHtmlPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
Reference in new issue
Block a user