Skip to main content

2. Text from scanned documents

Question: Can scanned documents be read reliably?

Rationale​

The oldest and often the most valuable documents are scans: census volumes, yearbooks from before desktop publishing, bound reports photographed page by page. They have no text layer, or an OCR layer produced years ago by whatever engine the scanner shipped with. Every later step (locating tables, extracting values, finding mentions) reads text, and OCR errors in numbers are the errors that reach a published series: a 1 read as l, a 0 read as O, a digit dropped from a total.

Modern OCR engines read printed tables well when the scan is adequate, and the choice of engine depends on the languages and scripts the organization publishes in. What matters as much as the engine is knowing which pages came out badly, so that they are re-processed or reviewed before extraction, and keeping the text layer with the document so that the work is done once.

Target state​

  • Every document in the inventory is marked as having a real text layer, an OCR layer, or neither, and the inventory says which need OCR.
  • OCR runs with an engine tested on the organization's own pages in each language, at a resolution that preserves digits.
  • Every OCR page has a quality score; pages above the threshold are re-processed or reviewed before extraction.
  • The text layer is stored with the document (a searchable PDF/A) and in the page text used for search and extraction.

Maturity levels​

LevelSteps
FoundationalMark scans in the inventory. Run OCR with one engine on the priority documents and keep the output with the document.
AI-readyTest two or three engines on a sample of the organization's pages per language; score every page; re-process or review pages over the threshold; store a searchable PDF/A.
AI-nativeRun OCR as a pipeline stage with the score as a gate; use a layout-aware model for complex pages; feed corrections back as test pages.
Running example

The 2014 census main report (EX-CEN-2014-01, 220 pages) has no text layer. One page of its OCR output, a population table, contains three garbled numbers and two garbled words. The quality score marks the page for re-OCR, and the lines to review are the table rows that extraction would otherwise read as values.

Recipes​

Recipe

2.1 Tell a scan from a text layer and mark it in the inventory

Level
Foundational
Skills
Developer
Indicative time
Minutes for a few hundred documents

Result: the inventory's text_layer column is correct for every document, and the organization knows how many pages need OCR.

A scanned PDF is a sequence of images; a PDF produced from a word processor contains text. The quickest test is to count the characters a PDF library can read from each page:

import fitz # PyMuPDF

doc = fitz.open("EX-CEN-2014-01.pdf")
chars = [len(page.get_text()) for page in doc]
text_pages = sum(1 for c in chars if c > 200)
print(f"{text_pages} of {len(doc)} pages have text")
  1. Run the count on a few pages of each PDF. A page with no characters, or with only a few, is an image. A page whose text contains long runs of symbols or no spaces has a broken OCR layer from an earlier tool.
  2. Mark the document text_layer = no when most pages are images, and ocr when an OCR layer exists. Keep the page counts, which are the OCR workload of chapter 9.
  3. Where an existing OCR layer is poor (recipe 2.3 scores it), treat the document as a scan and re-run OCR.

Worked example. The developer runs the count on the eight documents of the inventory. Seven report text on every page. The 2014 census report reports zero characters on 218 of its 220 pages, and the two pages that have text are the cover and the colophon, which were added later from a word processor. The inventory row for EX-CEN-2014-01 gets text_layer = no and the note "220 pages to OCR". The 2023 yearbook, which the inventory had marked ocr, reports text on every page, but the text of a sample page is a run of symbols without spaces, so its row is changed to no as well.

What this does not do. A character count does not tell whether the text is correct. Recipe 2.3 scores quality; recipe 2.2 chooses the engine.

Recipe

2.2 Choose and run the OCR engine for the organization's pages

Level
AI-ready
Skills
Developer
Indicative time
Two days to test; minutes per page to run

Result: the organization has chosen an OCR engine, its settings, and the scan resolution by measuring the candidates on its own pages in each language. The OCR output is stored as a searchable PDF beside the original.

An OCR engine is a program that reads text from an image of a page. Engines differ in the languages and scripts they support, in how well they read tables, and in the hardware they need. The measure to compare them on is the character error rate: the share of characters read wrongly, measured against pages whose correct text is known. The digit error rate matters most, because digits are what the tables contain.

  1. Build a test set: twenty pages per language and layout type (prose, tables, two-column), with the correct text of each typed or corrected by hand. Include the worst scans.
  2. Run candidate engines on the test set and compare the character error rate overall and on digits. The candidates:
    • Tesseract: the widest language coverage; run through OCRmyPDF, which produces a searchable PDF/A;
    • docTR and PaddleOCR: deep-learning engines with layout and table recognition;
    • Surya: 90-plus languages, with reading order and tables; check its licence for the organization's case;
    • olmOCR: a vision-language model that returns text with tables as Markdown, strongest on complex layouts and heaviest to run;
    • Docling: wraps several of these with layout analysis in one pipeline.
  3. Choose by digit error rate on tables first, then by language coverage and the hardware available. Record the engine, version, and settings in the pipeline's configuration.
  4. Run at 300 dots per inch, or the scan's native resolution if higher. Deskew and despeckle first (OCRmyPDF does both). Keep the output as PDF/A with the text layer positioned on the image, plus the page text as files for search and extraction.

Worked example. The developer builds a test set of sixty pages: twenty prose pages, twenty table pages, and twenty two-column pages, in English and French, with the worst scans of the census report among them. The correct text of each is typed by two assistants over three days. Three engines are run. On table pages, the first engine has a digit error rate of 2.1 percent, the second 0.9 percent, and the third 0.7 percent, but the third needs a GPU the organization does not have. The second engine is chosen. Its name, version, and settings go into the pipeline configuration, with the note "300 dpi, deskew on, French and English models loaded". The census report is run through it over a weekend, and the searchable PDF/A is stored beside the original scan.

What this does not do. A test set of twenty pages per type finds large differences between engines; small differences need more pages. A language whose script the engine does not support, or a historical typeface, needs a model trained for it, which the engines above allow.

Recipe

2.3 Score OCR quality page by page

Level
AI-ready
Skills
Python
Indicative time
Minutes per document

Result: every OCR page has a quality score and a list of lines to review, and pages over the threshold are marked for re-OCR before any extraction starts.

OCR errors in numbers have recognizable shapes: a letter O where a zero should be, a lowercase l where a one should be. The script reads the OCR text of one page, counts the number tokens that contain such garbles and the words broken by stray characters, and prints the lines to look at:

"""Score the quality of OCR text on a page and list the lines to review.

Reads the OCR output of one page (plain text) and reports the signs of
recognition errors that matter for data extraction:

digit garbles letters inside number tokens (1O,245; l,412; 98O)
broken words alphabetic tokens with no vowel, tokens that mix letters
and digits (Tab1e, popu1ation), or tokens with rare symbols
table lines lines with three or more number tokens, which are the
lines the extraction will read, with the count of
garbles in them

Prints a summary with the garble rate per number token and the lines to
review, and exits 0. A page whose rate exceeds the threshold is marked for
re-OCR with another engine or at a higher resolution. Standard library
only; no dictionary is used, so broken words are found by shape.

Usage:
python ocr_quality.py ocr_sample_page.txt [--threshold 0.03]

What this does not do: it finds errors by shape, so a wrong digit that is
still a digit (3 read as 8) is invisible to it; the total checks of the
verification chapter catch those. A page in a language with few vowels in
its romanization needs a different broken-word rule.
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

NUMBER = re.compile(r"^[\d,.\-%OolISB]+$")
GARBLE = re.compile(r"[OolISB]")
WORD = re.compile(r"^[A-Za-z][A-Za-z\-']*$")
RARE = re.compile(r"[~|¬¦`^]")
MIXED = re.compile(r"^(?=.*[A-Za-z]{2,})(?=.*\d)[A-Za-z\d]+$")
VOWELS = re.compile(r"[aeiouyAEIOUY]")


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("page", type=Path)
parser.add_argument(
"--threshold",
type=float,
default=0.03,
help="garble rate above which re-OCR is recommended",
)
args = parser.parse_args(argv)
lines = args.page.read_text(encoding="utf-8").splitlines()

numbers = garbled = 0
broken: list[str] = []
review: list[tuple[int, int, str]] = []
for i, line in enumerate(lines, start=1):
tokens = line.split()
line_numbers = [
t for t in tokens if NUMBER.match(t) and any(ch.isdigit() for ch in t)
]
line_garbled = [t for t in line_numbers if GARBLE.search(t)]
numbers += len(line_numbers)
garbled += len(line_garbled)
for t in tokens:
if (
WORD.match(t)
and len(t) > 2
and not VOWELS.search(t)
or RARE.search(t)
or (MIXED.match(t) and not NUMBER.match(t))
):
broken.append(f"{i}: {t}")
if len(line_numbers) >= 3 or line_garbled:
review.append((i, len(line_garbled), line))

rate = garbled / numbers if numbers else 0.0
print(
f"{len(lines)} lines, {numbers} number tokens, {garbled} garbled ({rate:.1%}), {len(broken)} broken words"
)
print("\nlines to review (garbles | line):")
for i, g, line in review:
print(f" {i:>3} {g:>2} | {line[:90]}")
if broken:
print("\nbroken words: " + ", ".join(broken))
verdict = "re-OCR recommended" if rate > args.threshold else "within threshold"
print(
f"\nverdict: {verdict} (garble rate {rate:.1%}, threshold {args.threshold:.0%})"
)
return 0


if __name__ == "__main__":
sys.exit(main())
python ocr_quality.py ocr_sample_page.txt

Output for the running example:

14 lines, 33 number tokens, 3 garbled (9.1%), 4 broken words

lines to review (garbles | line):
4 1 | Northern 1,284,310 631,2O5 653,105 412,880 871,430
5 0 | Central 2,106,944 1,039,517 1,067,427 1,203,118 903,826
6 1 | Southern 98O,775 482,310 498,465 301,224 679,551
7 1 | Eastern l,412,006 697,431 714,575 388,902 1,023,104
8 0 | Total 5,784,035 2,850,463 2,933,572 2,306,124 3,477,911
12 0 | Tbe census night was 14 to 15 August 2014. Enumeration lasted twelve days

broken words: 2: Tab1e, 9: popu1ation, 11: ~~, 11: ~~

verdict: re-OCR recommended (garble rate 9.1%, threshold 3%)

The first line counts the lines, the number tokens, and the garbled ones. The list that follows shows each line with a garble count, so that a reviewer can see at a glance which table rows are affected. The "broken words" line names words with a stray character, and the verdict compares the garble rate with the threshold.

  1. Run the score on every page of the OCR output and store the rate with the page in the inventory or the processing log.
  2. Read the lines to review for a sample. The garbled tokens in the table rows (631,2O5, 98O,775, l,412,006) are what the extraction of chapter 4 would otherwise record as values.
  3. Re-process a page over the threshold with another engine or at a higher resolution, or review its tables cell by cell.
  4. Set the threshold from the test set of recipe 2.2: the garble rate of pages whose digit error rate was acceptable.
  5. Keep corrected pages as test pages for the next engine change.

Worked example. The page above is a population table from the census report. Three of its 33 number tokens are garbled: 631,2O5 with a letter O, 98O,775 with a letter O, and l,412,006 with a letter l. The garble rate of 9.1 percent is three times the threshold, so the page is marked for re-OCR. Run again at 400 dots per inch with the second engine, the page comes back with zero garbled tokens, and the three values read 631,205, 980,775, and 1,412,006. The total check of chapter 5 later confirms that the four regions sum to the printed total of 5,784,035. The corrected page joins the test set.

What this does not do. The score finds errors by shape. A 3 read as 8 is a digit and invisible here. The total and derived-rate checks of chapter 5 catch those, which is why both run.

Implementation options​

  • Detection of scans: character counts per page with PyMuPDF or pdfplumber; a stored text_layer value of yes, ocr, or no in the inventory.
  • Engines: Tesseract through OCRmyPDF for breadth and a searchable PDF/A; docTR, PaddleOCR, or Surya for deep-learning recognition with tables; olmOCR or another vision-language model for complex layouts; Docling as a single pipeline.
  • Quality: the shape-based score in this chapter per page; character error rate on a test set for engine choice; engine confidence where the engine reports it.
  • Storage: PDF/A with text layer beside the original; page text files; the score in the processing log.

World Bank examples​

  • The Data Snapshots benchmark covers institutional documents including scanned pages, and its dataset is a source of test pages.
  • The program's document parser reads the page text that this chapter produces for scans.

Resources​

  • OCRmyPDF: Tesseract-based OCR that outputs a searchable PDF/A with deskew and clean-up; the default for a first pipeline.
  • Tesseract: the open-source engine with trained data for more than 100 languages.
  • docTR, PaddleOCR, and Surya: deep-learning OCR with layout and table recognition, for pages where Tesseract fails.
  • olmOCR: a vision-language OCR toolkit (Apache 2.0) that returns structured text from complex pages.
  • Docling: document conversion with OCR, layout, and tables in one pipeline.
  • Character error rate (jiwer): the library used to compute error rates against the corrected test pages.

Common mistakes​

  • Trusting the scanner's OCR layer. Old layers garble digits; score them before using them.
  • One engine for every language. Test per language and script on the organization's own pages.
  • Low resolution. Digits lose their shape below 300 dots per inch; re-scan or upscale before OCR.
  • No page score. Without it, bad pages reach extraction and the errors reach the series.

Verification​

  • Inventory. Every document has a text_layer value that was checked on a sample of pages.
  • Engine. The choice of engine is recorded with its digit error rate on the test set.
  • Scores. Every OCR page has a score, and the pages over the threshold are listed and handled.
  • Storage. The searchable PDF/A opens with selectable text positioned on the image.

Checklist​

0 of 6 done

Ticks are saved in this browser only.