BlogExtraction8 min read

The PDF says it has text. The text is wrong.

A text layer is not evidence of readable text. Broken font encodings produce confident gibberish that no schema catches, and the fix is to detect it before you trust it.

Extracting text from a PDF is cheap and extracting it from a page image is not, so every pipeline learns the same optimisation: if the PDF has a text layer, use it, and only fall back to the model for scans.

The optimisation is correct. The test — does it have a text layer — is not, because a text layer can be present and wrong.

An absent text layer fails loudly and you handle it. A corrupt text layer returns confident nonsense that looks exactly like successful extraction.

Three kinds of PDF wearing the same extension

Born digital. Generated by software from real text. The text layer is authoritative, extraction is exact and free. Most invoices from accounting systems.

Scanned, then OCR'd. A scanner or a tool added a text layer underneath the image. Its quality is whatever that OCR was, which might be excellent or might be a decade-old engine on a skewed page. It looks identical to born-digital in every API.

Scanned, no text layer. Extraction returns nothing. Easy, because the failure is obvious.

The second category is the problem. Extraction succeeds, returns text, and that text may bear an intermittent relationship to what is printed on the page.

Why born-digital text can also be wrong

Even without OCR, a text layer can be garbage. PDF stores glyph codes plus a font mapping; extraction converts codes back to characters using the font's ToUnicode map. When that map is missing or wrong — subset-embedded fonts, certain LaTeX output, some CAD and design exports — the codes come back as whatever they happen to map to.

The two that hit real documents:

Ligatures. fi, fl, ffi are single glyphs in many fonts. Without a correct mapping, "confirmation" extracts as "conrmation" — a plausible-looking word with characters silently missing. No error, no replacement character.

Subset encodings. A font embedded with only its used glyphs, renumbered from

  1. Without ToUnicode, extracted text is a stream of control characters or apparently random letters. This one is at least visible.

The ligature case is the dangerous one because it degrades quietly. An invoice number extracts as INV-2026-0083 when the page says INV-2026-0083 — or the document total loses a digit and nothing complains.

Detect it, cheaply

Four checks, all fast, run before deciding to trust the layer:

type LayerVerdict =
  | { use: "text"; confidence: number }
  | { use: "image"; reason: string }

function assessTextLayer(page: PdfPage): LayerVerdict {
  const text = page.extractText()

  // 1. Density. A page of an invoice has hundreds of characters.
  //    A near-empty layer over a visually dense page means a scan.
  const chars = text.replace(/\s/g, "").length
  if (chars < 100 && page.hasLargeImage) {
    return { use: "image", reason: "sparse text over image" }
  }

  // 2. Replacement and control characters. Broken encoding, loudly.
  const bad = (text.match(/[��--]/g) ?? []).length
  if (bad / Math.max(chars, 1) > 0.02) {
    return { use: "image", reason: "encoding damage" }
  }

  // 3. Dictionary hit rate. OCR noise and subset encodings produce
  //    tokens that are not words in any language.
  const words = text.match(/[A-Za-z]{4,}/g) ?? []
  const known = words.filter(isDictionaryWord).length
  if (words.length > 20 && known / words.length < 0.6) {
    return { use: "image", reason: "low lexical plausibility" }
  }

  // 4. Missing ToUnicode on the fonts that carry most of the text.
  //    This is the ligature case, and it is the only one of the four
  //    that catches it before the damage is in your data.
  if (page.fonts.some((f) => f.usageShare > 0.3 && !f.hasToUnicode)) {
    return { use: "image", reason: "font lacks ToUnicode map" }
  }

  return { use: "text", confidence: known / Math.max(words.length, 1) }
}

Check four is the one worth adding even if you skip the others. It is a property of the file, it is exact rather than heuristic, and it catches the failure that the other three miss.

The cheap cross-check

Where you want more certainty and can afford one model call, extract both ways on a sample of pages and compare the fields — not the full text, which will always differ in whitespace:

// Agreement on the fields you care about is the only comparison that matters.
const fromText  = await extractFields(page.extractText())
const fromImage = await extractFields(page.render(150))

const disagreements = FIELDS.filter((f) => !equal(fromText[f], fromImage[f]))
if (disagreements.length > 0) {
  await flagSource(page.documentId, "text_layer_unreliable", disagreements)
}

Run it on a sample per source — per supplier, per upload channel — rather than per document. Text layer quality is a property of whatever produced the file, so one verdict usually covers everything from that origin. Store it on the supplier record and you pay for the check once.

Hybrid beats either

The best results come from using both, deliberately:

Text layer for the words, image for the layout. The text layer often has exact characters but no reliable spatial structure — reading order across columns is frequently wrong, and tables come out interleaved. The image preserves the geometry. Sending both, with the text layer as a transcript alongside the page image, is more accurate than either alone and costs little more than the image.

Text layer as a verifier. Extract from the image, then confirm that critical values appear in the text layer. A total the model read as 1,240.50 that appears nowhere in the text layer is worth a second look.

Do not send the text layer instead of the image for anything with a table. Column structure is exactly what a text layer loses, and it is exactly what a spanning table needs.

What it saves

The economics are worth stating, because they justify the detection work. A page image on a standard-tier model costs around 1,560 tokens. The same page as text is perhaps 800 tokens, and text tokens are the same price as image tokens.

So the saving is roughly half the input cost on documents where the layer is trustworthy — meaningful at volume, and completely wiped out by one silently corrupt extraction that reaches a ledger. Which is the argument for the four checks: they are cheap, and they turn an optimisation that sometimes corrupts data into one that only ever declines to help.


More in when extraction accuracy collapses, what a scanned page actually costs, and how we build extraction pipelines.

Something here

the audit is the cheapest way to find out for certain.