DEV Community

Cover image for Your PDF resume can contain zero text. One command tells you.
Ava Bagherzadeh
Ava Bagherzadeh

Posted on

Your PDF resume can contain zero text. One command tells you.

We ship a resume builder. It renders a nice template, you hit Download, you get a PDF that looks exactly like the preview.

For months that PDF contained zero text. Not badly structured text. Not text in the wrong order. Zero. It was a picture of a resume in a PDF wrapper, and no parser on earth could read a single word of it.

Here is how to check yours in one command, how we found ours, and why the obvious fix was the wrong one.

Two resumes, identical on screen, one unreadable

The check

A PDF stores visible text as content-stream operators. Tj and TJ are the ones that actually show glyphs. If a file has none, there is no text layer, only pixels.

# macOS / Linux, no dependencies beyond what you have
python3 - "$1" <<'PY'
import re, sys, zlib
raw = open(sys.argv[1], 'rb').read()
streams = re.findall(rb'stream\r?\n(.*?)endstream', raw, re.S)
ops = 0
for s in streams:
    try:
        s = zlib.decompress(s)
    except Exception:
        pass
    ops += len(re.findall(rb'\)\s*Tj|\]\s*TJ', s))
print(f"text-showing operators: {ops}")
print("READABLE" if ops else "IMAGE ONLY. No ATS can read this file.")
PY
Enter fullscreen mode Exit fullscreen mode

Or if you have poppler installed, the two-second version:

pdftotext resume.pdf - | wc -c   # a real resume is 2000+ bytes. 0 or 1 means you have a picture.
Enter fullscreen mode Exit fullscreen mode

Run it on the last resume you sent somewhere. I would like to be wrong about how often this returns zero.

How ours got that way

The export was built the way most web apps build exports:

const canvas = await html2canvas(templateEl, { scale: 2 });
pdf.addImage(canvas.toDataURL('image/jpeg'), 'JPEG', 0, 0, w, h);
Enter fullscreen mode Exit fullscreen mode

Two lines, ships in an afternoon, output is pixel-identical to the preview. It is also a JPEG in a trench coat.

The verified artifact from our own storage:

/Producer (jsPDF 4.2.1)
one /Image XObject, DCTDecode
embedded JPEG 1588x652
0 Tj / 0 TJ
Enter fullscreen mode Exit fullscreen mode

16 KB, opens fine, prints fine, looks correct in every viewer. Completely opaque to software.

The three consequences, worst last

  1. An image-only resume is not parseable by any applicant tracking system. Every ATS starts by extracting a text layer. There is nothing to extract, so the parse comes back empty and the candidate looks like a blank form.
  2. Our own importer could not read it back. We fed our own export into our own parser and got an empty resume.
  3. A real person hit that empty parse and retyped their entire work history by hand. We know the number because the client logs keypresses for autosave. 12,546 keypresses. Somebody sat there and typed their own life back into a box because our export had thrown the text away.

That third one is why I am writing this instead of quietly patching it.

What does not fix it

We tried the two rescue routes first, because a fix that needs no rendering change is always tempting.

  • Native PDF extraction through a model that accepts PDFs: NO_TEXT, image_tokens: 0.
  • OCR, specifically mistral-ocr: NO_TEXT, 61 prompt tokens.

Neither is a model quality problem. There is nothing in the file to recover. A rescue layer cannot invent a text layer that was never written, and if it ever appears to, it is guessing at your work history.

The fix, and why it is the additive one

Three routes were on the table.

  1. Render server-side through a real PDF library that emits true text. Correct output, but it discards the visual template the user picked. That is a product regression to fix a bug.
  2. Re-implement every template in pdf-lib. Same loss, more work, two rendering paths to keep in sync forever.
  3. Keep the raster, and draw the same words invisibly on top of it, at the same coordinates.

We took the third. It is the standard searchable-scan construction: the page you see is the bitmap, the page a machine reads is the invisible text layer sitting exactly over it.

// collect runs BEFORE the capture, from the same un-scaled A4 layout
// html2canvas is about to rasterise. Reading after the restore measures
// the on-screen preview, which is transformed and narrower, and every
// word lands in the wrong place.
const textRuns = collectTextRuns(templateEl);
const canvas = await html2canvas(templateEl, { width: A4_WIDTH_PX, scale: 2 });
// ... then, per page, draw each run at its baseline with render mode 3 (invisible)
Enter fullscreen mode Exit fullscreen mode

That ordering detail cost us an afternoon. The text layer has to match the geometry that was rasterised, not the geometry on screen.

One hard rule we wrote into the file, and I would write it into yours: the invisible layer contains only words that are visibly on the page. Same words, same positions. The moment it contains anything else it stops being an accessibility layer and becomes keyword stuffing, which is both dishonest and detectable.

The part that generalises

We did not catch this by looking at the PDF. The PDF looked perfect. We caught it by asking a different question: not did the export succeed, but what can the other side actually read.

A raster-only export is large and looks healthy. File size tells you nothing. The regression test that now guards this counts text operators, because that is the only signal that distinguishes a readable resume from a picture of one.

expect(countTextOperators(pdfBytes)).toBeGreaterThan(200);
Enter fullscreen mode Exit fullscreen mode

If your product hands a file to somebody else's parser, put a test on the parser's view of it, not on yours.


I build AI Applyd, which scores, tailors and submits job applications on the company's own hiring system. The bug above was ours, in production, for months. The checker script at the top works on any PDF from anywhere, including ours. Run it on yours.

Top comments (0)