Every job seeker eventually hears the same scary line: "an ATS rejected your CV before a human ever saw it."
It's mostly not true. An applicant tracking system (ATS) is the software companies use to collect applications. It reads the text out of your CV (or résumé), drops it into a candidate profile, and lets recruiters search it. Most rejections still come from people, knockout questions on the form, or the sheer number of applicants.
But there's a real problem hiding under the myth: if the ATS can't read your file properly, your details end up jumbled or missing. Your two-column layout gets read straight across, your phone number was an icon, your "Experience" heading was a picture of the word "Experience". The recruiter searching for "SQL" never finds you.
I work on Litjob, an AI job application assistant, and I wanted a free tool that shows people exactly that: here's what an ATS gets out of your file. I had one hard rule.
The rule: the CV never leaves the browser
A CV is about the most personal document people have. Name, phone, address, work history, sometimes a photo and a date of birth. I didn't want to receive it, store it, or explain a privacy policy about it.
That rule also happened to be the cheap option. No upload endpoint, no storage, no AI calls per check, no server bill that grows with traffic. The whole check is a pure function that runs on the visitor's laptop.
So the plan became:
- Read the PDF or Word file in the browser.
- Pull out the text in the order a text extractor reads it, plus a few layout facts (where text sits on the page, images, tables, text boxes, headers).
- Run checks on those facts and explain each problem in plain English.
Reading PDFs with pdf.js
pdf.js is Mozilla's PDF engine, the one inside Firefox. It's big: the worker alone is about 1 MB minified. Loading that for every visitor to a marketing page would be silly, so it only loads when someone actually picks a file:
async function readPdf(file: File) {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const doc = await pdfjs.getDocument({
data: new Uint8Array(await file.arrayBuffer()),
isEvalSupported: false,
}).promise;
// ...
}
The new URL(..., import.meta.url) trick lets the bundler (Next.js here) copy the worker file into the build and hand back its URL. I was half expecting to have to copy the worker into public/ by hand and keep its version in sync forever. I didn't.
For each page, getTextContent() gives you text items in content-stream order, which is roughly what a naive extractor sees. Each item also has a transform matrix, so you know where it sits on the page. I normalise everything to fractions of the page size, so a US Letter page and an A4 page look the same to the checks:
const [left, bottom, right, top] = page.view;
const width = right - left;
const height = top - bottom;
for (const item of content.items) {
if (!('str' in item)) continue;
text += item.str + (item.hasEOL ? '\n' : '');
items.push({
page: pageNumber,
x: (item.transform[4] - left) / width,
y: 1 - (item.transform[5] - bottom) / height, // 0 = top of the page
width: item.width / width,
text: item.str,
});
}
Images come from the operator list. Count the "paint image" operators and you know how many pictures and icons are on the page:
const imageOps = new Set([
pdfjs.OPS.paintImageXObject,
pdfjs.OPS.paintInlineImageXObject,
pdfjs.OPS.paintImageMaskXObject,
pdfjs.OPS.paintImageXObjectRepeat,
]);
const operators = await page.getOperatorList();
imageCount += operators.fnArray.filter((op) => imageOps.has(op)).length;
The first check is the most important one, and the simplest: if a PDF gives back fewer than about 60 words, it's almost certainly a scan or a picture of a CV. To an ATS that's a blank document. I stop there and say so, because nothing else matters until that's fixed.
Spotting two-column CVs (the heuristic that took the most thought)
Two-column templates are everywhere, and they're the classic way to get your sidebar and your work history shuffled together line by line.
The obvious idea is "lots of text starts in the middle of the page, so it's two columns." That would flag nearly every normal CV, because normal CVs are full of things that sit to the right:
- right-aligned dates (
Mar 2021 – Present) - indented bullet points
- the second half of a long line
What ended up working: for each page, look only at substantial runs of text (at least 12 characters, at least 12% of the page wide) that start between 28% and 70% of the way across. Round their start positions into buckets and find the most common one. If at least six runs start at that same spot, and at least six other runs start at the left margin and stop before it, that's a column.
for (const item of pageItems) {
if (item.x >= 0.28 && item.x <= 0.7 && item.width >= 0.12 && item.text.trim().length >= 12) {
const bucket = Math.round(item.x * 25) / 25;
middleStarts.set(bucket, (middleStarts.get(bucket) ?? 0) + 1);
}
}
// ...find the busiest bucket, then count left-margin runs that end before it
Dates are short, so they don't count. Bullets start near the left margin, so they don't count. A sidebar plus a main column does count. I tested it on printed HTML CVs (a one-column one and a sidebar one) and it got both right. It will still be wrong sometimes, which is why the result says "your CV looks like it has two columns" and then shows the extracted text so people can judge for themselves.
That last part turned out to be the most useful feature of the whole tool. People don't need my verdict. They need to see their sidebar text shuffled into their job history.
Icons are secretly characters
This one surprised me. Lots of CV templates use an icon font for the little phone, email and location symbols. When you extract the text, those icons come out as characters from Unicode's Private Use Area (U+E000 to U+F8FF): code points with no agreed meaning, which fonts fill with whatever they like. Broken font encodings show up as the replacement character (U+FFFD) or as (cid:123) sequences.
To an ATS, your phone number now starts with a meaningless character, or the email icon is glued onto the address. So the check counts them:
function unreadableCharacterCount(text: string): number {
return (text.match(/[\uE000-\uF8FF\uFFFD]|\(cid:\d+\)/g) ?? []).length;
}
Funny story: my tooling quietly turned those \uE000 escapes into the actual invisible characters when the file was first written. The code still worked, because a character class made of the real characters means the same thing, but on screen the line read /[-]/, which is a great way to confuse the next person. I only caught it while rereading the code to write this post. They're escapes again now.
Letter-spaced headings are the cousin of this problem: E X P E R I E N C E looks elegant and reads as ten one-letter words. A small regex catches runs of single letters separated by spaces.
Word files are zip files full of XML
A .docx is a zip archive. Inside, word/document.xml holds the body, word/header1.xml and friends hold headers and footers, and docProps/app.xml usually has a page count. So: jszip to open it, the browser's own DOMParser to read the XML, and no Word library at all.
const { default: JSZip } = await import('jszip');
const zip = await JSZip.loadAsync(await file.arrayBuffer());
const xml = await zip.file('word/document.xml')?.async('string');
const body = new DOMParser().parseFromString(xml, 'application/xml');
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const tables = body.getElementsByTagNameNS(W, 'tbl').length;
const textBoxes = body.getElementsByTagNameNS(W, 'txbxContent').length;
Tables and text boxes are worth flagging because some ATS read tables cell by cell in an odd order, and many skip text boxes entirely.
Then there's a trap that's easy to fall into. Word saves shapes and text boxes twice, on purpose: a modern version inside mc:AlternateContent > mc:Choice, and a fallback copy inside mc:Fallback for old versions of Word. Both contain the same text, so a naive count reports two text boxes where there's one, and their text appears twice. Every count needs to skip anything under a Fallback element:
const MC = 'http://schemas.openxmlformats.org/markup-compatibility/2006';
function isFallbackCopy(node: Element): boolean {
for (let parent = node.parentElement; parent; parent = parent.parentElement) {
if (parent.namespaceURI === MC && parent.localName === 'Fallback') return true;
}
return false;
}
Headers got their own check too. Lots of people put their name, email and phone in the Word header so it repeats on every page. Some ATS ignore headers and footers. If the email only appears in the header, the tool says so.
The rest of the checks are boring, which is the point
After the text is out, everything else is plain string work:
-
Contact details: an email regex, plus a phone check that requires 9 to 15 digits, because a loose phone regex happily matches a date range like
2019 - 2021. - Headings: short lines on their own that look like "Work experience", "Education" and "Skills". If "Where I've been" is the only heading for your jobs, you get a gentle nudge.
-
Dates: at least a couple of
Mar 2021 – Presentor03/2021style dates, since an ATS works out your years of experience from them. -
Length and file name:
CV (2).pdfandresume_final.docxlook like every other file in the pile.
All the judging lives in one pure function, checkAtsReadability(facts), in a shared package with unit tests. The browser code only gathers facts. That split made it easy to test the awkward cases (a scanned PDF, contact details only in a header, a fake two-column page) without touching a real file.
What it can't do
I want to be clear about this, because the job-search internet is full of tools promising an "ATS score":
- It isn't an ATS score. There's no such universal number. Every company configures its ATS differently, and no outside tool can see it. This tool tells you whether your file is readable, nothing more.
- PDF tables are invisible to it. A PDF doesn't record "this is a table", only lines and text at positions, so I only check tables in Word files.
- The column detector is a heuristic. It's right on my test CVs, but someone will have a layout that fools it. Showing the extracted text is the safety net.
Try it, break it
The tool is here: getlitjob.com/ats-cv-checker. It's free, there's no sign-up, and you can open your browser's network tab and check that your file never gets uploaded.
If you have a CV that it gets wrong (especially a weird layout that fools the column check), I'd genuinely like to hear about it in the comments. Heuristics only get better when someone breaks them.

Top comments (1)
The line that stuck with me: people don’t need your verdict, they need to see their sidebar text shuffled into their job history. That’s so true. A score tells someone something’s wrong, but seeing the scrambled text makes them actually fix it.
The icon-font finding was new to me too. I’ve seen a lot of Indian CV templates with phone/email/LinkedIn icons and a photo in the header, so I’d guess that check fires a lot.
Curious: how often does the two-column check fire on real uploads compared to your test CVs? I’d love to know how common those layouts actually are.