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.
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.
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.
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.
Related
- Redacting Sensitive Data in PDFs with Python — the full workflow for digital and mixed documents
- Fix Redacted Text Still Searchable in PDF — invisible OCR layers that survive pixel redaction
- Scanning and OCR Processing with Python — Tesseract set-up, languages and page segmentation
- Fix OCR Garbage on Rotated Scans — orientation problems that break both reading and box placement