The heading extracted as E X P E R I E N C E
Shipped
This release replaced a résumé generator’s seven hardcoded PDF templates with one HTML structure and a swappable CSS theme, so a new look is a stylesheet instead of a config object. While measuring whether the output was still machine-readable, one CSS property turned out to be quietly destroying every section heading in the extracted text. The rest of this is how to find that class of bug in anything you generate a PDF from, because you cannot see it by looking at the page.
Why the page looking right proves nothing
A PDF does not store your text as sentences. Text is drawn by showing glyphs at positions, and each glyph carries a horizontal displacement that moves the cursor along for the next one. The ISO 32000 text clause describes exactly that: displacement values position glyphs, and horizontal scaling affects a glyph’s shape and its displacement together.
Reading text back out is therefore reconstruction, not lookup. An extractor gets a stream of glyphs with coordinates and has to decide where one word ends and the next begins, using nothing but the gaps. Push the glyphs far enough apart and the gap inside a word starts to look like the gap between words. Mozilla’s pdf.js has a long-running issue for precisely this symptom, where a heading comes out as V E L K O M M E N instead of VELKOMMEN.
The CSS property that pushes glyphs apart is letter-spacing, which the CSS Text Module calls tracking: spacing inserted between typographic character units. It is the standard way to get that wide, editorial look on a small uppercase heading. It is also, past a certain value, the thing that makes your headings stop being words.
Set up a render-and-read loop
Two dependencies. Playwright drives a headless Chromium to produce the PDF, and unpdf gives you pdf.js text extraction without a native canvas build.
mkdir pdfcheck && cd pdfcheck && npm init -y
npm i playwright unpdf
npx playwright install chromium
Now one module with everything: render a PDF, read its text, build a test page, and check whether a word survived. This is the whole harness.
// pdf-text.mjs
import { readFileSync } from "node:fs";
import { chromium } from "playwright";
import { getDocumentProxy, extractText } from "unpdf";
/** Render `html` to a Letter-size PDF at `outPath`. */
export async function renderPdf(html, outPath) {
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "load" });
await page.pdf({
path: outPath,
format: "Letter",
printBackground: true,
preferCSSPageSize: true,
});
} finally {
await browser.close();
}
return outPath;
}
/** Extract the PDF's text the way a parser downstream of you would. */
export async function pdfText(path) {
const pdf = await getDocumentProxy(new Uint8Array(readFileSync(path)));
const out = await extractText(pdf, { mergePages: true });
return Array.isArray(out.text) ? out.text.join("\n") : out.text;
}
/** A minimal page with one heading, tracked out by `spacing`. */
export function headingDoc(spacing) {
return `<!doctype html><html><head><style>
@page { size: letter; margin: 1in; }
h2 { font: 700 10px/1.4 -apple-system, Arial, sans-serif;
letter-spacing: ${spacing}; text-transform: uppercase; }
</style></head><body><h2>Experience</h2></body></html>`;
}
/** Did `word` survive extraction as one contiguous run of characters? */
export function survived(text, word) {
return text.toUpperCase().includes(word.toUpperCase());
}
if (import.meta.url === `file://${process.argv[1]}`) {
const spacing = process.argv[2] ?? "0.15em";
await renderPdf(headingDoc(spacing), "heading.pdf");
const text = await pdfText("heading.pdf");
console.log(`letter-spacing: ${spacing}`);
console.log(`extracted: ${JSON.stringify(text.trim())}`);
console.log(`survived: ${survived(text, "EXPERIENCE")}`);
}
Run it twice and you have the bug in your hands:
node pdf-text.mjs 0.15em
node pdf-text.mjs 0.06em
letter-spacing: 0.15em
extracted: "E X P E R I E N C E"
survived: false
letter-spacing: 0.06em
extracted: "EXPERIENCE"
survived: true
Both PDFs look identical to a person apart from slightly wider tracking. One of them no longer contains the word “experience”.
Find your own threshold, do not borrow mine
The value where this starts depends on the font, the size, the renderer version and the extractor, so measuring beats guessing. Sweep it.
// sweep.mjs
import { renderPdf, pdfText, headingDoc, survived } from "./pdf-text.mjs";
const STEPS = ["0", "0.02em", "0.04em", "0.06em", "0.08em", "0.10em", "0.12em", "0.15em"];
for (const spacing of STEPS) {
await renderPdf(headingDoc(spacing), "sweep.pdf");
const text = (await pdfText("sweep.pdf")).trim();
const ok = survived(text, "EXPERIENCE");
console.log(`${spacing.padEnd(7)} ${ok ? "ok " : "BROKEN "} ${JSON.stringify(text)}`);
}
0 ok "EXPERIENCE"
0.02em ok "EXPERIENCE"
0.04em ok "EXPERIENCE"
0.06em ok "EXPERIENCE"
0.08em ok "EXPERIENCE"
0.10em ok "EXPERIENCE"
0.12em BROKEN "E X P E R I E N C E"
0.15em BROKEN "E X P E R I E N C E"
A clean cliff between 0.10em and 0.12em on this stack. Pick a working value with headroom rather than the last passing one, because the cliff moves when a dependency does. The themes that shipped in this release cap tracking at 0.08em, and the .08em value appears five times in the default stylesheet at the release tag, once for each element that carries words in small caps.
Turn it into a gate that can fail
A check that only asserts good input passes is one you can weaken by accident and never hear about again. Give it a known-bad input too: the same stylesheet with one value changed.
// extraction.test.mjs
import assert from "node:assert/strict";
import { renderPdf, pdfText, survived } from "./pdf-text.mjs";
const HEADINGS = ["SUMMARY", "EXPERIENCE", "EDUCATION"];
/** Your real document, with whatever stylesheet you ship. */
function doc(css) {
const body = HEADINGS.map((h) => `<h2>${h}</h2><p>Some body copy.</p>`).join("");
return `<!doctype html><html><head><style>
@page { size: letter; margin: 1in; }
body { font: 11px/1.5 Georgia, serif; }
${css}
</style></head><body>${body}</body></html>`;
}
const SHIPPED = `h2 { font: 700 10px/1.4 Arial, sans-serif; letter-spacing: .08em;
text-transform: uppercase; }`;
// Same stylesheet, one value changed. This is the known-bad input.
const TRAP = SHIPPED.replace(".08em", ".15em");
async function missingHeadings(css, out) {
await renderPdf(doc(css), out);
const text = await pdfText(out);
return HEADINGS.filter((h) => !survived(text, h));
}
// Good input passes.
const shipped = await missingHeadings(SHIPPED, "shipped.pdf");
assert.deepEqual(shipped, [], `headings did not survive extraction: ${shipped.join(", ")}`);
console.log(`ok shipped stylesheet: all ${HEADINGS.length} headings survived`);
// Known-bad input fails. Without this, the assertion above could be weakened
// to something that cannot fail and would keep reporting green.
const trapped = await missingHeadings(TRAP, "trap.pdf");
assert.ok(
trapped.length > 0,
"the over-tracked stylesheet no longer breaks extraction; re-measure your threshold",
);
console.log(`ok trap stylesheet: lost ${trapped.join(", ")}`);
ok shipped stylesheet: all 3 headings survived
ok trap stylesheet: lost SUMMARY, EXPERIENCE, EDUCATION
To prove the gate really bites, change .08em to .15em in SHIPPED and run it again. The first assertion should fail and name the headings it lost. That mutation is the only evidence that the check is doing anything; a green test on good input alone proves nothing about whether it can go red.
Gotchas
Checking with one extractor tells you less than you think. Two libraries reading the same file disagree, and not consistently. Running poppler’s pdftotext against the trap PDF from the test above gives S U M M A RY, then EXPERIENCE intact, then E D U C AT I O N. Same file, same tracking value, three different outcomes word by word. Earlier in this work I checked only with pdftotext, saw headings come back fine, and concluded poppler was immune; the note I wrote into the theme documentation says so, and it is too absolute. Escape: assert against the extractor your consumers actually use, and treat a pass from a different one as unrelated news.
A one-sided check passes on a document that is already broken. My first version of this asserted that no separated-glyph headings appeared, and it went green immediately, because at the time it ran against a PDF whose tracking had already been lowered. The assertion had never once been exercised against a failing input. Escape: write the known-bad fixture first and confirm the check fails on it, then fix the real input.
text-transform: uppercase is baked into the extracted text. A heading written as Experience in your markup extracts as EXPERIENCE, because the transform is applied before the glyphs are drawn. Escape: compare case-insensitively, or you will chase a mismatch that only exists in your assertion.
Ligatures are the trap that is not there. The obvious sibling worry is fi and fl collapsing into a single glyph and breaking a word like “workflows”. I set font-variant-ligatures: none for it, then measured: both pdf.js and poppler recovered the word with ligatures fully enabled, because Chromium emits a usable character map alongside the glyphs. Escape: keep the property if you like, but do not write a test around a failure you have not reproduced, or you will ship an assertion that cannot fail.
Sources
- ISO 32000-2 clause 9, Text — glyph displacement, not stored strings, is what positions text in a PDF
- mozilla/pdf.js issue 6705 — the separated-glyph extraction symptom, tracked upstream
- CSS Text Module Level 3, letter-spacing — tracking as spacing inserted between typographic character units
Changelog
- feat(resume)!: themed rendering + a stored résumé (2.0.0) (#109) (0142674)