/blog

My CV PDF read out of order, and a screenshot would never have shown it

I rebuilt the CV for this site as a print stylesheet that Chrome turns into a PDF. It looked right in every screenshot. Then I extracted the text layer, which is what an applicant tracking system actually reads, and three things were wrong that no screenshot could show.

Test the text layer, not the picture

A PDF viewer shows you pixels. A parser reads a stream of text runs in the order the file stores them. To see what a parser sees, render nothing and extract the text with pdf.js:

const doc = await pdfjs.getDocument(file).promise;
for (let i = 1; i <= doc.numPages; i++) {
  const tc = await (await doc.getPage(i)).getTextContent();
  // tc.items are the text runs in file order, each with a position
}

Then assert on it. I wrote sixteen checks: the name is the first line, the email and phone are present, the section headings exist, dates are in a standard month-year form, no replacement characters, and, the one that mattered, each role's first bullet appears after its own heading and before the next role.

1. Bullets read out of order

The first run failed that last check. Every role's bullets came after the headings of the roles below it. On screen the page was perfect.

The cause was how the bullets were drawn: each list item was position: relative with a ::before pseudo-element positioned absolutely as the marker. Chrome paints positioned boxes in a later layer than normal flow content, and the PDF stores text in paint order. So all the in-flow text (titles, dates, technology lines) came first and all the positioned text (the bullets) came after.

The fix is to stop positioning the bullets and use real list markers:

.exp-bullets { list-style: disc outside; padding-left: 11pt; }
.exp-bullets li { display: list-item; position: static; }
.exp-bullets li::before { content: none; }

After that the reading-order assertion passed. A parser that assigns bullets to the nearest preceding heading would previously have attached the wrong achievements to the wrong employer.

2. A rule that undid my own hiding

To fit two pages I hide some bullets and some web-only text in print with a .pdf-hide class. After the list fix, hidden bullets came back. The list item rule set display: list-item !important, and its selector is more specific than the bare .pdf-hide rule, so it won. Both used !important, so specificity decided. The hiding rule has to be at least as specific as the rule it overrides, and come after it.

3. A media query that matched the printer

A few edits later the PDF grew a blank third page. I had added a fixed bottom navigation bar for phones, with a body padding so the footer would clear it, inside @media (max-width: 768px).

An A4 page with 14 mm margins is roughly 690 to 794 CSS pixels wide, depending on how the engine counts. That matches a phone-width query. So the phone-only padding applied while printing, pushed the content past the page boundary, and Chrome emitted an empty page. The fix is to write every mobile or tablet rule as @media screen and (max-width: ...).

Make it fail loudly

Each of these was invisible until I looked at the right thing, so I added checks that look at it for me. The smoke test I run after every deploy fetches the live PDF and counts page objects in the raw bytes:

const raw = Buffer.from(await res.arrayBuffer()).toString('latin1');
const pages = (raw.match(/\/Type\s*\/Page[^s]/g) ?? []).length;
// fail unless pages === 2

That works because Chrome writes each page as an uncompressed /Type /Page object, and the [^s] skips /Type /Pages. It is a cheap check and it caught a stale PDF within minutes of adding it: the new CSS was live but the old three-page file was still being served.

What I would carry over to any print stylesheet

  • Extract the text layer and assert on order, not just content.
  • Avoid position on anything whose text order matters. Use native list markers.
  • Write screen-width media queries as screen and (...).
  • Do not use break-inside: avoid on a block that can be larger than a page; it leaves page-sized gaps.
  • Count the pages of the final file in CI or a post-deploy check.

I cannot promise any particular ATS scores a document a certain way, because each one parses differently and I have not uploaded this to a real one. What I can say is that the text layer now reads in the same order a person would read the page, which is the property most of them depend on.