From 14e65ccbc0490f85d863760ae4afd73c476d78bf Mon Sep 17 00:00:00 2001 From: Alex On Date: Thu, 11 Jun 2026 20:28:36 +0200 Subject: [PATCH 1/3] fix(generate-pdf): build file:// URLs with pathToFileURL for Windows The main-module guard compared import.meta.url against a hand-built `file://${resolve(process.argv[1])}` string. On Windows, resolve() returns backslash paths (C:\... or \\wsl.localhost\... UNC), so the comparison never matched: the CLI entry point never ran and the script exited 0 silently without generating anything. Use pathToFileURL() like the other CLI scripts already do (scan.mjs, scan-ats-full.mjs, update-system.mjs, followup-cadence.mjs), and fix the two other hand-built file:// URLs in the same file (fonts injection and the Playwright baseURL), which produced invalid backslash URLs on Windows. Fixes #948 Co-Authored-By: Claude Fable 5 --- generate-pdf.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/generate-pdf.mjs b/generate-pdf.mjs index 1781a278f0..23c1e3c9d3 100644 --- a/generate-pdf.mjs +++ b/generate-pdf.mjs @@ -14,7 +14,7 @@ import { chromium } from 'playwright'; import { resolve, dirname } from 'path'; import { readFile } from 'fs/promises'; import { mkdirSync } from 'fs'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -221,7 +221,7 @@ async function generatePDF() { const fontsDir = resolve(__dirname, 'fonts'); html = html.replace( /url\(['"]?\.\/fonts\//g, - `url('file://${fontsDir}/` + `url('${pathToFileURL(fontsDir).href}/` ); // Close any unclosed quotes from the replacement (handles all font formats) html = html.replace( @@ -262,7 +262,7 @@ export async function renderHtmlToPdf(html, outputPath, opts = {}) { // Set content with file base URL for any relative resources await page.setContent(html, { waitUntil: 'load', - baseURL: `file://${baseDir}/`, + baseURL: `${pathToFileURL(baseDir).href}/`, }); // Wait for fonts to load @@ -299,7 +299,7 @@ export async function renderHtmlToPdf(html, outputPath, opts = {}) { } } -const isMain = process.argv[1] && import.meta.url === `file://${resolve(process.argv[1])}`; +const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; if (isMain) { generatePDF().catch((err) => { console.error('❌ PDF generation failed:', err.message); From 32fe58bcc3cb8433955a8536d61d37a13be43830 Mon Sep 17 00:00:00 2001 From: Alex On Date: Thu, 11 Jun 2026 20:56:07 +0200 Subject: [PATCH 2/3] fix(generate-pdf): inline local fonts as data: URLs so they actually embed renderHtmlToPdf() loads documents with page.setContent(), which leaves the page at about:blank. Chromium blocks file:// subresource loads from non-file pages ('Not allowed to load local resource'), so the file:// font URLs injected by generatePDF() never loaded and every PDF silently fell back to system fonts -- on all platforms, since the tool shipped. Replace the file:// URL injection with inlineLocalFonts(), which inlines url('./fonts/...') references as base64 data: URLs inside renderHtmlToPdf(). data: URLs carry no origin restriction, and doing it in the renderer also covers generate-cover-letter.mjs, which calls renderHtmlToPdf() directly without any font handling. Verified on Windows: PDFs now embed Space Grotesk + DM Sans subsets (Type3 with ToUnicode maps; extracted text is byte-identical to the fallback PDFs, so ATS parseability is unchanged). Fixes #951 Co-Authored-By: Claude Fable 5 --- generate-pdf.mjs | 50 +++++++++++++++++++++++++++++++++++------------- test-all.mjs | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/generate-pdf.mjs b/generate-pdf.mjs index 23c1e3c9d3..b962d217cd 100644 --- a/generate-pdf.mjs +++ b/generate-pdf.mjs @@ -207,7 +207,6 @@ async function generatePDF() { console.log(`📁 Output: ${outputPath}`); console.log(`📏 Format: ${format.toUpperCase()}`); - // Read HTML to inject font paths as absolute file:// URLs let html = await readFile(inputPath, 'utf-8'); let cvMarkdown = ''; try { @@ -217,18 +216,6 @@ async function generatePDF() { } validateCvSectionOrder(html, cvMarkdown); - // Resolve font paths relative to career-ops/fonts/ - const fontsDir = resolve(__dirname, 'fonts'); - html = html.replace( - /url\(['"]?\.\/fonts\//g, - `url('${pathToFileURL(fontsDir).href}/` - ); - // Close any unclosed quotes from the replacement (handles all font formats) - html = html.replace( - /file:\/\/([^'")]+)\.(woff2?|ttf|otf)['"]?\)/g, - `file://$1.$2')` - ); - // Normalize text for ATS compatibility (issue #1) const normalized = normalizeTextForATS(html); html = normalized.html; @@ -241,9 +228,44 @@ async function generatePDF() { return renderHtmlToPdf(html, outputPath, { format, baseDir: dirname(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 names = [...new Set([...html.matchAll(FONT_REF)].map((m) => m[2]))]; + const dataUrls = new Map(); + for (const name of names) { + if (name.includes('..')) continue; + try { + const buf = await readFile(resolve(__dirname, 'fonts', name)); + 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. * + * Local url('./fonts/...') references are inlined as data: URLs first so + * fonts render regardless of page origin (see inlineLocalFonts). + * * @param {string} html - Full HTML document to render. * @param {string} outputPath - Absolute path to write the PDF to. * @param {{format?: 'a4'|'letter', baseDir?: string}} [opts] @@ -255,6 +277,8 @@ export async function renderHtmlToPdf(html, outputPath, opts = {}) { mkdirSync(dirname(outputPath), { recursive: true }); + html = await inlineLocalFonts(html); + const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); diff --git a/test-all.mjs b/test-all.mjs index 1665298f81..7d75daabbc 100644 --- a/test-all.mjs +++ b/test-all.mjs @@ -2200,6 +2200,46 @@ try { fail(`update-system SEMVER_RE test crashed: ${e.message}`); } +// ── 17. FONT INLINING (#951) ──────────────────────────────────── + +console.log('\n17. Font inlining (data: URLs, #951)'); + +try { + // Importing must not trigger the CLI (the import.meta.url guard); it + // exposes inlineLocalFonts, which renderHtmlToPdf runs before setContent. + const { inlineLocalFonts } = await import(pathToFileURL(join(ROOT, 'generate-pdf.mjs')).href); + + // Chromium blocks file:// subresources from setContent() pages (the page + // stays at about:blank), so ./fonts refs must become data: URLs (#951). + const fontFile = readdirSync(join(ROOT, 'fonts')).find(f => f.endsWith('.woff2')); + const inlined = await inlineLocalFonts( + `` + ); + if (inlined.includes('data:font/woff2;base64,') && !inlined.includes('./fonts/')) { + pass('local ./fonts references are inlined as data: URLs'); + } else { + fail('./fonts reference was not inlined as a data: URL — fonts will silently fall back (#951)'); + } + + // A missing font file must not corrupt the HTML or throw. + const missing = await inlineLocalFonts(``); + if (missing.includes(`url('./fonts/does-not-exist.woff2')`)) { + pass('missing font files keep their original reference'); + } else { + fail('missing font file mangled the url() reference'); + } + + // Traversal outside fonts/ must never be inlined. + const traversal = await inlineLocalFonts(``); + if (traversal.includes(`url('./fonts/../cv.md')`)) { + pass('path traversal outside fonts/ is not inlined'); + } else { + fail('path traversal escaped the fonts/ directory'); + } +} catch (e) { + fail(`font inlining test crashed: ${e.message}`); +} + // ── SUMMARY ───────────────────────────────────────────────────── console.log('\n' + '='.repeat(50)); From ae5f4d5fbcb14005e10700519fa123e002634ef7 Mon Sep 17 00:00:00 2001 From: Alex On Date: Thu, 11 Jun 2026 21:04:26 +0200 Subject: [PATCH 3/3] fix(generate-pdf): use resolved-path containment for font inlining guard The previous guard only rejected names containing '..', but the regex admits names starting with '/', and resolve(fontsDir, '/etc/passwd') returns the absolute path verbatim -- escaping fonts/. Resolve each reference and require it to stay inside fonts/ via path.relative(). Flagged by CodeRabbit on #952. Co-Authored-By: Claude Fable 5 --- generate-pdf.mjs | 14 +++++++++++--- test-all.mjs | 9 ++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/generate-pdf.mjs b/generate-pdf.mjs index b962d217cd..2cb874f431 100644 --- a/generate-pdf.mjs +++ b/generate-pdf.mjs @@ -11,7 +11,7 @@ */ import { chromium } from 'playwright'; -import { resolve, dirname } from 'path'; +import { resolve, dirname, relative, isAbsolute } from 'path'; import { readFile } from 'fs/promises'; import { mkdirSync } from 'fs'; import { fileURLToPath, pathToFileURL } from 'url'; @@ -244,12 +244,20 @@ async function generatePDF() { 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) { - if (name.includes('..')) continue; + // 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(resolve(__dirname, 'fonts', name)); + 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) { diff --git a/test-all.mjs b/test-all.mjs index 7d75daabbc..1ce19eb692 100644 --- a/test-all.mjs +++ b/test-all.mjs @@ -2229,13 +2229,20 @@ try { fail('missing font file mangled the url() reference'); } - // Traversal outside fonts/ must never be inlined. + // Traversal outside fonts/ must never be inlined — neither via ".." + // segments nor via absolute names (resolve() returns those verbatim). const traversal = await inlineLocalFonts(``); if (traversal.includes(`url('./fonts/../cv.md')`)) { pass('path traversal outside fonts/ is not inlined'); } else { fail('path traversal escaped the fonts/ directory'); } + const absolute = await inlineLocalFonts(``); + if (absolute.includes(`url('./fonts//etc/passwd')`)) { + pass('absolute-path escape (./fonts//etc/passwd) is not inlined'); + } else { + fail('absolute-path reference escaped the fonts/ directory'); + } } catch (e) { fail(`font inlining test crashed: ${e.message}`); }