Know whether a PDF needs OCR

Decide, across a pile of PDFs, which pages to send to character recognition and which ones already read.

No AI needed

FreeInstantYour data never leaves

The verdict

N0, because the decision fits in two numbers the file already carries — the characters it yields and the images it draws — and because being wrong costs asymmetrically: paying to OCR a readable page costs money, calling a scanned page readable loses its content. The 120-character threshold exists for that reason, and this entry shows what it changes: a scanned page carrying a 28-character header is a scan, where the “is there any text” rule, with a low threshold, calls it readable. Move up to N1 the day you find, among the pages called readable, text that is not text: it scores letter pairs against your own pages, and it catches the broken page N0 lets through. Know what it costs before you do: it also turns down financial annexes, which are far from a negligible share of a document set, and every one of them will go to character recognition.

Characters and images counted page by page, by a PDF library

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/know-whether-a-pdf-needs-ocr/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Sort a pile of PDFs into the pages that need OCR and the pages that do not.

Rung N0. OCR and vision models are billed by the page, and most of the pages
in an archive do not need either: a PDF written by an accounting tool, a word
processor or a print driver already carries its text, exactly, next to its
drawing instructions. Reading that text costs nothing and cannot be wrong,
because no recognition happened.

So the decision is taken per page, not per document. A contract exported from a
word processor with a scanned signature page at the end is one file with two
kinds of page in it, and sending the whole thing to OCR pays for the pages that
were already readable.

Two numbers per page are enough, and this is the part a model would replace
with a guess: how many non-space characters the page yields, and how many
images it draws. Text and no image is a page to leave alone. An image and
almost no text is a scan — and « almost » matters, because a scanned page
often carries a header or a page number in real text, which a rule that only
asks « is there any text » counts as a readable page.

`pypdf` reads the page here, `pdf.js` in JavaScript. Neither is asked to
recognise anything.
"""

from __future__ import annotations

import io

# A page of an ordinary document yields a few thousand characters; a header, a
# reference or a page number yields a few dozen. Anything under this, with an
# image on the page, is a scan wearing a label. Raise it for dense documents,
# lower it for forms.
MIN_CHARACTERS = 120


def triage_pages(pdf_bytes, *, min_characters: int = MIN_CHARACTERS, reader=None) -> dict:
    """
    Say, page by page, which ones have to go to OCR.

    The answer is a plan: `needs_ocr` is the list of page numbers to pay for,
    `readable` the ones to read for free, `unreadable` the ones that carry
    neither text nor image and that OCR would not help either.

    `reader` is injected by the tests; in production it is `pypdf.PdfReader`.
    """
    if reader is None:  # imported here so the rest of this file needs nothing
        from pypdf import PdfReader as reader

    try:
        document = reader(io.BytesIO(pdf_bytes))
        pages = list(document.pages)
    except Exception as error:  # noqa: BLE001 - a broken file is an answer too
        return {"pages": [], "needs_ocr": [], "readable": [], "unreadable": [],
                "reason": f"this file could not be opened as a PDF: {error}"}

    report = []
    for number, page in enumerate(pages, start=1):
        text = page.extract_text() or ""
        characters = sum(1 for character in text if not character.isspace())
        images = len(page.images)
        report.append({
            "page": number,
            "characters": characters,
            "images": images,
            "verdict": _verdict(characters, images, min_characters),
        })
    return {
        "pages": report,
        "needs_ocr": [p["page"] for p in report if p["verdict"] == "scan"],
        "readable": [p["page"] for p in report if p["verdict"] == "text"],
        "unreadable": [p["page"] for p in report if p["verdict"] == "blank"],
        "reason": None,
    }


def _verdict(characters: int, images: int, min_characters: int) -> str:
    # The order matters. A page with enough text is readable whatever it also
    # draws; below that, an image is what OCR is for, and nothing at all is a
    # page OCR would return empty — which is worth knowing before paying.
    if characters >= min_characters:
        return "text"
    return "scan" if images else "blank"

JavaScript

snippets/know-whether-a-pdf-needs-ocr/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Sort a pile of PDFs into the pages that need OCR and the pages that do not.
 *
 * Rung N0. OCR and vision models are billed by the page, and most of the pages
 * in an archive do not need either: a PDF written by an accounting tool, a
 * word processor or a print driver already carries its text, exactly, next to
 * its drawing instructions. Reading that text costs nothing and cannot be
 * wrong, because no recognition happened.
 *
 * So the decision is taken per page, not per document. A contract exported
 * from a word processor with a scanned signature page at the end is one file
 * with two kinds of page in it, and sending the whole thing to OCR pays for
 * the pages that were already readable.
 *
 * Two numbers per page are enough, and this is the part a model would replace
 * with a guess: how many non-space characters the page yields, and how many
 * images it draws. Text and no image is a page to leave alone. An image and
 * almost no text is a scan — and « almost » matters, because a scanned page
 * often carries a header or a page number in real text, which a rule that only
 * asks « is there any text » counts as a readable page.
 *
 * `pdf.js` reads the page here, `pypdf` in Python. Neither is asked to
 * recognise anything.
 */

// A page of an ordinary document yields a few thousand characters; a header, a
// reference or a page number yields a few dozen. Anything under this, with an
// image on the page, is a scan wearing a label. Raise it for dense documents,
// lower it for forms.
export const MIN_CHARACTERS = 120;

/**
 * Say, page by page, which ones have to go to OCR.
 *
 * The answer is a plan: `needs_ocr` is the list of page numbers to pay for,
 * `readable` the ones to read for free, `unreadable` the ones that carry
 * neither text nor image and that OCR would not help either.
 *
 * `load` is injected by the tests; in production it is pdf.js.
 */
export async function triagePages(pdfBytes, { minCharacters = MIN_CHARACTERS, load } = {}) {
  // Imported here, and only here: nothing else in this file needs it.
  const pdfjs = load ?? (await import('pdfjs-dist/legacy/build/pdf.mjs'));
  const painted = new Set([
    pdfjs.OPS.paintImageXObject,
    pdfjs.OPS.paintInlineImageXObject,
    pdfjs.OPS.paintImageMaskXObject,
    pdfjs.OPS.paintImageXObjectRepeat,
  ]);

  const report = [];
  try {
    const document = await pdfjs.getDocument({
      data: new Uint8Array(pdfBytes), isEvalSupported: false,
    }).promise;
    for (let number = 1; number <= document.numPages; number += 1) {
      /* eslint-disable no-await-in-loop -- pdf.js hands pages out one at a time */
      const page = await document.getPage(number);
      const content = await page.getTextContent();
      const operators = await page.getOperatorList();
      /* eslint-enable no-await-in-loop */
      const text = content.items.map((item) => item.str ?? '').join('');
      const characters = [...text].filter((character) => !/\s/u.test(character)).length;
      const images = operators.fnArray.filter((code) => painted.has(code)).length;
      report.push({ page: number, characters, images, verdict: verdictOf(characters, images, minCharacters) });
    }
  } catch (error) {
    return { pages: [], needs_ocr: [], readable: [], unreadable: [],
      reason: `this file could not be opened as a PDF: ${error.message}` };
  }

  const numbers = (kind) => report.filter((p) => p.verdict === kind).map((p) => p.page);
  return {
    pages: report,
    needs_ocr: numbers('scan'),
    readable: numbers('text'),
    unreadable: numbers('blank'),
    reason: null,
  };
}

function verdictOf(characters, images, minCharacters) {
  // The order matters. A page with enough text is readable whatever it also
  // draws; below that, an image is what OCR is for, and nothing at all is a
  // page OCR would return empty — which is worth knowing before paying.
  if (characters >= minCharacters) return 'text';
  return images ? 'scan' : 'blank';
}

Try it

This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.

Five documents, and the triage plan the snippet derives from them. The outputs are the code’s own, run when the site is built: nothing is written by hand.

  1. A contract exported from a word processor

    Given

    Two pages of text, no image: nothing to pay for.

    What it returns

    0 page(s) to send to OCR, 2 to read for free

    Two pages of text, no image: nothing to pay for.

    In detail

    PageCharactersImagesDecision
    12240readable
    22240readable
  2. A document scanned from end to end

    Given

    Two image pages, no characters: both go to OCR.

    What it returns

    2 page(s) to send to OCR, 0 to read for free

    Two image pages, no characters: both go to OCR.

    In detail

    PageCharactersImagesDecision
    101to OCR
    201to OCR
  3. A contract with a single scanned page

    Given

    Three pages, one to pay for: this is what per-document triage costs you.

    What it returns

    1 page(s) to send to OCR, 2 to read for free

    Three pages, one to pay for: this is what per-document triage costs you.

    In detail

    PageCharactersImagesDecision
    12240readable
    201to OCR
    32240readable
  4. A scanned page carrying a header in real text

    Given

    Twenty-eight characters and one image: a rule that asks “is there any text” would call it readable.

    What it returns

    1 page(s) to send to OCR, 0 to read for free

    Twenty-eight characters and one image: a rule that asks “is there any text” would call it readable.

    In detail

    PageCharactersImagesDecision
    1281to OCR
  5. A page whose font carries no character map

    Given

    A hundred and eighty-three characters, no image: filed among the readable pages.

    What it returns

    0 page(s) to send to OCR, 1 to read for free

    A hundred and eighty-three characters, no image: filed among the readable pages.

    In detail

    PageCharactersImagesDecision
    11830readable

    Here it breaksThe page yields “#$%&!*+,-./0123” where it shows a paragraph, and counting characters cannot tell: it is called readable. That is this level’s breaking point, and it is what level N1 catches by scoring letter pairs against those of the pages you already trust.See the level above

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • Depends on a library
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the document is read inside your own process and is sent to nobody
  • The triage decides what leaves next: a page called readable will not go to an OCR provider, a page sent to OCR will

Breaking point

An unreadable text layer counts as text: a page whose font carries no character map extracts as “#$%&!*+,-./0123” where it shows a paragraph, and its 183 characters file it among the readable pages. The witness is in the same test: an ordinary contract page yields 224 and gets exactly the same verdict.

When to move up a level

You read back pages that were called readable and find text that is not text: runs of punctuation, letters that form no words, a whole page that no full-text search ever finds.

The four options

  1. RecommendedN0Characters and images counted page by page, by a PDF libraryFree · Instant
  2. N1Letter pairs, learned from your own readable pagesnégligeable · Instant
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N1Letter pairs, learned from your own readable pagesnégligeable · Instant

Letter pairs, learned from your own readable pages

Lightweight classic modelN1négligeableInstant

Python

snippets/know-whether-a-pdf-needs-ocr/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Tell a usable text layer from a broken one, on the pages that claim to have one.

Rung N1. Rung N0 counts characters, and a text layer made of the wrong
characters counts just as high: a PDF whose font carries no character map, or a
broken one, extracts as glyph indices read as ASCII — «#$%&'*+,-./0123» where
the page shows a paragraph. N0 calls that page readable and it is not.

Telling language from that is what a character model does, and it is the
smallest one there is: the frequency of each pair of letters. «qu» and «es» are
common in French, «q#» and «&'» are not, and a page scores by how well its
pairs match the pairs of pages you already trust.

Nothing is shipped with this model. `fit` is given the text of your own
readable pages — N0 has just listed them — so the reference is your documents,
in your language, with your vocabulary. The threshold comes from the same
place: the lowest score among the training pages, with a margin, rather than a
number chosen here.

What this cannot do is read. A page of figures, a table of amounts, a form of
short fields: these are real text that no language model of letters will call
language, and the score says so. It separates letters from noise, not sense
from nonsense.
"""

from __future__ import annotations

import math

# The letters this model counts. Anything else — digits, punctuation, symbols
# — becomes a single « other » class, which is exactly what a broken character
# map produces in quantity.
ALPHABET = "abcdefghijklmnopqrstuvwxyzàâäçéèêëîïôöùûüÿœæ "
OTHER = "#"
SYMBOLS = ALPHABET + OTHER

# How far below the worst training page a page may score before it is called
# unreadable.
#
# Half a nat per pair, and the number comes from pages that are NOT in the
# training set: four ordinary French administrative pages — an amendment, an
# article of the civil code, an acknowledgement letter, an invoice heading —
# score between −2.42 and −2.83 against a model fitted on six contract pages
# whose worst is −2.48. The furthest sits 0.35 below it, so three tenths would
# refuse it, and did. Half a nat clears it and still refuses an English page,
# 0.65 below, and a table of amounts, 1.66 below.
#
# It is a default, not a constant: it was measured on six training pages and
# four held-out ones, which is small. Keep a handful of your own readable pages
# out of `fit`, score them, and move this number until they all pass — that is
# the only calibration that says anything about your own documents.
MARGIN = 0.5


def normalise(text: str) -> str:
    """Lower case, and every symbol outside the alphabet folded into one."""
    return "".join(c if c in ALPHABET else OTHER for c in text.lower())


def fit(pages) -> dict:
    """Count the letter pairs of pages you trust, and set the threshold from them."""
    counts = {a: {b: 1 for b in SYMBOLS} for a in SYMBOLS}  # add-one smoothing
    for page in pages:
        clean = normalise(page)
        for first, second in zip(clean, clean[1:]):
            counts[first][second] += 1
    totals = {a: sum(row.values()) for a, row in counts.items()}
    model = {"log_probability": {a: {b: math.log(counts[a][b] / totals[a]) for b in SYMBOLS}
                                 for a in SYMBOLS}}
    scores = [score(page, model) for page in pages]
    if not scores:
        # A model fitted on nothing would answer « readable » to everything,
        # which is the expensive mistake of this entry: a page declared
        # readable that is not one goes into the index empty, and nobody looks
        # at it again.
        raise ValueError("fit needs at least one page you have read yourself")
    model["threshold"] = min(scores) - MARGIN
    return model


def score(text: str, model: dict) -> float:
    """Mean log probability of the letter pairs, or -inf on a page too short."""
    clean = normalise(text)
    pairs = list(zip(clean, clean[1:]))
    if not pairs:
        return float("-inf")
    table = model["log_probability"]
    return sum(table[a][b] for a, b in pairs) / len(pairs)


def is_readable(text: str, model: dict) -> dict:
    """Say whether this text layer looks like the pages the model was fitted on."""
    value = score(text, model)
    return {"readable": value >= model["threshold"], "score": value,
            "threshold": model["threshold"]}

JavaScript

snippets/know-whether-a-pdf-needs-ocr/n1.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Tell a usable text layer from a broken one, on the pages that claim to have one.
 *
 * Rung N1. Rung N0 counts characters, and a text layer made of the wrong
 * characters counts just as high: a PDF whose font carries no character map,
 * or a broken one, extracts as glyph indices read as ASCII —
 * «#$%&'*+,-./0123» where the page shows a paragraph. N0 calls that page
 * readable and it is not.
 *
 * Telling language from that is what a character model does, and it is the
 * smallest one there is: the frequency of each pair of letters. «qu» and «es»
 * are common in French, «q#» and «&'» are not, and a page scores by how well
 * its pairs match the pairs of pages you already trust.
 *
 * Nothing is shipped with this model. `fit` is given the text of your own
 * readable pages — N0 has just listed them — so the reference is your
 * documents, in your language, with your vocabulary. The threshold comes from
 * the same place: the lowest score among the training pages, with a margin,
 * rather than a number chosen here.
 *
 * What this cannot do is read. A page of figures, a table of amounts, a form
 * of short fields: these are real text that no language model of letters will
 * call language, and the score says so. It separates letters from noise, not
 * sense from nonsense.
 */

// The letters this model counts. Anything else — digits, punctuation, symbols
// — becomes a single « other » class, which is exactly what a broken character
// map produces in quantity.
export const ALPHABET = "abcdefghijklmnopqrstuvwxyzàâäçéèêëîïôöùûüÿœæ ";
export const OTHER = '#';
const SYMBOLS = [...(ALPHABET + OTHER)];

// How far below the worst training page a page may score before it is called
// unreadable.
//
// Half a nat per pair, and the number comes from pages that are NOT in the
// training set: four ordinary French administrative pages — an amendment, an
// article of the civil code, an acknowledgement letter, an invoice heading —
// score between −2.42 and −2.83 against a model fitted on six contract pages
// whose worst is −2.48. The furthest sits 0.35 below it, so three tenths would
// refuse it, and did. Half a nat clears it and still refuses an English page,
// 0.65 below, and a table of amounts, 1.66 below.
//
// It is a default, not a constant: it was measured on six training pages and
// four held-out ones, which is small. Keep a handful of your own readable
// pages out of `fit`, score them, and move this number until they all pass —
// that is the only calibration that says anything about your own documents.
export const MARGIN = 0.5;

/** Lower case, and every symbol outside the alphabet folded into one. */
export function normalise(text) {
  return [...text.toLowerCase()].map((c) => (ALPHABET.includes(c) ? c : OTHER)).join('');
}

/** Count the letter pairs of pages you trust, and set the threshold from them. */
export function fit(pages) {
  const counts = new Map(SYMBOLS.map((a) => [a, new Map(SYMBOLS.map((b) => [b, 1]))])); // add-one
  for (const page of pages) {
    const clean = normalise(page);
    for (let i = 0; i + 1 < clean.length; i += 1) {
      const row = counts.get(clean[i]);
      row.set(clean[i + 1], row.get(clean[i + 1]) + 1);
    }
  }
  const logProbability = new Map();
  for (const [a, row] of counts) {
    const total = [...row.values()].reduce((sum, n) => sum + n, 0);
    logProbability.set(a, new Map([...row].map(([b, n]) => [b, Math.log(n / total)])));
  }
  const model = { logProbability, threshold: -Infinity };
  const scores = pages.map((page) => score(page, model));
  if (scores.length === 0) {
    // A model fitted on nothing would answer « readable » to everything, which
    // is the expensive mistake of this entry: a page declared readable that is
    // not one goes into the index empty, and nobody looks at it again.
    throw new RangeError('fit needs at least one page you have read yourself');
  }
  model.threshold = Math.min(...scores) - MARGIN;
  return model;
}

/** Mean log probability of the letter pairs, or -Infinity on a page too short. */
export function score(text, model) {
  const clean = normalise(text);
  if (clean.length < 2) return -Infinity;
  let total = 0;
  for (let i = 0; i + 1 < clean.length; i += 1) {
    total += model.logProbability.get(clean[i]).get(clean[i + 1]);
  }
  return total / (clean.length - 1);
}

/** Say whether this text layer looks like the pages the model was fitted on. */
export function isReadable(text, model) {
  const value = score(text, model);
  return { readable: value >= model.threshold, score: value, threshold: model.threshold };
}

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the model is fitted on your documents, inside your own infrastructure, and is sent to nobody
  • The training set is made of your pages: what they hold stays in the model as letter-pair frequencies

Breaking point

It separates letters from noise, not sense from nonsense: a table of amounts — 12/01/2026 1 250,00 4 300,50 — scores −4.10 against a threshold of −2.98, like the broken page, although it is text read perfectly. Witness in the same test: a commercial letter absent from the training set scores −2.83.

When to move up a level

Your documents are not prose — slips, tables, financial annexes, forms of short fields — and the score turns down pages nobody needs to look at again.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

A page-image classifier — scan against born-digital — exists and works, but it requires rendering every page to an image before deciding, that is, paying for the heaviest step in the chain in order to find out whether to run it. The two numbers level N0 reads are already in the file, with no rendering, and they answer the same question.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

Sending each page’s image to a vision model to find out whether to send it to a vision model is the one level that contradicts itself in a sentence: the call you are trying to avoid is the call you have to make in order to decide. It also adds one data egress per page across a store the triage existed to keep at home.

Further reading

Where the figures come from

Metadata