Redact PDF Text by Regex Pattern
The symptom: a regex finds every account number when you run it over page.get_text(), yet the redacted PDF still shows some of them — usually the ones that wrap onto a second line, contain a non-breaking space, or were split into separately positioned glyph runs by the generator. page.search_for() only locates literal strings, so the obvious bridge — find matches with re, then search for each matched string — silently loses anything the text extractor and the search engine see differently.
Root Cause
PyMuPDF offers two views of the same page. get_text("text") returns a reading-order string with line breaks inserted wherever the layout wraps. search_for(needle) scans the page's character positions for the needle and returns rectangles, joining characters across spans on the same line but not across lines. A regex that matches GB29 NWBK 6016 1331 9268 19 in a whitespace-normalised string produces a needle that never exists on any single line of the page. Separately, characters such as U+00A0 (non-breaking space) or U+2011 (non-breaking hyphen) match \s or - in your normalised text but differ from the space you pass to search_for. The regex was right; the mapping from match back to page geometry was lossy.
Minimal Diagnostic
Compare what the regex matches in normalised text against what search_for can locate. Every match with zero rectangles is a value that would survive redaction.
# pip install pymupdf
import re
from pathlib import Path
import pymupdf
SOURCE = Path("in/statement.pdf")
IBAN = re.compile(r"\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]{4}){3,7}(?:\s?[A-Z0-9]{1,3})?\b")
def unlocatable_matches(pdf_path: Path, rx: re.Pattern) -> list[tuple[int, str]]:
missing = []
try:
doc = pymupdf.open(pdf_path)
except Exception as exc:
raise RuntimeError(f"cannot open {pdf_path}: {exc}") from exc
with doc:
for page in doc:
flat = " ".join(page.get_text("text").split()) # normalise all whitespace
for m in rx.finditer(flat):
if not page.search_for(m.group(0)):
missing.append((page.number + 1, m.group(0)))
return missing
if __name__ == "__main__":
for page_no, value in unlocatable_matches(SOURCE, IBAN):
print(f"page {page_no}: regex matched {value!r} but search_for found nothing")
page 1: regex matched 'GB29 NWBK 6016 1331 9268 19' but search_for found nothing
page 3: regex matched 'DE89 3704 0044 0532 0130 00' but search_for found nothing
If that list is non-empty, a match-then-search pipeline leaks those values.
Fix: Match Over Words, Redact Word Rectangles
Build the searchable string from get_text("words") so every character offset maps to a word with a known rectangle. A match then selects the words it overlaps, regardless of how many lines they span.
# pip install pymupdf
import re
from pathlib import Path
import pymupdf
SOURCE = Path("in/statement.pdf")
DEST = Path("out/statement-redacted.pdf")
PATTERNS = {
"iban": re.compile(r"\b[A-Z]{2}\d{2}(?: [A-Z0-9]{4}){3,7}(?: [A-Z0-9]{1,3})?\b"),
"email": re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"),
}
NBSP = {"\u00a0": " ", "\u2011": "-", "\u2013": "-"} # normalise look-alike characters
def word_index(page: pymupdf.Page):
"""Join words with single spaces and remember each word's span and rectangle."""
words = page.get_text("words", sort=True) # sort=True gives reading order
text, spans = "", []
for x0, y0, x1, y1, word, *_ in words:
clean = "".join(NBSP.get(ch, ch) for ch in word) # changed: map look-alikes per char
start = len(text)
text += clean + " " # changed: exactly one space between words
spans.append((start, start + len(clean), pymupdf.Rect(x0, y0, x1, y1)))
return text, spans
def regex_rects(page: pymupdf.Page) -> list[tuple[str, pymupdf.Rect]]:
text, spans = word_index(page)
hits = []
for label, rx in PATTERNS.items():
for m in rx.finditer(text):
for start, end, rect in spans:
if start < m.end() and end > m.start(): # changed: overlap, not equality
hits.append((label, rect))
return hits
def redact(src: Path, dest: Path) -> int:
count = 0
with pymupdf.open(src) as doc:
for page in doc:
hits = regex_rects(page)
for label, rect in hits:
page.add_redact_annot(rect, text=f"[{label}]", fontsize=6, fill=(0, 0, 0))
if hits:
page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_PIXELS) # once per page
count += len(hits)
dest.parent.mkdir(parents=True, exist_ok=True)
doc.save(dest, garbage=4, deflate=True, clean=True) # drop orphaned original text
return count
if __name__ == "__main__":
try:
print(f"{redact(SOURCE, DEST)} word box(es) redacted")
except Exception as exc:
raise SystemExit(f"redaction failed: {exc}")
The patterns now use a literal space between groups rather than \s?, because the word index guarantees single spaces. Values printed without spaces (GB29NWBK...) are a single word and still match the pattern with the space group made optional; add ? after the space if your documents mix both styles.
A word that is only partly inside a match — IBAN:GB29 glued together by the generator — is redacted whole. That over-redacts a label, which is the safe direction. If precision matters more, use page.get_text("rawdict") to get per-character boxes and union only the characters inside the match.
Variant Fix 1: Card Numbers Matching Everything
A pattern like (?:\d[ -]?){13,16} redacts invoice numbers, phone numbers and timestamps. Filter candidates with the Luhn checksum that real card numbers satisfy:
# pip install pymupdf
import re
CARD = re.compile(r"(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)")
def luhn_ok(candidate: str) -> bool:
digits = [int(c) for c in candidate if c.isdigit()]
if not 13 <= len(digits) <= 19:
return False
checksum = 0
for i, d in enumerate(reversed(digits)):
if i % 2: # double every second digit from the right
d = d * 2 - 9 if d > 4 else d * 2
checksum += d
return checksum % 10 == 0
def card_matches(text: str) -> list[re.Match]:
return [m for m in CARD.finditer(text) if luhn_ok(m.group(0))]
Replace rx.finditer(text) with card_matches(text) for the card label in regex_rects. Roughly one random digit string in ten passes Luhn, so this cuts false positives by about ninety percent without ever skipping a real card number.
Variant Fix 2: Case and Accent Differences in Names
Names typed in a CRM (Zoë Ó Briain) rarely match the PDF byte-for-byte: the generator may have decomposed accents, used a different apostrophe, or upper-cased the header. Normalise both sides before comparing, while keeping offsets aligned to the original words:
# pip install pymupdf
import re
import unicodedata
def fold(s: str) -> str:
"""Casefold and strip combining accents without changing string length per character."""
return "".join(
unicodedata.normalize("NFKD", ch)[0].casefold() if ch.strip() else " "
for ch in s
)
def name_pattern(full_name: str) -> re.Pattern:
parts = [re.escape(fold(p)) for p in full_name.split()]
return re.compile(r"\b" + r"\W{0,2}".join(parts) + r"\b")
Apply fold to each word inside word_index and match with name_pattern. Taking only the first code point of each NFKD decomposition keeps one output character per input character, so the offsets still map to the same words. \W{0,2} between name parts tolerates O'Briain, O’Briain and O Briain.
Variant Fix 3: Letter-Spaced Values Become Separate Words
Some generators — payroll systems and form-filling tools in particular — position every character of a field individually, often inside printed boxes. The extractor sees 4 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 as sixteen one-character words, and a pattern expecting 4111 1111 groups never matches. Detect runs of single-character words on the same line and match against a collapsed copy, while keeping a map from each collapsed character back to its word:
# pip install pymupdf
import re
import pymupdf
def collapsed_index(page: pymupdf.Page):
"""Collapse runs of one-character words on a line into a single token for matching."""
words = page.get_text("words", sort=True)
text, spans = "", []
prev_line, prev_single = None, False
for x0, y0, x1, y1, word, block, line, _ in words:
key = (block, line)
single = len(word) == 1 and word.isalnum()
if not (single and prev_single and key == prev_line):
text += " " # new token unless continuing a run
start = len(text)
text += word
spans.append((start, len(text), pymupdf.Rect(x0, y0, x1, y1)))
prev_line, prev_single = key, single
return text, spans
CARD_COMPACT = re.compile(r"(?<!\d)\d{13,19}(?!\d)")
Run CARD_COMPACT (and a compact IBAN pattern without spaces) over this index in addition to the normal one, and union the rectangles. Running both indexes costs one extra pass over a list the page already produced, so the overhead is negligible; skipping it leaves exactly the fields most likely to hold personal data — boxed form entries — unredacted.
The same issue appears in reverse on the extraction side, where letter-spaced headings come out as S T A T E M E N T; fix PDF text extraction missing spaces explains how the extractor decides where one word ends, which is the setting that produces both symptoms.
Keeping Patterns Maintainable
Once a job carries more than three or four patterns, keep them in a small table that a reviewer can read without parsing Python, and load it at start-up. Each entry needs a label for the replacement text, the expression, and an optional validator name:
# pip install pyyaml
from pathlib import Path
import re
import yaml
from card_checks import luhn_ok # the Luhn helper from Variant Fix 1
PATTERN_FILE = Path("redaction_patterns.yaml")
VALIDATORS = {"luhn": lambda s: luhn_ok(s), None: lambda s: True}
def load_patterns(path: Path) -> list[tuple[str, re.Pattern, callable]]:
try:
entries = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise SystemExit(f"cannot load {path}: {exc}")
return [(e["label"], re.compile(e["regex"]), VALIDATORS[e.get("validator")])
for e in entries]
Version the pattern file alongside the job and log its hash with every run. When someone asks months later whether a given document was screened for national insurance numbers, the log answers it.
Verification
Verification reuses the same patterns against an independent extraction, and also checks that the word count dropped only by roughly the number of redacted words — a large unexplained drop means the redaction boxes were too big and removed surrounding text.
# pip install pdfplumber pymupdf
from pathlib import Path
import re
import pdfplumber
import pymupdf
PATTERNS = {
"iban": re.compile(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b"),
"email": re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"),
}
def word_count(pdf: Path) -> int:
with pymupdf.open(pdf) as doc:
return sum(len(page.get_text("words")) for page in doc)
def verify(original: Path, redacted: Path, expected_removed: int) -> None:
with pdfplumber.open(redacted) as pdf:
flat = " ".join(" ".join((p.extract_text() or "") for p in pdf.pages).split())
leaks = {k: rx.findall(flat) for k, rx in PATTERNS.items() if rx.search(flat)}
assert not leaks, f"values still extractable: {leaks}"
before, after = word_count(original), word_count(redacted)
removed = before - after
# labels like [iban] add one word per redaction box, so allow a small margin
assert removed <= expected_removed + 5, (
f"{removed} words disappeared but only {expected_removed} were targeted"
)
print(f"ok: {removed} word(s) removed, no pattern matches remain")
if __name__ == "__main__":
verify(Path("in/statement.pdf"), Path("out/statement-redacted.pdf"), expected_removed=24)
Run the diagnostic from the top of the page against the redacted output as well: it should now report nothing, because there is nothing left for the regex to match.
FAQ
Why not use page.search_for with a regex directly?
It does not accept patterns — it takes a literal needle. The word-index approach is the supported way to combine regular expressions with page geometry.
Does sort=True matter for get_text("words")?
Yes. Without it, words come in content-stream order, which for multi-column layouts can interleave columns and split a value that reads contiguously on the page. Sorting by position restores reading order for ordinary layouts.
How do I handle values split by hyphenation at a line end?
Add pymupdf.TEXT_DEHYPHENATE to the flags when extracting: page.get_text("words", flags=pymupdf.TEXT_DEHYPHENATE, sort=True). The joined word keeps the rectangle of its first half, so also redact the next word when a match ends at a dehyphenated word.
Can I preview matches before removing anything?
Add the redaction annotations, save to a review copy without calling apply_redactions, and open it in a viewer — the marked areas show as outlined boxes. Apply only on the copy that gets distributed.
Related
- Redacting Sensitive Data in PDFs with Python — the full workflow including scrubbing and scans
- Fix Redacted Text Still Searchable in PDF — when the boxes are there but the text is not gone
- Fix PDF Text Extraction Missing Spaces — why word boundaries differ between tools
- Cleaning Messy CSV Data with pandas — the same normalisation ideas applied to tabular text