Skip to content

Backlight

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.pdf

Why this exists

Every 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.

Who it is for

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.

What it is not

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.

What it looks for

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 PDF Text rendering mode 3 or 7 — glyphs that paint nothing
PDF-COLOR PDF Glyphs with too little contrast against what is actually painted behind them
PDF-SIZE PDF Type whose effective size on the page is below legibility, after both matrices
PDF-ALPHA PDF Text drawn at zero fill alpha via an ExtGState
PDF-OOB PDF Text positioned outside the page box, including at negative coordinates
PDF-REDACT PDF Text under an opaque shape but still intact — the classic failed redaction
PDF-CLIP PDF 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 PDF 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 PDF Whether a signature's /ByteRange actually covers the whole file
PDF-INCR PDF Incremental updates: earlier revisions still sitting inside the file
PDF-DATES PDF Timestamps that cannot both be true

Placement — ordinary text parked where no reader looks.

ID Applies to What it finds
PDF-META PDF Passages in the info dictionary or XMP packet
PDF-ANNOT PDF 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 PDF A link whose visible text names one site while it points at another
PDF-EMBED PDF 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 PDF 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.

Calibration

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-COLOR measures WCAG contrast against the fill actually painted behind the glyphs. It catches #fefefe on white and it catches near-black on a dark banner. It is not looking for the string 1 1 1 rg.
  • Effective size, not the operand. PDF-SIZE multiplies through the text matrix and the CTM, so 12pt type scaled to 4% is reported at its real 0.48pt.
  • Styles are resolved. DOCX-VANISH follows w:rStyle through the style chain and docDefaults, 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-ZW does not flag ZWJ/ZWNJ inside Arabic, Persian, Indic or emoji text, where they do real typographic work. TXT-BIDI reports overrides, not the marks and isolates that ordinary mixed-direction text uses.
  • Phrasing needs corroboration. TXT-INJECT matches 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-CMAP refuses to guess. It only contradicts a /ToUnicode table 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. confirmed means structurally unambiguous; possible means 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.

Privacy

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).

Install

# one-off
npx backlight-scan cv.pdf

# or install it
npm install -g backlight-scan
backlight scan cv.pdf
backlight 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.

The report format

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
}

Development

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.

Test samples

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.

Contributing

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.

An honest limitation

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.

Licence

Apache-2.0.

About

See what the parser sees — hidden-text and document-integrity scanner for PDF/DOCX. Runs entirely in your browser; nothing is uploaded.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages