See what the parser sees.
A CV can carry text that a person never sees and a machine reads in full. Backlight finds it.
Drop a PDF or Word file in and it reports every place the visible document and the extractable document disagree — invisible rendering modes, white-on-white text, one-point type, zero-width characters, ASCII smuggled through Unicode tag codepoints, payloads parked in metadata and image alt text.
Try it → · The file never leaves your browser. There is no server.
npx backlight-scan cv.pdfEvery résumé you send goes through a parser before it reaches a person — an applicant tracking system, a keyword filter, increasingly a language model. That parser reads the text layer: the characters stored in the file. A human reads the rendered page: the pixels a viewer paints.
Nothing in either format requires those two to agree.
You can write a paragraph in a PDF with text rendering mode 3 and it paints nothing at all. You can
set a Word run to w:vanish and it disappears from the page while sitting in plain sight inside
document.xml. You can shrink type to one point, drop the fill alpha to zero, park text in an
image's alt text, or encode a whole message in codepoints that render as nothing anywhere.
Every one of those is invisible to the reader and fully legible to the machine. Backlight looks for the gap.
If you are applying for jobs — scan your own CV. Plenty of résumé builders and "ATS optimiser" tools inject keywords into metadata or hidden layers without telling you, and being caught with hidden text in your file is a far worse outcome than a slightly lower keyword score.
If you are hiring, or you build an ATS — your parser is reading text no human on your team has ever seen. Backlight shows you what it is reading.
It does not give verdicts. There is no score, no "clean" or "cheating" label, no ranking of candidates. Every finding shows the exact bytes and the exact location so a person can decide. Deciding is not a scanner's job.
It does not claim to detect AI-written text. Those detectors do not work reliably, and their errors fall hardest on people writing in a second language. Adding the feature would sell the tool faster and destroy its credibility on first contact with reality. It is out of scope on purpose.
A clean report is not proof of honesty. It means none of the techniques below are present in this file. It says nothing about whether the content is true.
Thirty detectors. backlight detectors prints the live list.
Hidden text — the words are there; you just cannot see them.
| ID | Applies to | What it finds |
|---|---|---|
PDF-TR |
Text rendering mode 3 or 7 — glyphs that paint nothing | |
PDF-COLOR |
Glyphs with too little contrast against what is actually painted behind them | |
PDF-SIZE |
Type whose effective size on the page is below legibility, after both matrices | |
PDF-ALPHA |
Text drawn at zero fill alpha via an ExtGState |
|
PDF-OOB |
Text positioned outside the page box, including at negative coordinates | |
PDF-REDACT |
Text under an opaque shape but still intact — the classic failed redaction | |
PDF-CLIP |
Text drawn first, then buried under an image | |
DOCX-VANISH |
DOCX | w:vanish / w:webHidden, whether set directly or inherited from a style |
DOCX-COLOR |
DOCX | Run colour with too little contrast against its resolved shading |
DOCX-SIZE |
DOCX | Runs under 5pt |
Document integrity — what the file says about itself, and whether it holds together.
| ID | Applies to | What it finds |
|---|---|---|
PDF-CMAP |
A font whose /ToUnicode table contradicts the encoding that drew the glyph — the page reads one way to a person and another to a parser |
|
PDF-SIG |
Whether a signature's /ByteRange actually covers the whole file |
|
PDF-INCR |
Incremental updates: earlier revisions still sitting inside the file | |
PDF-DATES |
Timestamps that cannot both be true |
Placement — ordinary text parked where no reader looks.
| ID | Applies to | What it finds |
|---|---|---|
PDF-META |
Passages in the info dictionary or XMP packet | |
PDF-ANNOT |
Text held in annotations, which never print | |
DOCX-META |
DOCX | Passages in docProps core, app or custom properties |
DOCX-ALT |
DOCX | Image alt text carrying more than a description |
DOCX-COMMENT |
DOCX | Review comments travelling inside the file |
File security — things a document can do, and things it can lie about.
| ID | Applies to | What it finds |
|---|---|---|
PDF-LINK |
A link whose visible text names one site while it points at another | |
PDF-EMBED |
Embedded JavaScript, launch actions, attachments, open actions | |
DOCX-FIELD |
DOCX | INCLUDETEXT, DDE, LINK and similar fields that pull in external content |
DOCX-BOMB |
DOCX | Decompression limits tripped, or an XML DOCTYPE declaration |
Characters and phrasing.
| ID | Applies to | What it finds |
|---|---|---|
TXT-ZW |
both | Zero-width codepoints beyond what the surrounding script legitimately needs |
TXT-BIDI |
both | Bidirectional overrides, which can make stored and displayed order differ |
TXT-TAG |
both | ASCII smuggled through the U+E0000 Unicode tag block |
TXT-HOMO |
both | One word built from Latin plus lookalike Cyrillic or Greek letters |
TXT-INJECT |
both | Wording that addresses the software reading the document rather than a person |
TXT-STUFF |
both | Repeated blocks, unreadable term lists, one term past any natural rate |
Structure.
| ID | Applies to | What it finds |
|---|---|---|
PDF-IMGONLY |
No text layer at all — a human reads it, an ATS extracts nothing |
Metadata, info dictionaries, XMP packets, annotations, Word field codes, comments and image alt text are all pulled into the same text model, so the character-level detectors cover them too.
Still to come: a render→OCR comparison pass that catches techniques nobody has thought of yet. See PLAN.md.
A scanner that fires on ordinary documents gets uninstalled in a week, so the false-positive work is treated as the hard part, not the polish:
- Contrast, not colour matching.
PDF-COLORmeasures WCAG contrast against the fill actually painted behind the glyphs. It catches#fefefeon white and it catches near-black on a dark banner. It is not looking for the string1 1 1 rg. - Effective size, not the operand.
PDF-SIZEmultiplies through the text matrix and the CTM, so 12pt type scaled to 4% is reported at its real 0.48pt. - Styles are resolved.
DOCX-VANISHfollowsw:rStylethrough the style chain anddocDefaults, so hiding text via a named style is not an escape. - Scanned pages are understood. Every searchable scan on earth paints an invisible OCR layer over an image. That is not concealment, and Backlight reports it as an observation instead of a finding. The distinction that matters is that concealment mixes visible and invisible text on a page; an OCR layer is invisible all the way through.
- Scripts are respected.
TXT-ZWdoes not flag ZWJ/ZWNJ inside Arabic, Persian, Indic or emoji text, where they do real typographic work.TXT-BIDIreports overrides, not the marks and isolates that ordinary mixed-direction text uses. - Phrasing needs corroboration.
TXT-INJECTmatches wording, which is the softest signal here, so it only counts when the text is also somewhere a reader would not see it — or when the phrase has no innocent reading in a document at all. An engineer who builds language-model systems will write "system prompt" in their own CV, in plain view, entirely honestly, and that is not a finding. PDF-CMAPrefuses to guess. It only contradicts a/ToUnicodetable when the font's encoding is genuinely authoritative — a non-subset face using a named standard encoding. Inside an embedded subset the byte codes are often just glyph indices with no inherent meaning, and comparing those against an assumed Latin encoding would invent mismatches on ordinary documents.- Confidence is separate from severity.
confirmedmeans structurally unambiguous;possiblemeans a benign explanation is common. Both ship with the evidence attached.
Calibration is also where the bugs are. TXT-STUFF originally reported a 424-item keyword list on a
perfectly ordinary CV, because the résumé builder that produced it emits one text-showing operator
per glyph and the detector was joining runs with newlines — so every individual letter counted as a
term. It now assembles the body with layout-aware joining, and reads the body only.
The test suite asserts three things: every technique in the matrix is detected, every registered detector has at least one sample, and clean documents produce zero findings at medium or above. All three have to pass.
The web version runs the entire scan in a Web Worker in your browser. There is no upload, no API, no
analytics on file contents, and no server that could receive the file even by mistake — the site is
static. packages/web/src/worker.ts imports the scanner and nothing
else; you can read the whole data path in about a minute.
The CLI is offline and has one runtime dependency (fflate, for decompression).
# one-off
npx backlight-scan cv.pdf
# or install it
npm install -g backlight-scan
backlight scan cv.pdfbacklight scan <file...> [options]
--json Emit the raw report as JSON
--sarif Emit SARIF 2.1.0 for code-scanning dashboards
--out <file> Write machine-readable output to a file
--fail-on <severity> Exit 1 when a finding at this level or above is present
--suppress <ids> Skip detectors, e.g. TXT-ZW,PDF-SIZE
--quiet Only print files that have findings
backlight samples <dir> Write the test corpus to a directory
backlight detectors List every detector and what it looks for
Exit codes: 0 clean or below threshold, 1 findings at or above --fail-on, 2 error — so
--fail-on high works as a gate in an intake pipeline.
Every consumer — the web UI, the CLI, the SARIF exporter — builds on one contract, defined in
packages/core/src/types.ts:
interface Finding {
id: string; // "PDF-TR" — stable, never renamed
category: Category;
severity: Severity; // critical | high | medium | low | info
confidence: Confidence;// confirmed | likely | possible
title: string;
location: { page?: number; xpath?: string; offset?: number; bbox?: [number,number,number,number] };
evidence: string; // what was actually found, escaped and truncated
rationale: string; // why it is a finding
remediation: string; // what to do
}npm install
npm test # the golden matrix
npm run build # core + cli
npm run dev # the web app
npm run samples # write the corpus to samples/out/The scanner parses PDF itself rather than using a viewer library. That is deliberate: a viewer exists to render a sane page from an insane file, so it normalizes, repairs and discards exactly the anomalies this project is looking for. A glyph painted with rendering mode 3 never reaches the caller. A detector has to see the file as written.
samples/ builds files that really do hide text with every technique in the matrix — but every
hidden block contains a labelled marker like CANARY_PDF_TR3_D01, never text written to persuade
anything.
That is not squeamishness, it is what the detectors need. Not one line in this codebase reads what
hidden text says; they test rendering mode, contrast, point size, alpha, w:vanish, codepoint
ranges. A detector that passes on a canary passes identically on any payload, so a realistic payload
would add exactly zero coverage while making the repository copy-pasteable. Markers also make
failures legible: when a test breaks, the assertion names the technique.
Same rule for new samples — label the technique, not the message.
New detectors are the most useful contribution, especially for techniques not yet covered. A
detector is one file in packages/core/src/detectors/ plus a sample in packages/core/src/samples/;
nothing else needs to change. See CONTRIBUTING.md.
Documents that produce a false positive are just as valuable. Open an issue with the file (or a minimal reproduction of it) and the finding it raised.
This is an arms race, and the current detectors are structural — they know the techniques they know. The strongest possible check does not depend on knowing any technique in advance: render each page to an image, OCR it, and compare that against the extracted text layer. Anything present in the extraction and absent from the render is hidden, whatever route was used to hide it. That pass is phase 2 and it runs in the browser too, via WASM.