| name | academic-pdf-redaction |
| version | 1.2.1 |
| description | Redact author-identifying text from academic PDFs for double-blind peer review anonymization — trigger when preparing manuscripts for blind review, anonymizing submissions, or stripping names/affiliations/emails/DOIs from PDFs. Not for scanned image-only PDFs, legal e-discovery, or general PDF merge/extract. |
| risk | safe |
| source | openrouter-deepsearch |
| date_added | 2026-06-16T00:00:00.000Z |
Overview
This skill removes author-identifying information from academic papers before they enter double-blind peer review. The goal is narrow: strip the handful of strings that reveal identity (names, affiliations, contact details, venue self-references) while leaving the scientific content byte-for-byte intact so reviewers can still evaluate the work.
The skill uses PyMuPDF (fitz) to search for specific text strings and physically remove the underlying glyphs — not merely paint black boxes over them.
When to Use
- Preparing an academic paper for double-blind peer review submission.
- Anonymizing a PDF manuscript by removing author names, affiliations, emails, arXiv IDs, DOIs, and venue self-references.
- You have a text-based PDF (not a scanned image) and need targeted string-level redaction.
Prerequisites
- Python 3.10+ with PyMuPDF >= 1.24 installed. Older PyMuPDF releases (before 1.24) carry known parser CVEs that a malicious document can trigger.
- Install or verify PyMuPDF (PowerShell, Windows host):
pip install "PyMuPDF>=1.24"
python -c "import fitz; print(fitz.__doc__)"
- If the PDF is a scanned image with no extractable text layer, this skill cannot help — there are no text glyphs to search for. Those documents need pixel-level redaction after OCR (see Related skills).
- The redaction pipeline and
verify_redaction helper are inlined in this file (Steps 2–4 and Verification). This folder does not ship helper scripts or a references pack.
Procedure
Step 1 — Identify the identifying strings to redact
Collect the literal strings and decide which regex patterns to apply. PyMuPDF's search_for() only does literal substring matching — it cannot take a regular expression. Patterns split into two groups:
Literal strings (passed straight to search_for):
- Author full names — redact the complete name ("John Smith"), never the bare surname ("Smith"). A lone surname collides with unrelated cited authors and would scrub legitimate citations.
- Affiliations — institutions and companies ("Duke University", "Acme Research").
- Venue self-references — "ICML 2024", "ICML Workshop"; naming the target venue in the body is a common deanonymisation tell.
- Acknowledgement names — people thanked by name in the Acknowledgements section.
- Equal-contribution footnotes — "Equal contribution", "* Equal contribution".
Regex patterns (matched against extracted text first, then concrete hits are looked up with search_for):
- Email addresses —
[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}
- arXiv identifiers —
arXiv:\d{4}\.\d{4,5}(?:v\d+)?
- DOIs —
10\.\d{4,9}/[-._;()/:A-Za-z0-9]+
Step 2 — Run the redaction pipeline
Use redact_with_pymupdf from the implementation inlined in Step 4:
from pathlib import Path
result = redact_with_pymupdf(
input_path=r"~\papers\submission.pdf",
output_path=r"~\papers\submission_redacted.pdf",
literal_patterns=[
"John Smith",
"Jane Doe",
"Duke University",
"Acme Research",
"ICML 2024",
"Equal contribution",
"* Equal contribution",
],
min_retained_ratio=0.8,
)
print(f"Pages: {result.pages}")
print(f"Redactions applied: {result.redactions_applied}")
print(f"Retained ratio: {result.retained_ratio:.1%}")
Step 3 — Three rules that keep redaction correct
-
Stop at the References heading. Everything from the bibliography onward stays untouched so self-citations survive. The heading is detected as a standalone line (normalized, case-insensitive, trailing colon stripped), not a loose substring — the word "references" appears in ordinary prose, and a substring match would wrongly cut the redaction short or skip half the paper.
-
Redact only specific text matches. Search for exact identifying strings and black out just those rectangles. This keeps you from removing surrounding sentences and makes the result auditable.
-
Verify the output before trusting it. A bug in a single pattern can silently blank most of a page. Re-open the saved PDF and confirm the page count is unchanged and the bulk of the text (80%+) remains.
Step 4 — Reference implementation (PyMuPDF / fitz)
Full pipeline (copy into the project; this folder does not ship a helper module). The key components:
from __future__ import annotations
import re
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
import fitz
_REFERENCE_HEADINGS: frozenset[str] = frozenset(
{"references", "bibliography", "works cited"}
)
_DEFAULT_REGEXES: Sequence[re.Pattern[str]] = (
re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"),
re.compile(r"arXiv:\d{4}\.\d{4,5}(?:v\d+)?", re.IGNORECASE),
re.compile(r"10\.\d{4,9}/[-._;()/:A-Za-z0-9]+"),
)
@dataclass(frozen=True)
class RedactionResult:
"""Stats from a single redaction run, returned for logging and tests."""
pages: int
original_chars: int
redacted_chars: int
references_page: int | None
redactions_applied: int
() -> :
.original_chars == :
.redacted_chars / .original_chars
() -> | :
page_num (doc.page_count):
raw_line doc[page_num].get_text().splitlines():
normalized = raw_line.strip().lower().rstrip()
normalized _REFERENCE_HEADINGS:
page_num
() -> RedactionResult:
in_path = Path(input_path)
out_path = Path(output_path)
in_path.is_file():
FileNotFoundError()
in_path.suffix.lower() != :
ValueError()
out_path.resolve() == in_path.resolve():
ValueError(
)
< min_retained_ratio < :
ValueError(
)
literals: [] = [p.strip() p literal_patterns p p.strip()]
literals regex_patterns:
ValueError(
)
:
doc: fitz.Document = fitz.((in_path))
Exception exc:
RuntimeError() exc
:
original_chars = (
(doc[i].get_text()) i (doc.page_count)
)
original_chars == :
ValueError(
)
references_page = _find_references_page(doc)
redactions_applied =
page_num (doc.page_count):
references_page page_num >= references_page:
page: fitz.Page = doc[page_num]
page_text = page.get_text()
targets: [] = (literals)
pattern regex_patterns:
targets.update(.group() pattern.finditer(page_text))
target targets:
rect page.search_for(target):
page.add_redact_annot(rect, fill=(, , ))
redactions_applied +=
page.apply_redactions()
redacted_chars = (
(doc[i].get_text()) i (doc.page_count)
)
page_count = doc.page_count
out_path.parent.mkdir(parents=, exist_ok=)
doc.save((out_path), garbage=, deflate=)
:
doc.close()
result = RedactionResult(
pages=page_count,
original_chars=original_chars,
redacted_chars=redacted_chars,
references_page=references_page,
redactions_applied=redactions_applied,
)
verify_redaction(in_path, out_path, min_retained_ratio=min_retained_ratio)
result
Pitfalls
Never blank the References section
Self-citations live in the bibliography, and a reviewer uses the bibliography to check how the work relates to prior art. Anonymising it corrupts the evidence the review depends on. The right way to hide self-citation signals is to neutralise giveaway phrasing in the body ("in our prior work [12]"), not to delete the reference list.
Never rely on a deprecated PDF library
As of 2026, PyMuPDF releases before 1.24 carry known parser CVEs that a malicious document can trigger. Pin a current stable release:
pip install "PyMuPDF>=1.24"
Never redact regions or pages — only specific strings
Region- or page-level redaction destroys the content a reviewer needs and almost always removes far more than the identifying strings.
import fitz
page: fitz.Page = doc[0]
for block in page.get_text("blocks"):
rect = fitz.Rect(block[:4])
page.add_redact_annot(rect, fill=(0, 0, 0))
page.draw_rect(fitz.Rect(0, 0, 600, 100), fill=(0, 0, 0))
for rect in page.search_for("John Smith"):
page.add_redact_annot(rect, fill=(0, 0, 0))
page.apply_redactions()
Never redact bare surnames
A lone surname like "Smith" collides with unrelated cited authors and would scrub legitimate citations. Always redact the complete name ("John Smith").
Never overwrite the source PDF
The output path must differ from the input path. Overwriting the source destroys the only un-redacted copy.
apply_redactions() is mandatory
Without page.apply_redactions(), the text is still extractable under the black box — the classic "redaction" that anyone can copy-paste straight back out. The annotation alone does not remove glyphs.
Scanned PDFs have no text layer
If original_chars == 0, the PDF is likely a scan. OCR it first; image-only PDFs need pixel redaction, not this skill.
Verification
Redaction bugs are easy to ship because the output still looks like a PDF. Verification is not optional — it is the step that turns a silent corruption into a loud failure. Re-open the saved file and check it independently of the code that produced it.
Use the verification function inlined below and run it against both the original and redacted PDF:
from __future__ import annotations
from pathlib import Path
import fitz
def verify_redaction(
original_path: str | Path,
output_path: str | Path,
*,
min_retained_ratio: float = 0.8,
) -> None:
"""Re-open both PDFs and fail loudly if the redaction looks destructive.
Checks three things, each guarding a real failure mode:
* page count unchanged — redaction must not add or drop pages;
* a sane amount of text left — guards against a fully blanked document;
* retained ratio >= floor — guards against an over-broad pattern.
Raises on any failure; returns None on success.
"""
orig_path = Path(original_path)
out_path = Path(output_path)
if not orig_path.is_file():
raise FileNotFoundError(f"Original PDF missing: {orig_path}")
if not out_path.is_file():
raise FileNotFoundError(f"Redacted PDF missing: {out_path}")
if not 0.0 < min_retained_ratio < 1.0:
raise ValueError(
f"min_retained_ratio must be in (0, 1); got {min_retained_ratio!r}"
)
orig: fitz.Document = fitz.open(str(orig_path))
try:
redc: fitz.Document = fitz.open(str(out_path))
except Exception as exc:
orig.close()
RuntimeError() exc
:
orig_pages = orig.page_count
redc_pages = redc.page_count
orig_chars = ((orig[i].get_text()) i (orig_pages))
redc_chars = ((redc[i].get_text()) i (redc_pages))
:
orig.close()
redc.close()
retained = redc_chars / orig_chars orig_chars
()
()
()
redc_pages != orig_pages:
ValueError(
)
redc_chars < :
ValueError(
)
retained < min_retained_ratio:
ValueError(
)
()
Verification checklist
python -c "import fitz; print(fitz.VersionBind)"
Related Skills
- Scanned / image-only PDFs have no text layer for
search_for to hit. Run OCR to recover text, or use an image-redaction skill that paints over the pixels and re-flattens the page so the original raster is gone.
- Plain-text or office documents (
.txt, .docx, .md) carry no PDF geometry; a text-redaction skill that operates on the string content is simpler and safer than forcing them through PyMuPDF.