Resume advice uses the word "ATS-friendly" a lot. It almost never shows what a parser actually pulled out of the file.
I make resume templates in Google Docs. They're the templates on ReadableResume (https://readableresume.com). Before publishing them I wanted a test for one narrow question: if someone fills in a template and clicks File > Download > PDF, which fields does a parser read correctly, and which does it get wrong?
I couldn't find a public way to test a resume against Workday or Greenhouse. So I used tools I could pin and rerun:
- the resume parser from OpenResume, pinned to commit
4f8255a - pdfminer.six, for the raw text
- python-docx, for the Word export
This post covers how the test is wired, what broke, what I changed, and where the test stops being useful. The numbers come from the result files in the test repo.
The setup
1. Test the file a real person exports
The test never generates its own PDFs. A script downloads each template as PDF and .docx through Google Docs' public export URL. It's the closest automated stand-in for File > Download I found, and it needs no Google login. The exact files behind the published results are stored in the repo.
EXPORT_URL = "https://docs.google.com/document/d/{doc_id}/export?format={fmt}"
for fmt in ("pdf", "docx"):
download(EXPORT_URL.format(doc_id=entry["docId"], fmt=fmt), folder / f"{key}.{fmt}")
Every report records the SHA-256 of the PDF it tested, so a rerun can confirm it used the same file.
2. Run OpenResume's parser in Node
OpenResume runs its parser in the browser. Its read-pdf.ts loads pdf.js through a browser worker entry, and that doesn't run in Node. So I re-implemented only that first step, mirroring the upstream code, and import the rest of the parser unchanged from a git submodule:
import "./node-globals";
import * as pdfjs from "pdfjs-dist/legacy/build/pdf.js";
import { groupTextItemsIntoLines } from "lib/parse-resume-from-pdf/group-text-items-into-lines";
import { groupLinesIntoSections } from "lib/parse-resume-from-pdf/group-lines-into-sections";
import { extractResumeFromSections } from "lib/parse-resume-from-pdf/extract-resume-from-sections/index";
// readPdf() mirrors upstream read-pdf.ts, minus the browser worker
const textItems = await readPdf(new Uint8Array(readFileSync(path)));
const lines = groupTextItemsIntoLines(textItems as any);
const sections = groupLinesIntoSections(lines);
const resume = extractResumeFromSections(sections);
The pdf.js 3.x legacy build looks for DOMMatrix and Path2D when it loads in Node, and normally the native canvas package provides them. They're only used for rendering. Text extraction never touches them, so two empty classes are enough and the runner needs no native dependencies:
const g = globalThis as any;
g.DOMMatrix ??= class DOMMatrix {};
g.Path2D ??= class Path2D {};
Python calls the runner through tsx and reads JSON from stdout.
3. Write down the expected answer
Each sample resume has an expected-resume.json shaped like OpenResume's own Resume type: name, email, phone, location, link and summary, then every job, school and project, then skills. It's built from the same source content as the template, never from parser output.
The comparison rules:
- Each profile field, each field of each entry, and each bullet is one item.
- Lists match one to one, in order, with the same length.
- An empty expected field must stay empty. That catches text landing in the wrong field.
- Before comparing: Unicode NFC, every dash becomes
-, whitespace is collapsed, a leading bullet is dropped, case is ignored.
def normalize(text: str) -> str:
text = unicodedata.normalize("NFC", text or "")
for dash in DASHES:
text = text.replace(dash, "-")
text = re.sub(r"\s+", " ", text).strip()
return text.casefold()
Some values have no field in the parser. It has no location for a job or a school, it keeps one link, and it only knows US phone numbers and "City, ST" locations. Those values go into an outOfScope list. The report shows whether each one appears in the text, but they don't count toward the score.
4. A second opinion on the raw text
A parser can hide problems by being forgiving. So pdfminer.six also reads every PDF, in the file's own content-stream order, with layout analysis off and nothing sorted. A new line starts when the baseline moves more than 2pt. A space goes in where there's a gap wider than a quarter of the font size but no space character.
for char in chars:
text = char.get_text()
if previous is not None and abs(char.y0 - previous.y0) > NEW_LINE_BASELINE_SHIFT_PT:
lines.append(current)
current = ""
elif (
previous is not None
and char.x0 - previous.x1 > WORD_GAP_OF_FONT_SIZE * char.size
and not current.endswith(" ")
and not text.isspace()
):
current += " "
current += text
previous = char
The text check fails on any ligature character (U+FB00 to U+FB06), any private-use character (U+E000 to U+F8FF), any word from the sample that comes out split in two, any value out of order, and any text left over after every expected value is removed.
5. The Word file, and testing the tests
python-docx reads the exported .docx. Text must come out in order, every bullet must still be a real list item, and no bullet may sit on Symbol or Wingdings. Tables, text boxes, and text in headers or footers all fail.
Before each run, selftest.py takes a passing fixture, breaks it on purpose, and asserts that the right check now fails: a split word (Proj ects), an fi ligature, duplicated content, swapped sections, a table in the .docx, a bullet moved onto the Symbol font, and so on. A check that never fails isn't checking anything.
What broke
My own font check, first
The first version of the font check listed the fonts in each PDF and failed any file that contained a font other than the template's. When the first eight templates (48 files) went through it, 8 resume files failed. The Georgia templates contained Times New Roman. The Trebuchet MS and Verdana templates contained Arial.
Every other check on those 8 files passed. Counting fonts per character showed why: Google Docs draws tab characters and the gap after each bullet in a default font. In the Classic resume, which is set in Georgia, that's 22 whitespace characters in Times New Roman and not one visible character.
I changed the check to count only fonts that draw visible characters and to report whitespace fonts separately. Same PDF, same SHA-256, and it passed.
32 fonts through the same export
Next question: what happens if someone keeps the layout and only changes the font?
I took one sample, a one-page resume in the Jake's Resume layout on US Letter, and made 32 copies that differ only in font: 26 that Google Docs offers, plus 6 that are common in Word or on a Mac but that Docs may not have. Each copy went through a Google Docs import and PDF export. In its original font, Times New Roman, the sample reads 46 of 46 fields.
A font counts as reading cleanly if it drew every visible character in the PDF, the PDF stayed on one page, the text check passed, and all 46 fields matched. 9 of 32 did: Arial, Book Antiqua, Calibri, Cambria, EB Garamond, Garamond, Georgia, Times New Roman and Trebuchet MS.
Some of the rest:
| Font | Drawn in | Pages | Ligatures in the PDF | OpenResume fields |
|---|---|---|---|---|
| Lato | Lato | 1 | fi ×5, fl ×1 | 43/46 |
| Roboto | Roboto | 1 | fi ×4, fl ×1, ffi ×1 | 43/46 |
| Helvetica | Helvetica Neue | 1 | fi ×4, fl ×1, ffi ×1 | 43/46 |
| Inter | Inter | 1 | none (4 private-use chars) | 44/46 |
| Aptos | Arial | 1 | none | 36/46 |
| Tahoma | Tahoma | 1 | none | 36/46 |
| Verdana | Verdana | 2 | none | 41/46 |
| Roboto Slab | Roboto Slab | 2 | fi ×5, fl ×1 | 29/46 |
| Courier New | Courier New | 2 | none | 10/46 |
All 32 rows are in findings/2026-10-05-font-export/ in the repo, with every field's expected and actual value.
Ligatures, and a space that disappears
16 fonts stored letter pairs as one ligature character: "fi" and "fl" in 15 of them (6 of those also "ffi"), and "ff" in Source Sans 3. pdfminer reads first (U+FB01) instead of first. Anything doing a plain keyword match on that text would miss the word.
OpenResume's output had a different problem. With Lato, one bullet came back as:
Added structured logging and a Grafana dashboard that the on-call engineer now checksfirst during incidents
The ligature was gone, and so was the space. In 15 of the 16 ligature fonts, at least one field failed on whitespace alone. In Lato, Lora, Raleway and Roboto that was the only problem, and it still cost 3 of 46 fields.
To see why, I dumped the raw pdf.js text items for that line (trimmed):
{"str":"...the on-call engineer now checks","x":61.2,"width":401.25}
{"str":" ","x":462.45,"width":2.7}
{"str":"fi","x":464.47,"width":5.99}
{"str":"rst during incidents","x":470.46,"width":88.39}
In the Times New Roman PDF the same bullet is one text item. In Lato, pdf.js splits the run at the ligature glyph and returns a plain "fi". Then two steps in OpenResume combine badly. First, readPdf drops items that are only whitespace:
const isEmptySpace = (textItem: TextItem) =>
!textItem.hasEOL && textItem.text.trim() === "";
textItems = textItems.filter((textItem) => !isEmptySpace(textItem));
Then groupTextItemsIntoLines merges neighbouring items that are closer than a typical character width. It only adds a space after :, , or ., or next to | and bullet marks:
if (distance <= typicalCharWidth) {
if (shouldAddSpaceBetweenText(leftItem.text, currentItem.text)) {
leftItem.text += " ";
}
leftItem.text += currentItem.text;
// ...
}
"checks" ends at x = 462.45 and "fi" starts at 464.47, a 2pt gap. The space item that sat between them is already gone, so they merge with nothing in between: checksfirst.
Other font problems
-
Inter. The brackets and hyphen in
(312) 555-0143and the colon after "GPA" came out as private-use characters (U+E081, U+E082, U+E088, U+E092), in both pdfminer and pdf.js. The phone regex wants a bracket, space, hyphen or digit where those characters sit, so the phone field came back empty. - Substitution. Google Docs drew Aptos in Arial and Helvetica in Helvetica Neue. That showed up when I checked which font drew each character in the PDF.
-
Company glued to the date. With Aptos (drawn in Arial), Tahoma and Roboto Slab, the parser put each company name on the end of the date line above it:
May 2025 – Aug 2025Ironhaw Freight. Company and date failed for every job. I haven't tracked down why. Arial on its own read 46 of 46. -
Courier New. OpenResume ran words together (
Builta shipmentstatusAPI inGoto). 18 fields differ only in spacing, and 10 of 46 read correctly. pdfminer's text was fine.
Page breaks
At the same point sizes, 15 fonts pushed the one-page sample onto a second page. OpenResume puts the text items of all pages into one list and starts a new line only when pdf.js marks an item with hasEOL. The last item on a page doesn't get one. This is the Verdana PDF:
page 1 end: {"str":"Won second place out of 34 teams at the campus hackathon, judged by local engineers","hasEOL":false}
page 2 start: {"str":"TECHNICAL SKILLS","hasEOL":false}
So the last bullet on page 1 and the heading at the top of page 2 become one line:
• Won second place out of 34 teams at the campus hackathon, judged by local engineersTECHNICAL SKILLS
OpenResume's main test for a section heading is a line holding a single text item that is bold and all caps. This heading is now the tail of a bullet, so the Skills section disappears.
Every two-page PDF had this join. Where the break landed decided the damage:
| Page break falls | Fonts | What the parser got wrong |
|---|---|---|
| right before the Skills heading | 5 | heading swallowed, all three skills lines empty |
| between two skills lines | 7 | two lines merged (SQL, CFrameworks:), all three skills fields wrong |
| between a project bullet and the next project's title | 2 | title glued onto the bullet, projects shifted, last one empty |
| between two bullets | 1 | nothing from the join itself (that font, Courier New, failed for other reasons) |
The last row matters. It's the one case the parser recovers from, and it's what the templates aim for.
Names that aren't ASCII
OpenResume describes its parser as designed for single-column resumes in English. The name rule shows it:
export const matchOnlyLetterSpaceOrPeriod = (item: TextItem) =>
item.text.match(/^[a-zA-Z\s\.]+$/);
const NAME_FEATURE_SETS: FeatureSet[] = [
[matchOnlyLetterSpaceOrPeriod, 3, true],
[isBold, 2],
[hasLetterAndIsAllUpperCase, 2],
// Match against other unique attributes
[hasAt, -4], // Email
[hasNumber, -4], // Phone
[hasParenthesis, -4], // Phone
[hasComma, -4], // Location
[hasSlash, -4], // Url
[has4OrMoreWords, -2], // Summary
];
Every text item above the first section heading gets a score, and the highest positive score wins. I replaced the sample name in two resumes (one in Arial, one in Times New Roman) with an accented Latin name, a Cyrillic name and a Chinese name, then exported each through Google Docs.
| Name | Font in the PDF | Name field |
|---|---|---|
| José Álvarez | unchanged | read correctly |
| Иван Петров | unchanged (Arial and Times New Roman include Cyrillic) | read correctly |
| 王小明 | Docs swapped in MS PGothic (Arial template) or MS PMincho (Times New Roman template) | empty |
The accented and Cyrillic names fail the regex, but the name line is bold. The +2 from isBold was enough to make it the top candidate.
The Chinese name is drawn in the substituted font. That font's name doesn't contain "Bold", and the characters aren't A to Z, so the name line scores 0. Nothing else in the header scores above zero either, and the parser returns an empty name. pdfminer extracted the characters fine, every other field parsed, and the page count didn't change.
One idea I haven't tested yet: lead with the romanized name, like "Xiaoming Wang (王小明)".
Rules you only find in the source
Some rules are easy to trip without noticing. The degree rule is one:
const hasDegree = (item: TextItem) =>
DEGREES.some((degree) => item.text.includes(degree)) ||
/[ABM][A-Z\.]/.test(item.text); // Match AA, B.S., MBA, etc.
"Boston, MA" matches [ABM][A-Z\.]. So does "Ann Arbor, MI". A school's location can compete with the real degree.
A few more from the extractors:
- A job title scores +4 if one word exactly matches a fixed list (Engineer, Manager, Nurse, Teacher and 65 others). A digit anywhere in the title scores -4 and cancels it.
- Sections are found by keywords in the heading (experience, education, project, skill and a few more). For each kind, only the first matching section is read.
What I changed
I don't have a clean before and after for the templates. These rules went into the template generator while the first templates were being built, and the committed results never show a template failing on them. What I can show is what each change is for, and the numbers now.
1. Five fonts. Every template uses Arial, Georgia, Times New Roman, Trebuchet MS or Verdana. I picked them because they're in the Docs font menu and also ship with Windows and macOS. Four of them read cleanly in the font test. Verdana didn't fit the Jake's layout on one page at the same point sizes. The templates set in Verdana use their own sizes, stay on one page, and read every field.
2. Bullets never split across pages. The generator, which lives in a separate private repo, sets "keep lines together" on every bullet:
def bullet(self, text: str, size: float | None = None) -> None:
p = self.paragraph()
# OpenResume joins the last line of a page to the first line of the next.
# When the break falls between two bullets, the bullet mark splits them again.
p.paragraph_format.keep_together = True
_apply_bullet(p)
self.add_runs(p, [(text, {"size": size})])
The parser still glues the two pages together. In 8 of the 10 two-page resume files, the break lands between two bullets, the last row of the table above. This is the Federal template where page 1 meets page 2 (‖ marks a boundary between pdf.js text items):
• ‖ Recruited and scheduled about 60 volunteers a month • ‖ Wrote the volunteer handbook and ran monthly orientation sessions for new volunteers
OpenResume splits descriptions on the most common bullet character, so both bullets come out right.
The other two files are the Academic CV. On US Letter the break falls inside the Teaching section. On A4 it swallows the "GRANTS AND AWARDS" heading, the same failure as the Skills heading above. OpenResume has no field for either section, so no scored field changes, but the join is there. All 10 two-page resume files read every scored field.
3. A lint step before generating. The rules above are checked against the sample content before anything goes to Google Docs. Simplified from the generator, with messages translated:
if kind == "education" and re.search(r"[ABM][A-Z.]", entry["location"]):
problems.append(f"school location '{entry['location']}' matches the degree regex")
if kind == "experience" and not JOB_TITLES & set(entry["title"].split()):
problems.append(f"job title '{entry['title']}' has no word the parser knows")
if kind == "experience" and re.search(r"\d", entry["title"]):
problems.append(f"job title '{entry['title']}' contains a digit")
4. Out of scope instead of bending the content. The International CV keeps a UK phone number and a UK location. Jobs keep their locations. A second link stays. They're listed as out of scope, shown in the report, and not scored.
The numbers now
- 140 files: 20 template sets × (resume, cover letter, references page) × (US Letter, A4), plus 20 filled-in example resumes on US Letter.
- 60 resumes with 2,209 scored items. All 2,209 match.
- 325 more items are out of scope and not scored. 314 have no field in the parser at all, and 246 of those are job or school locations. 6 are second links (the parser keeps one). 4 are the International CV's UK phone numbers and locations, which fall outside its format rules. 1 is a degree outside its list (PharmD). All 325 appear in the extracted text.
- The 80 cover letters and references pages get the text, font, page and Word checks, without field extraction. All pass.
- In all 140 PDFs, every visible character uses the template's font, and every font is embedded.
While writing this, I reran the whole suite locally with make check. All 140 files passed, and the JSON reports matched the committed ones apart from the timestamp. GitHub Actions downloads every template from Google Docs again and reruns the checks once a week.
Limits
- One open-source parser and one text extractor. This says nothing direct about Workday, Greenhouse, iCIMS or any other commercial system. Some of them may normalise ligatures. Some may do worse.
- One export path in the published results: Google Docs' PDF export. Chrome's Print to PDF and Word's Save as PDF were only spot-checked by hand for the first release, not run on every file.
- It tests my sample text, not yours. Your own content can still trip a parser.
- OpenResume is pinned to a commit from October 2024, with pdf.js 3.7.107. Pinning makes the results reproducible. It also means they age.
- The font test is one sample, one layout, US Letter, exported on 2026-10-05. Google Docs changes over time.
- The name test is one name per script, in two templates.
Run it yourself
git clone --recurse-submodules https://github.com/supertj/readableresume-parser-tests.git
cd readableresume-parser-tests
make docker-verify # every check on the stored PDFs, with pinned Node, Python and packages
make docker-verify-fresh # download the current exports from Google Docs first
Without Docker you need Node 20+ and uv: cd parser && npm ci && cd .., then make check for the stored files or make verify to download first. make font-study reruns the font test on the PDFs in findings/.
AI disclosure: this post was drafted with an AI assistant, working from the test repo and its result files. Every number was checked against those files, and the tests were rerun before publishing.
Top comments (1)
The ligature finding is the most consequential one for me: pdf.js drops the whitespace item next to the ligature, so the word merges with its neighbor and a plain keyword search misses it entirely. It makes a strong case that "ATS-friendly" can only be verified at the parser level, not by eyeballing the PDF.