Redact Scanned PDF Regions with OCR

The redaction job reports 0 matches for a scanned contract, a phone photo of an ID card, or a faxed form — and ships the page untouched. There is no text to search: a scanned page is one image, and page.search_for() or any regex over page.get_text() returns nothing. Silence from the matcher is indistinguishable from a clean document unless the job checks for it.

Root Cause

Text redaction works on glyph operators in the page content stream. A scan has one image operator and no glyphs, so every text-based locator returns an empty list, and apply_redactions() with no annotations does nothing. To redact a scan you need geometry from somewhere else: an OCR engine that reads the pixels, reports each recognised word with a bounding box in image pixels, and lets you convert those boxes to PDF points. The redaction itself then blanks pixels under the boxes with PDF_REDACT_IMAGE_PIXELS. Two further details cause misses once OCR is in place: word boxes are in the rendered image's coordinate space, which differs from page space by the rendering DPI and the page rotation; and OCR splits or merges tokens differently from how a value is written, so patterns must be applied to lines, not isolated words.

Minimal Diagnostic

Confirm the page has no text layer and that OCR can see the value. If OCR cannot read it, no amount of redaction code will find it — fix image quality first.

# pip install pymupdf pytesseract pillow
import io
import re
from pathlib import Path
import pymupdf
import pytesseract
from PIL import Image

SOURCE = Path("in/scanned-contract.pdf")
NINO = re.compile(r"[A-CEGHJ-PR-TW-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D]")

def ocr_probe(pdf_path: Path, rx: re.Pattern, dpi: int = 300) -> None:
    try:
        doc = pymupdf.open(pdf_path)
    except Exception as exc:
        raise SystemExit(f"cannot open {pdf_path}: {exc}")
    with doc:
        for page in doc:
            text_chars = len(page.get_text().strip())
            pix = page.get_pixmap(dpi=dpi)
            image = Image.open(io.BytesIO(pix.tobytes("png")))
            try:
                ocr_text = pytesseract.image_to_string(image)
            except pytesseract.TesseractNotFoundError:
                raise SystemExit("tesseract binary not on PATH")
            found = rx.findall(ocr_text)
            print(f"page {page.number + 1}: text layer {text_chars} chars, "
                  f"OCR {len(ocr_text)} chars, pattern hits {len(found)}")

if __name__ == "__main__":
    ocr_probe(SOURCE, NINO)
page 1: text layer 0 chars, OCR 2213 chars, pattern hits 1
page 2: text layer 0 chars, OCR 1987 chars, pattern hits 0

Zero text-layer characters with non-zero OCR output confirms the root cause. If tesseract is missing, fix the TesseractNotFoundError first.

From pixels to redacted pixels Six steps. The page is rendered to a pixmap at 300 DPI. Tesseract image_to_data returns words with pixel boxes. Words are grouped into lines by block, paragraph and line numbers. Patterns are matched per line and mapped to member words. Boxes are scaled by 72 over DPI and padded into page points. Redaction annotations are applied with pixel redaction so the scan remains but the value is blanked. Render get_pixmap dpi=300 OCR image_to_data word boxes Group words into lines Match regex per line Scale px x 72/dpi, then pad Blank PDF_REDACT_IMAGE_PIXELS Scaling assumes an unrotated page; apply the derotation matrix when page.rotation is not 0

Fix: Line-Level OCR Matching with Scaled, Padded Boxes

The fix groups OCR words into lines, matches patterns against each line's text, converts member-word boxes to PDF points, pads them, and applies pixel redaction.

# pip install pymupdf pytesseract pillow
import io
import re
from collections import defaultdict
from pathlib import Path
import pymupdf
import pytesseract
from PIL import Image

SOURCE = Path("in/scanned-contract.pdf")
DEST = Path("out/scanned-contract-redacted.pdf")
DPI = 300
PAD = 2.0                                               # points of margin around each word box
MIN_CONF = 30                                           # ignore OCR noise below this confidence

PATTERNS = {
    "nino": re.compile(r"[A-CEGHJ-PR-TW-Z]{2} ?\d{2} ?\d{2} ?\d{2} ?[A-D]"),
    "email": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
}

def ocr_lines(page: pymupdf.Page) -> list[list[dict]]:
    pix = page.get_pixmap(dpi=DPI)
    image = Image.open(io.BytesIO(pix.tobytes("png")))
    data = pytesseract.image_to_data(image, output_type=pytesseract.Output.DICT)
    lines = defaultdict(list)
    for i, word in enumerate(data["text"]):
        if not word.strip() or float(data["conf"][i]) < MIN_CONF:   # changed: drop blanks and noise
            continue
        key = (data["block_num"][i], data["par_num"][i], data["line_num"][i])  # changed: group per line
        lines[key].append({
            "text": word,
            "box": (data["left"][i], data["top"][i], data["width"][i], data["height"][i]),
        })
    return list(lines.values())

def to_page_rect(page: pymupdf.Page, box: tuple[int, int, int, int]) -> pymupdf.Rect:
    x, y, w, h = box
    scale = 72 / DPI                                    # changed: pixels -> points
    rect = pymupdf.Rect(x * scale, y * scale, (x + w) * scale, (y + h) * scale)
    rect = rect * page.derotation_matrix                # changed: honour /Rotate on the page
    return (rect + (-PAD, -PAD, PAD, PAD)) & page.rect  # changed: pad, then clip to the page

def redact_scan(src: Path, dest: Path) -> int:
    total = 0
    with pymupdf.open(src) as doc:
        for page in doc:
            for line in ocr_lines(page):
                text, spans = "", []
                for word in line:
                    start = len(text)
                    text += word["text"] + " "
                    spans.append((start, start + len(word["text"]), word["box"]))
                for label, rx in PATTERNS.items():
                    for m in rx.finditer(text):
                        for start, end, box in spans:
                            if start < m.end() and end > m.start():
                                page.add_redact_annot(to_page_rect(page, box), fill=(0, 0, 0))
                                total += 1
            page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_PIXELS)  # changed: blank pixels only
        dest.parent.mkdir(parents=True, exist_ok=True)
        doc.save(dest, garbage=4, deflate=True)
    return total

if __name__ == "__main__":
    try:
        print(f"{redact_scan(SOURCE, DEST)} word box(es) blanked")
    except pytesseract.TesseractNotFoundError:
        raise SystemExit("install tesseract-ocr first")

image_to_data numbers words by block, paragraph and line, so grouping on that triple reproduces the visual lines. The two-point padding absorbs the difference between Tesseract's tight glyph box and the actual ink, including anti-aliased edges that would otherwise leave a faint outline of each character. Clipping with & page.rect keeps boxes near the edge from raising an error.

page.derotation_matrix converts from the rendered (visually upright) orientation back into the page's unrotated coordinate system that annotations use. Without it, a page stored with /Rotate 90 — very common for landscape scans — gets its boxes placed in the wrong quadrant, blanking empty margin and leaving the value intact.

OCR word boxes versus padded redaction boxes A scanned page shows a signature block, an OCR word box drawn tightly around the national insurance number, and a padded redaction rectangle two points larger on every side that covers anti-aliased ink. A dashed region marks where an unrotated box would land on a page stored with a rotation of 90 degrees, in the empty margin rather than over the value. Scanned page, 300 DPI render 1 1 Letterhead not matched 2 2 Padded redaction box OCR box plus 2 pt each side 3 3 Signature block area-redact by template if required 4 4 Misplaced box result of ignoring page rotation

Variant Fix 1: OCR Reads the Value Wrong

Low-contrast scans produce AB 12 34 S6 C — an S where a 5 is printed — and the pattern fails. Two layers of defence. First, preprocess before OCR: grayscale, upscale small text, and binarise, as covered in improve OCR accuracy with image preprocessing. Second, make the pattern tolerant of common confusions for the character classes it expects:

# pip install pillow pytesseract
import re
from PIL import Image, ImageOps

DIGITISH = "[0-9OoSsIlBZ]"                                  # characters OCR confuses with digits
NINO_TOLERANT = re.compile(
    rf"[A-Z]{{2}} ?{DIGITISH}{{2}} ?{DIGITISH}{{2}} ?{DIGITISH}{{2}} ?[A-D]"
)

def prepare(image: Image.Image) -> Image.Image:
    gray = ImageOps.grayscale(image)
    gray = gray.resize((gray.width * 2, gray.height * 2))   # help small print
    return gray.point(lambda v: 255 if v > 160 else 0)      # crude binarisation

A tolerant pattern over-matches slightly more. For redaction that trade is almost always correct.

Variant Fix 2: Handwriting and Stamps OCR Cannot Read

Handwritten account numbers, signatures and rubber stamps are invisible to Tesseract. When a document type has a fixed layout, redact the region regardless of content by applying a template of rectangles, measured once in PDF points:

# pip install pymupdf
from pathlib import Path
import pymupdf

TEMPLATES = {
    "claim-form-v3": [pymupdf.Rect(300, 640, 560, 720),   # signature and date
                      pymupdf.Rect(40, 150, 300, 176)],   # handwritten policy number
}

def redact_template(src: Path, dest: Path, template: str) -> None:
    with pymupdf.open(src) as doc:
        for page in doc:
            for rect in TEMPLATES[template]:
                page.add_redact_annot(rect & page.rect, fill=(0, 0, 0))
            page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_PIXELS)
        doc.save(dest, garbage=4, deflate=True)

Scans drift by a few millimetres between feeds, so template rectangles need generous margins — ten points or more on each side.

Which locating method fits which scanned content Rows are three locating methods and columns are content types. OCR with strict patterns works for clean printed values, is unreliable on poor scans, fails on handwriting and handles variable layouts. Tolerant patterns with preprocessing work on printed values and poor scans, fail on handwriting and handle variable layouts. Fixed templates work on all three content types but only when the layout is fixed. Method Clean print Poor scan Handwriting Variable layout OCR + strict regex reliable misses values blind yes Tolerant regex + preprocessing reliable mostly blind yes Fixed template areas reliable reliable reliable no Combine a template for known fields with OCR matching for everything else

Verification

Checking a scanned output means OCRing it again. Re-render each redacted page, OCR it, and assert that no pattern matches. Also confirm that the output still contains an image of the right size — a bug that deletes the image entirely would also pass the pattern check.

# pip install pymupdf pytesseract pillow
import io
import re
from pathlib import Path
import pymupdf
import pytesseract
from PIL import Image

PATTERNS = [re.compile(r"[A-Z]{2} ?\d{2} ?\d{2} ?\d{2} ?[A-D]"),
            re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")]

def verify_scan(original: Path, redacted: Path, dpi: int = 300) -> None:
    with pymupdf.open(original) as a, pymupdf.open(redacted) as b:
        assert len(a) == len(b), "page count changed"
        for pa, pb in zip(a, b):
            assert pb.get_images(), f"page {pb.number + 1}: image removed entirely"
            pix = pb.get_pixmap(dpi=dpi)
            text = pytesseract.image_to_string(Image.open(io.BytesIO(pix.tobytes("png"))))
            flat = " ".join(text.split())
            hits = [rx.pattern for rx in PATTERNS if rx.search(flat)]
            assert not hits, f"page {pb.number + 1}: OCR still reads {hits}"
            assert not pb.get_text().strip() or pa.get_text().strip(), \
                f"page {pb.number + 1}: unexpected text layer appeared"
    print(f"{redacted.name}: OCR finds no sensitive values")

if __name__ == "__main__":
    verify_scan(Path("in/scanned-contract.pdf"), Path("out/scanned-contract-redacted.pdf"))

Run the verifier with the tolerant patterns from Variant Fix 1, not the strict ones — the point is to catch values the redaction pass might have misread. Keep a sample of before/after page renders for manual review during the first weeks of running the job; OCR-driven redaction deserves human spot checks until its miss rate on your documents is known.

Performance Notes

OCR dominates the run time: expect about one to two seconds per A4 page at 300 DPI per CPU core. Render and OCR pages in a process pool, but keep PyMuPDF documents local to each worker. Rendering at 200 DPI roughly halves the time and still reads 10-point print reliably; drop below that only after measuring hit rates on your own scans. Cache the OCR word data (it is plain JSON) keyed on the file hash so that a pattern change does not force every document through OCR again.

FAQ

Can I use the OCR layer that the scanner already added instead of running Tesseract? Only for locating, and only if it lines up with the image. It was produced by unknown software at unknown settings; verify its boxes against the image on a sample before relying on it, and remove the layer afterwards with doc.scrub(hidden_text=True).

Why blank pixels instead of deleting the image? The page is the image. PDF_REDACT_IMAGE_REMOVE would leave a blank page. Pixel mode rewrites only the covered area of the image.

Does pixel redaction recompress the image and lose quality? PyMuPDF re-encodes the modified image. Quality loss is usually invisible for document scans; check file size and a zoomed render on a sample if the originals are high-quality colour.

How do I redact photos embedded in an otherwise digital PDF? The same way: locate with OCR on a render of the page, or use the image's own rectangle from page.get_image_rects(xref) to redact the whole photo.

Part of Redacting Sensitive Data in PDFs with Python.