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
"""
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
/**
* 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.
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
Page Characters Images Decision 1 224 0 readable 2 224 0 readable 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
Page Characters Images Decision 1 0 1 to OCR 2 0 1 to OCR 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
Page Characters Images Decision 1 224 0 readable 2 0 1 to OCR 3 224 0 readable 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
Page Characters Images Decision 1 28 1 to OCR 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
Page Characters Images Decision 1 183 0 readable 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
- RecommendedN0Characters and images counted page by page, by a PDF libraryFree · Instant
- N1Letter pairs, learned from your own readable pagesnégligeable · Instant
- N2Not worth it here
- 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
"""
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
/**
* 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
- pypdf — la bibliothèque de lecture PDF en Python
- pdf.js — la bibliothèque de lecture PDF de Mozilla
- ISO 32000-1 (PDF 1.7), la spécification publiée par Adobe
- Lire le texte d’une page scannée — la fiche qui traite l’étape suivante
Where the figures come from
- pypdf, documentation de extract_text (consultée) : ce que la bibliothèque rend, et ce qu’elle ne reconstruit pas
- pdf.js, API getTextContent et getOperatorList (consultée) : les éléments de texte d’une page et les opérateurs de tracé, dont ceux qui peignent une image
- ISO 32000-1, section 9.10 (consultée) : la table ToUnicode, et ce qui arrive à l’extraction quand elle manque
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.