Monorepo Architecture & Internal Design¶
This document details the architectural decisions, pipeline workflows, and core algorithms powering PDF Anonymizer.
monorepo Architecture¶
PDF Anonymizer separates the underlying processing logic (SDK) from the command-line entry points. This decoupled design ensures the core library can be embedded into server APIs or automated data workflows without bringing along CLI dependencies.
+--------------------------------+ +---------------------------+
| pdf-anonymizer-cli | | pdf-anonymizer-api |
| (Typer CLI) | | (FastAPI; core only) |
+---------------+----------------+ +-------------+-------------+
| |
+----------------+-------------------+
v
+--------------------------------+
| pdf-anonymizer-core |
| (SDK & LLM adapters) |
+---------------+----------------+
|
+------------------------+------------------------+
| | |
v v v
+----------------+ +----------------+ +----------------+
| Text Loader | | LLM Router | | Mapping Engine |
| & PDF Extractor| | & API Adapters | | & Reverser |
+----------------+ +----------------+ +----------------+
The Processing Pipeline¶
When you run pdf-anonymizer run, the system executes the following sequential steps:
graph TD
File[Input File: PDF, MD, TXT, CSV, XLSX] --> Ext[Text Extractor]
Ext -->|Markdown Converter| RawMD[Raw Markdown String]
RawMD --> Chunk[Text Chunking]
Chunk --> Regex[RE2 regex + checksums]
Chunk --> LLM[LLM Entity Identification]
Regex --> Merge[Merge detections]
LLM --> Merge
Merge --> Gaz[Keep-list / deny-list]
Gaz --> Cons[Base Form Consolidation]
Cons --> MapGen[Placeholder Mapping Generator]
MapGen --> Ops[Per-type operators]
Ops --> Repl[Span-based replacement]
Repl --> Output[Anonymized Markdown and JSON map]
Output --> Verify[Residual leftover scan]
Output --> Risk[Linkage-risk score]
Text Extraction & PDF Conversion¶
- Instead of traditional OCR or layout-unaware PDF parsing, the project uses
pymupdf4llmto convert PDF files into clean, readable Markdown. This retains tables, headings, and lists in a structured text layout that LLMs can parse with higher accuracy. - A PDF with pages and no text layer is a hard error.
--ocrruns Tesseract through PyMuPDF and writes word boxes to*.anonymized.layout.jsonso a later native-PDF redact pass can use the same spans. OCR is slower and less accurate than a real text layer. - Optional local span NER (
[ner]extra, GLiNER on CPU) is the semantic stage forbest-speed/best-costwhen installed.best-qualitymay run it first and still calls the LLM for identity clues. No extra keeps the LLMbest-speedpath. - A recognizer is one detector (regex, optional local NER, language model, deny-list). Each entity stores
source(which recognizer) andscore(that recognizerβs 0β1 hint). Regex checksum failures areTYPE_LIKEat 0.55.--min-confidence(default 0) can drop weak hits. Scores are not calibrated probabilities. See Terminology. - Optional HTTP service is a separate package (
pdf-anonymizer-api) that calls core only. Dockerfile and compose live in that package and bind127.0.0.1:8000. No authentication (item 22). - Residual reports stay report-only unless you opt in:
pdf-anonymizer applyorrun --apply-residualsrewrites accepted leftovers with the same span engine (item 23). - For Markdown and Text files, standard file reads are executed.
- CSV and Excel skip this loader. See Table path.
- Word
.docxskips this loader. See Word path.
Native PDF write¶
- Markdown remains the default output for a PDF input.
--output-pdfopens the source with PyMuPDF, finds each original withsearch_for, adds a redaction annotation, thenapply_redactions. That excises the old glyphs from the content stream. It is not an overlay-only black box.- The reversible mode writes the stand-in (
EMAIL_1) into the redaction.--redactfills the box black and does not write a stand-in. Deanonymize cannot restore a redacted page. - Every native write sanitizes
/Info, XMP, embedded files, leftover annotations, and optional-content groups, and saves withgarbage=4(no incremental/Prevhistory). - Rasterize-and-rebuild is the scan fallback (item 14), not the digital default. Images, form fields, and some vector drawings can still leak. This is not a legal de-identification certificate.
Semantic Chunking¶
- Depending on the
--characters-to-anonymizeparameter (default100,000characters), the text is sliced into chunks:- Markdown/PDF: Uses
langchain_text_splitters.MarkdownTextSplitterto avoid cutting headers or code blocks midway. - Text/Fallback: Uses
langchain_text_splitters.RecursiveCharacterTextSplitter.
- Markdown/PDF: Uses
- This keeps individual requests within LLM token constraints and limits memory footprints.
Linkage-risk report¶
- After masking, the CLI scores clumps of stand-in types in the same passage (job + company + place).
- Result is
data/stats/<stem>.risk.json(high/medium/low). The file is not rewritten.pdf-anonymizer reportruns the same score later.
Residual check (after replacement)¶
- The CLI re-runs the cheap regex pass on the masked text (unless
--no-verify). - Stand-in labels (
PERSON_1,IBAN_LIKE_1) are ignored. Leftover emails or numbers are written todata/stats/<stem>.residual_pii.json. - The file is not rewritten.
--verify-llmadds an optional second read by the language model.pdf-anonymizer verifyruns the same scan later.
Regex first pass (with checksums)¶
- Each chunk is scanned with the RE2 pattern library (emails, cards, IBANs, national IDs, and so on).
- A hit that has a cheap extra digit check (card Luhn, IBAN mod-97, VIN check digit, a few national IDs) is relabeled
TYPE_LIKEif that check fails (for exampleIBAN_LIKE). The text is still replaced. A verified hit keeps the real type and wins if both labels appear for the same span. ListingIBANin--anonymized-entitiesalso includesIBAN_LIKE.
LLM Entity Identification¶
- Each chunk is sent to the selected LLM provider along with the chosen prompt.
- The LLM returns structured JSON lists of detected entities, specifying their direct text and their base entity type (e.g.
PERSON,ORGANIZATION,DATE,LOCATION). - The detailed prompt also asks for identity clues: phrases that point to one person without writing their name. If the model knows who is meant, it uses type
PERSONand puts the name inbase_form. If it does not know the name, it uses typeINDIRECT. The simple prompt does not ask for this. See How PDF Anonymizer is Different.
Base Form Consolidation¶
To solve coreference problems (e.g. associating "Dr. Smith", "Smith", and "Dr. John Smith" to the same individual):
* The system extracts all entity base_form suggestions.
* It sorts base forms by length (descending) and merges shorter matching forms into their longer canonical representation.
Placeholder Mapping Generation¶
- Standard placeholders are created using the entity type and an incremental count (e.g.,
PERSON_1,PERSON_2). - Variations Handling: If an entity is a partial or varied reference of a base form (e.g. "John" vs. "John Doe"), a sub-variant placeholder is generated (e.g.,
PERSON_1.v_1). This tracks how the text refers to the individual without losing syntactic differences.
Span-based replacement¶
- Mentions are located in the full document with word-boundary rules.
- Overlapping hits are resolved longest-first (
John Doewins over the innerJohn). - Slices are written from the end of the string so earlier offsets stay valid.
Per-type operators¶
- Default write is still
replace(PERSON_1). --operator TYPE=mask|hash|generalize|shift|fake|encryptchanges how that type is written.CREDIT_CARD_LIKEfollowsCREDIT_CARD.encryptis AES-256-GCM (ENC1_β¦), reversible with a secret, and those originals are omitted from the mapping file.fakeis seeded (--fake-secret/ANONYMIZER_FAKE_SECRET) so the same person always gets the same invented name.- Two dates that both become
2019cannot both be restored uniquely.
Keep-list and deny-list¶
- Keep-list phrases stay visible even if regex or the model found them.
- Deny-list phrases become
CUSTOM_neven if detection missed them. - Keep wins if the same phrase is on both lists.
Cross-document maps¶
--mapping-inseeds placeholder counts from an existing map (plaintext or encrypted).- Files in one
runshare the growing map so Ada staysPERSON_1.
Regex-only / offline¶
--no-llmor-p regex-onlyskips the language model. Regex, checksums, operators, leftover scan, and risk still run. Names and identity clues are missed.
Encrypted mapping¶
- Default remains plaintext
*.mapping.json. - A passphrase (
--mapping-passphrase/ANONYMIZER_MAPPING_KEY) writes AES-256-GCM + Argon2id*.mapping.json.enc. The source file SHA-256 and schema version are bound as GCM AAD. Mapping files (plain or locked) are written atomically as mode0600. --ephemeral-mappingkeeps the vocabulary in process memory only.deanonymizedecrypts with the same key.--source-sha256rejects a map locked for a different file.- Design, threat model, trade-offs, and test notes: Mapping encryption.
HIPAA coverage aid¶
--entity-profile hipaa-safe-harbor(andprompts.hipaa) asks for the identifier classes that apply to text and applies year-only dates, ZIP3, age90+.- This is an aid, not a compliance certificate. Pixels in photos are not hidden.
TAB-style eval harness¶
tests/eval/scores mention-level and entity-level recall, split by direct vs quasi identifiers.scripts/eval_tab.pyruns the mini fixture. Tests and scripts only β no product change.
Table path (CSV / Excel)¶
.csvand.xlsxnever enterload_and_extract_text_from_file.tables.load_tablewalks cells; apply writes cells. The file is not flattened into one replacement buffer.- Regex runs per-cell on
textand formula-cached strings only. Number and date cells skip regex so employee IDs and quantities are not shredded. - LLM sees row-addressed batches (
[Sheet!A2] Name: β¦), including serialized number/date cells. A hit is kept only iflocate_spansfinds it in some cell. - Apply is
replace_entitieson each cell'ssearch_textwith the sameentity["text"]list the text engine uses. - Review flatten (
v1 | v2 | β¦, blank line after every row) is whatanonymize_filereturns for tables.verifyandreportscore that flatten, not raw CSV bytes. - Excel writes cached values only (no formulas). Charts, comments, headers/footers, validation lists, defined names, and hyperlinks are left as-is.
- This is still cell-level pseudonymization. It is not k-anonymity.
Word path (DOCX)¶
.docxnever entersload_and_extract_text_from_file.word.load_docxwalks visible paragraph text in the main document, headers, footers, footnotes, endnotes, comments, and glossary parts; apply writes those same nodes. Field codes (w:instrText) and external hyperlink targets are separate blocks.- Regex runs per block on the concatenated visible text of each paragraph (runs are joined so
jane+@+acme.comis one email). - LLM sees the part-wise flatten. A hit is kept only if
locate_spansfinds it in some block. - Apply is
replace_entitieson each block'ssearch_textwith the sameentity["text"]list the text engine uses. Replacement text is written into the firstw:tof the paragraph; later runs are cleared. Intra-run formatting after the first run is not preserved. - Review flatten (
# Part: /word/document.xmlthen paragraphs) is whatanonymize_filereturns for Word.verifyandreportscore that flatten, not the ZIP bytes. - Tracked-change deletions (
w:del) are skipped. Images, alt text, core properties (author), charts, and embedded objects are left as-is. .doc,.docm,.dot,.dotm, and.dotxare rejected.
Reversibility & The Mapping Engine¶
When executing pdf-anonymizer deanonymize, the recovery engine performs the following tasks:
Bidirectional Mapping Compatibility¶
- It accepts both current format (
placeholder -> original_value) and legacy format (original_value -> placeholder) mapping tables by automatically detecting matching regex structures.
Dynamic Wildcard Reversion¶
- When replacing placeholders, it matches the base placeholder and any sub-variants dynamically using a regular expression:
This ensures
PERSON_1.v_1andPERSON_1are both restored to the same correct original name.
Statistics & Auditing¶
After restoration, the engine computes:
* unused_mappings: Placeholders present in the map that were not found in the anonymized text.
* not_found_mappings: Placeholders detected in the text that had no matching entry in the map.
* These are output to a JSON file in data/stats/<stem>.deanonymization_stat.json for validation and compliance auditing.
See Also¶
- Recipes & Common Workflows β practical usage of the concepts described here (profiles, caching, debugging, round-trip workflows).
- CLI Reference β full command reference and profiles.
- SDK & API Usage β programmatic access to the same engines.
- API Reference (auto) β detailed function signatures.
- Installation & Setup β environment and provider setup.