Article

import io
import os
import re
import sys
import time
import shutil
import logging
import textwrap
import subprocess
from pathlib import Path

INSTALL_JBIG2 = True

def sh(cmd: str, check: bool = True) -> int:
   """Run a shell command, echo it, and show the tail of its output."""
   print(f"  $ {cmd}")
   r = subprocess.run(cmd, shell=True, text=True,
                      stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
   if r.stdout and r.stdout.strip():
       for ln in r.stdout.strip().splitlines()[-12:]:
           print("    " + ln)
   if check and r.returncode != 0:
       raise RuntimeError(f"Command failed ({r.returncode}): {cmd}")
   return r.returncode

def install_dependencies() -> None:
   """Install OCRmyPDF's system + Python dependencies for Colab/Ubuntu."""
   apt_pkgs = (
       "tesseract-ocr tesseract-ocr-eng tesseract-ocr-osd "
       "tesseract-ocr-deu tesseract-ocr-fra "
       "ghostscript unpaper pngquant poppler-utils qpdf"
   )
   sh("apt-get update -qq", check=False)
   sh(f"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {apt_pkgs}")
   sh(f'"{sys.executable}" -m pip install -q --upgrade ocrmypdf img2pdf "pillow<12"')
   if INSTALL_JBIG2 and shutil.which("jbig2") is None:
       try:
           build_pkgs = ("autoconf automake libtool pkg-config "
                         "libleptonica-dev zlib1g-dev build-essential git")
           sh(f"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {build_pkgs}")
           sh("rm -rf /tmp/jbig2enc && "
              "git clone -q https://github.com/agl/jbig2enc.git /tmp/jbig2enc")
           sh("cd /tmp/jbig2enc && ./autogen.sh >/dev/null 2>&1 && "
              "./configure >/dev/null 2>&1 && make -j2 >/dev/null 2>&1 && "
              "make install >/dev/null 2>&1 && ldconfig")
           print("  jbig2enc:",
                 "installed" if shutil.which("jbig2") else "built, but binary not on PATH")
       except Exception as e:
           print("  jbig2enc build skipped (optional):", e)

def ensure_installed() -> None:
   have_tools = bool(shutil.which("tesseract") and shutil.which("gs"))
   try:
       import ocrmypdf
       import img2pdf
       from PIL import Image
       have_py = True
   except Exception:
       have_py = False
   if have_tools and have_py:
       print("Dependencies already present — skipping installation.")
   else:
       print("Installing dependencies (first run can take a few minutes)...")
       install_dependencies()

ensure_installed()

def _purge(*prefixes):
   for name in [m for m in list(sys.modules)
                if any(m == p or m.startswith(p + ".") for p in prefixes)]:
       del sys.modules[name]

def _load_ocrmypdf():
   _purge("PIL", "ocrmypdf")
   import ocrmypdf
   return ocrmypdf

try:
   ocrmypdf = _load_ocrmypdf()
except ImportError as e:
   if "_Ink" in str(e) or "PIL" in str(e):
       print("Repairing an incompatible Pillow (reinstalling pillow<12)...")
       sh(f'"{sys.executable}" -m pip install -q --force-reinstall "pillow<12"')
       try:
           ocrmypdf = _load_ocrmypdf()
           print("Pillow repaired — continuing without a restart.")
       except Exception:
           raise RuntimeError(
               "Pillow is still incompatible in this session. Use the Colab menu: "
               "Runtime > Restart session, then run this cell again."
           )
   else:
       raise

from ocrmypdf.exceptions import (
   ExitCode,
   PriorOcrFoundError,
   EncryptedPdfError,
   MissingDependencyError,
   TaggedPDFError,
   DigitalSignatureError,
   DpiError,
   InputFileError,
   UnsupportedImageFormatError,
)

from ocrmypdf.helpers import check_pdf
from ocrmypdf.pdfa import file_claims_pdfa
import img2pdf
from PIL import Image, ImageDraw, ImageFont, ImageFilter

logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
logging.getLogger("ocrmypdf").setLevel(logging.WARNING)
logging.getLogger("pdfminer").setLevel(logging.ERROR)
logging.getLogger("PIL").setLevel(logging.WARNING)

SAMPLE_TEXT_PAGES = [
   "Optical Character Recognition, commonly abbreviated as OCR, is the "
   "process of converting images of typed or printed text into machine "
   "encoded text. This page was generated as a synthetic scan so that the "
   "OCRmyPDF pipeline has something realistic to recognize and search.",
   "On 14 March 2026 the archive contained 1,482 pages across 37 folders. "
   "Roughly 92 percent of those pages were scanned at 200 to 300 dots per "
   "inch. The remaining 8 percent were skewed and required deskewing before "
   "any reliable recognition was possible.",
   "After OCRmyPDF finishes, the output is a searchable PDF/A file. You can "
   "select text, copy it, and run full text search across thousands of "
   "documents. The original image resolution is preserved while a hidden "
   "text layer is placed accurately underneath the page image.",
]

def _find_font():
   for cand in (
       "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
       "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
   ):
       if os.path.exists(cand):
           return cand
   return None

_FONT_PATH = _find_font()
FONT = ImageFont.truetype(_FONT_PATH, 40) if _FONT_PATH else ImageFont.load_default()

def _add_speckle(img, n=6000, dark=60):
   """Sprinkle light dark specks to imitate scanner noise (motivates --clean)."""
   import random
   px = img.load()
   w, h = img.size
   for _ in range(n):
       px[random.randint(0, w - 1), random.randint(0, h - 1)] = random.randint(0, dark)
   return img

def render_page(text, skew=False):
   """Render one A4 page (1654x2339 px ≈ 200 DPI) of dark text on white."""
   W, H = 1654, 2339
   img = Image.new("L", (W, H), 255)
   draw = ImageDraw.Draw(img)
   draw.multiline_text((150, 180), textwrap.fill(text, width=58),
                       fill=25, font=FONT, spacing=18)
   if skew:
       img = img.rotate(6, resample=Image.BICUBIC, expand=False, fillcolor=255)
       img = img.filter(ImageFilter.GaussianBlur(0.6))
       img = _add_speckle(img)
   return img

def build_scanned_pdf(pdf_path: Path, pages_text, skew_index=1):
   """Render pages to PNGs and wrap them losslessly into an image-only PDF."""
   pngs = []
   for i, text in enumerate(pages_text):
       img = render_page(text, skew=(i == skew_index))
       p = pdf_path.parent / f"_pg_{pdf_path.stem}_{i}.png"
       img.save(p, format="PNG", dpi=(200, 200))
       pngs.append(str(p))
   with open(pdf_path, "wb") as f:
       f.write(img2pdf.convert(pngs))
   for p in pngs:
       os.remove(p)
   return pdf_path

def do_ocr(input_file, output_file, **kw):
   """Wrapper around ocrmypdf.ocr() that disables the progress bar and times it."""
   kw.setdefault("progress_bar", False)
   t0 = time.perf_counter()
   rc = ocrmypdf.ocr(input_file, output_file, **kw)
   return rc, time.perf_counter() - t0

def tokens(s: str):
   return re.findall(r"[a-z0-9]+", s.lower())

def kb(path) -> str:
   return f"{Path(path).stat().st_size / 1024:,.1f} KB"

def banner(title: str):
   line = "─" * 74
   print(f"\n{line}\n  {title}\n{line}")

banner("0 ·  Environment")
print("Python  :", sys.version.split()[0])
print("ocrmypdf:", ocrmypdf.__version__)
sh("tesseract --version", check=False)
sh("gs --version", check=False)
sh("tesseract --list-langs", check=False)
print("unpaper :", shutil.which("unpaper"))
print("pngquant:", shutil.which("pngquant"))
print("jbig2   :", shutil.which("jbig2"), "(optional encoder)")

WORK = Path("/content/ocrmypdf_demo")
try:
   WORK.mkdir(parents=True, exist_ok=True)
except Exception:
   WORK = Path.cwd() / "ocrmypdf_demo"
   WORK.mkdir(parents=True, exist_ok=True)

print("Workdir :", WORK)

banner("1 ·  Build a synthetic image-only 'scanned' PDF")
input_pdf = WORK / "scanned_input.pdf"
build_scanned_pdf(input_pdf, SAMPLE_TEXT_PAGES, skew_index=1)
print(f"Created {input_pdf.name}  ({kb(input_pdf)}, 3 pages; page 2 is skewed + speckled)")
print("This PDF has NO text layer yet — selecting/searching it returns nothing.")

banner("2 ·  Basic OCR  (deskew + auto-rotate)")
out_basic = WORK / "out_basic.pdf"
rc, dt = do_ocr(
   input_pdf, out_basic,
   language=["eng"],
   deskew=True,
   rotate_pages=True,
)
print(f"Exit code: {rc.name} ({int(rc)}) in {dt:.1f}s  ->  {out_basic.name} ({kb(out_basic)})")

banner("3 ·  Advanced OCR  (PDF/A-2, --optimize 3, sidecar, metadata)")
out_adv = WORK / "out_advanced.pdf"
sidecar = WORK / "ocr_text.txt"
rc, dt = do_ocr(
   input_pdf, out_adv,
   language=["eng"],
   deskew=True,
   rotate_pages=True,
   optimize=3,
   jpg_quality=80,
   png_quality=80,
   output_type="pdfa-2",
   sidecar=sidecar,
   title="OCRmyPDF Colab Tutorial",
   author="Tutorial",
   subject="Demonstration of OCRmyPDF",
   keywords="ocr, pdf, tesseract, ocrmypdf",
)
print(f"Exit code: {rc.name} ({int(rc)}) in {dt:.1f}s  ->  {out_adv.name} ({kb(out_adv)})")
sh(f'pdfinfo "{out_adv}" | grep -E "Title|Author|Subject|Keywords|Pages"', check=False)

banner("4 ·  Prove searchability + measure OCR word-recall")
ocr_text = sidecar.read_text(errors="ignore")
print("Sidecar text (first 300 chars):\n" + ocr_text[:300].strip())
embedded = WORK / "embedded_text.txt"
sh(f'pdftotext "{out_adv}" "{embedded}"', check=False)
print(f"\npdftotext extracted {len(embedded.read_text(errors='ignore').split())} "
     f"words from the OUTPUT PDF (the input had 0).")
src = tokens(" ".join(SAMPLE_TEXT_PAGES))
found = set(tokens(ocr_text))
recall = sum(1 for w in src if w in found) / max(1, len(src))
print(f"OCR word-recall vs. source: {recall * 100:.1f}%  ({len(src)} source words)")

banner("5 ·  Validate output + size comparison")
print("check_pdf (valid PDF structure):", check_pdf(out_adv))
print("file_claims_pdfa (PDF/A marker):", file_claims_pdfa(out_adv))
print(f"input    : {kb(input_pdf)}")
print(f"basic    : {kb(out_basic)}")
print(f"advanced : {kb(out_adv)}   (PDF/A-2 + image optimisation)")

banner("6 ·  Modes & exceptions: skip-text / redo-ocr / force-ocr")
try:
   do_ocr(out_adv, WORK / "should_fail.pdf", language=["eng"])
   print("Unexpected: no exception was raised.")
except PriorOcrFoundError as e:
   print(f"Caught PriorOcrFoundError (exit code {e.exit_code}): the PDF already "
         f"has text. Choose a mode to override:")
rc, _ = do_ocr(out_adv, WORK / "out_skiptext.pdf", language=["eng"], skip_text=True)
print(f"  --skip-text -> {rc.name}")
rc, _ = do_ocr(out_adv, WORK / "out_redo.pdf", language=["eng"], redo_ocr=True)
print(f"  --redo-ocr  -> {rc.name}")
rc, _ = do_ocr(out_adv, WORK / "out_force.pdf", language=["eng"], force_ocr=True)
print(f"  --force-ocr -> {rc.name}")

banner("7 ·  Tesseract engine tuning  (--oem / --psm)")
rc, dt = do_ocr(
   input_pdf, WORK / "out_tuned.pdf",
   language=["eng"],
   tesseract_oem=1,
   tesseract_pagesegmode=3,
   output_type="pdf",
)
print(f"Tuned run -> {rc.name} in {dt:.1f}s")

banner("8 ·  Image cleaning with unpaper  (--clean / --clean-final)")
try:
   rc, dt = do_ocr(
       input_pdf, WORK / "out_cleaned.pdf",
       language=["eng"], deskew=True,
       clean=True, clean_final=True, output_type="pdf",
   )
   print(f"Cleaned run -> {rc.name} in {dt:.1f}s")
except Exception as e:
   print("Cleaning step skipped (unpaper issue):", type(e).__name__, e)

banner("9 ·  Auto-orientation (OSD) on a 90°-rotated page  (--rotate-pages)")
try:
   rot_png = WORK / "_rot.png"
   render_page(SAMPLE_TEXT_PAGES[0]).rotate(90, expand=True, fillcolor=255) \
       .save(rot_png, format="PNG", dpi=(200, 200))
   rot_pdf = WORK / "rotated_input.pdf"
   with open(rot_pdf, "wb") as f:
       f.write(img2pdf.convert([str(rot_png)]))
   os.remove(rot_png)
   rot_side = WORK / "rotated_text.txt"
   rc, dt = do_ocr(
       rot_pdf, WORK / "out_rotated_fixed.pdf",
       language=["eng"], rotate_pages=True, sidecar=rot_side, output_type="pdf",
   )
   n = len(rot_side.read_text(errors="ignore").split())
   print(f"OSD corrected the page; recovered {n} words -> {rc.name} in {dt:.1f}s")
except Exception as e:
   print("Auto-orientation demo skipped:", type(e).__name__, e)

banner("10 ·  OCR a single image (image_dpi hint)")
single_png = WORK / "single_scan.png"
render_page(SAMPLE_TEXT_PAGES[2]).save(single_png, format="PNG")
rc, dt = do_ocr(
   single_png, WORK / "out_from_image.pdf",
   language=["eng"],
   image_dpi=200,
   output_type="pdf",
)
print(f"Image -> searchable PDF: {rc.name} in {dt:.1f}s")

banner("11 ·  In-memory OCR with BytesIO streams")
in_io = io.BytesIO(input_pdf.read_bytes())
out_io = io.BytesIO()
ocrmypdf.ocr(in_io, out_io, language=["eng"], output_type="pdf", progress_bar=False)
out_bytes = out_io.getvalue()
(WORK / "out_in_memory.pdf").write_bytes(out_bytes)
print(f"OCR'd entirely in RAM -> {len(out_bytes):,} bytes written to out_in_memory.pdf")

banner("12 ·  Batch-process a folder of PDFs")
batch_in = WORK / "batch_in"
batch_out = WORK / "batch_out"
batch_in.mkdir(exist_ok=True)
batch_out.mkdir(exist_ok=True)
build_scanned_pdf(batch_in / "invoice_001.pdf",
                 [SAMPLE_TEXT_PAGES[0], SAMPLE_TEXT_PAGES[1]], skew_index=1)
build_scanned_pdf(batch_in / "memo_002.pdf",
                 [SAMPLE_TEXT_PAGES[2]], skew_index=-1)
print(f"{'file':<20}{'result':<14}{'time':<8}size")
for src_pdf in sorted(batch_in.glob("*.pdf")):
   dst = batch_out / src_pdf.name
   try:
       rc, dt = do_ocr(src_pdf, dst, language=["eng"],
                       deskew=True, output_type="pdfa")
       print(f"{src_pdf.name:<20}{rc.name:<14}{dt:<8.1f}{kb(dst)}")
   except Exception as e:
       print(f"{src_pdf.name:<20}{type(e).__name__:<14}{'-':<8}-")

banner("13 ·  New-style typed OcrOptions API (v17+)")
try:
   from ocrmypdf._options import OcrOptions
   opts = OcrOptions(
       input_file=str(input_pdf),
       output_file=str(WORK / "out_options.pdf"),
       languages=["eng"],
       deskew=True,
       rotate_pages=True,
       output_type="pdfa",
       progress_bar=False,
   )
   rc = ocrmypdf.ocr(opts)
   print(f"OcrOptions run -> {rc.name} ({int(rc)})")
except Exception as e:
   print("OcrOptions API not available in this version:", type(e).__name__, e)

banner("14 ·  Results")
produced = sorted(p for p in WORK.glob("*.pdf"))
for p in produced:
   print(f"  {p.name:<26}{kb(p)}")
for p in sorted(batch_out.glob("*.pdf")):
   print(f"  batch_out/{p.name:<16}{kb(p)}")
print(f"\nAll files are in: {WORK}")
try:
   from google.colab import files
   for p in [out_adv, out_basic, sidecar, embedded]:
       if Path(p).exists():
           files.download(str(p))
except Exception as e:
   print("(Colab download unavailable — open the files from the panel instead.)", e)
print("\nDone. ")


Tags: reading   article   ocr   pdf   llm  

Last modified 03 November 2026