DEV Community

Cover image for Redacting PII 100% Offline: A Technical Deep Dive
Muhammad Monjurul Karim
Muhammad Monjurul Karim

Posted on • Originally published at Medium

Redacting PII 100% Offline: A Technical Deep Dive

Cross-posted from Medium. Originally published at https://medium.com/data-science-collective/redacting-pii-100-offline-a-technical-deep-dive-a326f9fed3b8

If you handle contracts, medical records, legal discovery, or even just research data, redacting PII is not optional. HIPAA, GDPR, PCI, FOIA — they all want it gone. And yet the tools we reach for are stuck in two failure modes.

The first is the cloud route: you upload a document full of the exact personal data you're trying to protect, and a server sends back a "redacted" copy. That's a privacy paradox — you solve the privacy problem by first exposing the document. For anything sensitive, that's a non-starter.

The second is the manual route: draw black rectangles over every SSN by hand. Slow, error-prone, and because most viewers black the rendering rather than the text, the layer underneath can often be selected or deleted to reveal what was "redacted." Or the tool auto-blacks whole lines and you get a wall of censor bars.

Neither is good enough. I wanted accurate, automatic, value-only redaction that runs on my own machine. So I built one. (If you want the tool and not the story, it's here: github.com/monjurulkarim/privateredact.) This post walks through how it works — extraction, hybrid regex + local-LLM detection, the part nobody gets right (drawing the box), and proving the output is actually clean.

The "why"

The goal wasn't just to scribble black over a PDF. It was to build something I'd trust with a real medical record or contract:

  • Read anything — text PDFs, scanned/image PDFs (OCR), DOCX, TXT, images.
  • Find PII semantically, not just by format. Regex catches a phone number. It does not catch "patient presented with Type 2 diabetes."
  • Redact the value, not the label555-1234, not Phone: 555-1234. Never the whole line.
  • Preserve the document — DOCX formatting intact.
  • Prove it worked — a post-export scan that re-OCRs the output and reports what survived.
  • 100% local — no file, hash, or byte leaves the machine.

The stack

Everything below runs on the user's machine. No backend.

  • Electron — bundles the renderer, the PDF/OCR engines, and the LLM into one installable app.
  • pdf.js (Mozilla) — reads born-digital PDFs, and crucially exposes each text item's bounding box.
  • Tesseract (v6) — OCR for scanned PDFs and images; v6 returns word-level boxes.
  • Ollama — local LLM runtime for semantic detection.
  • pdf-lib (encrypted-aware fork) — output assembly + password-protected I/O.
  • sharp + node-canvas — rasterizing pages and painting redaction boxes.
  • jszip — DOCX files are zip-of-XML; this lets you edit OOXML in place.

Extraction: born-digital vs. scanned

A PDF isn't a PDF. Some have a real text layer; others are wrapped images of scanned paper. Redacting text you never read is how leaks happen — so you fork on whether the page actually has a text layer:

# Conceptual — two extraction paths
if page.has_text_layer:
    # born-digital: pdf.js gives each text item + its bounding box
    words = [{'text': item.text, 'box': item.bbox} for item in page.items]
else:
    # scanned / image: OCR it, and we need WORD-level boxes, not just text
    ocr = tesseract.recognize(page.render_to_image(), blocks=True)
    words = [{'text': w.text, 'box': w.bbox} for w in ocr.words]
Enter fullscreen mode Exit fullscreen mode

Non-obvious bit: when you OCR, you must ask for word-level boxes (Tesseract's { text: true, blocks: true }). Otherwise the engine hands you whole paragraphs as one box, and you're back to blacking entire lines.

Detection: regex first, LLM second

Two layers, because neither is sufficient alone.

The regex layer is fast and precise for well-formatted PII. The trick is value-only matching — you don't want to black SSN:, you want to black the number after it:

# Redact the VALUE (capture group 2), not the label (group 1)
pattern = r'(SSN|Social Security)[:\s]+([0-9X\-]{9,})'
for m in re.finditer(pattern, text):
    value_start, value_end = m.span(2)   # the number's offsets only
    boxes = offsets_to_boxes(value_start, value_end, page)
Enter fullscreen mode Exit fullscreen mode

Real gotcha: ID patterns need a digit lookahead. Without one, they false-positive on prose — "passport control", "case study", "member number two."

The LLM layer catches what patterns structurally cannot: a diagnosis, a codename, an address buried in a sentence. The page text is chunked and sent to the local Ollama model with a strict JSON schema, and it returns { type, text, start, end, confidence }:

detections = regex_scan(page.text)              # fast, known formats
detections += llm_scan(page.text, model=ollama) # context-only PII
for d in detections:
    d.boxes = resolve_span(d.text, page)        # tolerant of case/whitespace
Enter fullscreen mode Exit fullscreen mode

The hard part isn't calling the model — it's trusting its output enough to draw a box. You have to actually find that string in the page text and map it to a real bounding box; if the match rate is low, you drop the detection rather than risk a misplaced box.

The part nobody gets right: drawing the box

This is where most redaction tools fall apart.

pdf.js doesn't return one box per word. It returns runs of text, and a single run can span an entire line — sometimes 80% of the page width. If you naively black the run's box for any entity inside it, you black the label, the surrounding words, everything.

The fix is proportional sub-boxes. Inside a run, you know the character offset where the entity starts and ends. Assuming roughly uniform char width within that run, black only that horizontal slice:

# Don't black the whole run. Black the SLICE the entity occupies.
def proportional_box(run_box, ent_start_char, ent_end_char, run_len):
    width = run_box.x1 - run_box.x0
    pad   = 0.4 * (width / run_len)          # a little breathing room
    x0 = run_box.x0 + (ent_start_char / run_len) * width - pad
    x1 = run_box.x0 + (ent_end_char   / run_len) * width + pad
    return Box(x0, run_box.y0, x1, run_box.y1)
Enter fullscreen mode Exit fullscreen mode

So Phone: ████-████ redacts just the bold part. The label survives. The document stays readable.

Two more rules, learned the hard way:

  • One union function, used twice. The boxes shown in the review overlay must be exactly the boxes painted on export. If they differ, what the user approves is not what they get. Both paths call the same routine.
  • Reject pathological boxes. If a computed box covers 95%+ of the page, something went wrong. Reject that detection outright rather than black the page.

Export and proof

For PDF, each page is rasterized, the unioned boxes are painted solid black, and the result is embedded into a fresh PDF. Rasterized output is the point: no recoverable text layer. You cannot select-and-delete the black box to reveal the original.

# Rasterize -> paint -> embed. No text layer survives.
for page in document:
    raster = render(page)
    raster = paint_black(raster, page.unioned_boxes)
    output_pdf.embed(raster)
Enter fullscreen mode Exit fullscreen mode

DOCX is trickier — you want to keep the formatting. A DOCX is a zip of XML, so redaction is in-place surgery on the XML text nodes: find the runs, replace the PII characters inside them, leave every other node untouched.

The last stage is the one I care most about: the leak scan. After the file is written, the output is OCR'd again and re-scanned. It runs on the exact bytes written, and it never claims "clean" if the scan was skipped or failed:

output_text = ocr(exported_pdf_bytes)
remaining   = scan_for_pii(output_text)
if scan_failed_or_skipped:
    report("could not verify — do not ship")
elif remaining:
    report(remaining)          # exactly what survived
else:
    report("0 PII remaining")
Enter fullscreen mode Exit fullscreen mode

"0 PII remaining" isn't a button label — it's the result of actually re-reading the redacted file and checking.

Frontend: approve what you export

A backend this autonomous is also dangerous — you don't hand a model the keys to black out a contract unsupervised. The frontend is a review surface: a React overlay on the page image showing every proposed redaction as a box, with a toggle between image and raw text. Remove a box the model got wrong, or draw one it missed. Because the overlay uses the same union-boxes logic as the exporter, what you see is byte-for-byte what gets painted.

Want to try it yourself?

I packaged the whole thing — extraction, hybrid detection, the review UI, the leak-scan proof — into a desktop app. It runs entirely on your machine: no account, no upload, nothing leaving your disk.

The fastest way to see it work on your own documents is the free build on GitHub. You can load, detect, and review a real file end to end — exporting the redacted output is the only thing behind a license.

Try it: github.com/monjurulkarim/privateredact
Or grab a license to export: in this link


Over to you: what's the hardest PII you've had to redact from a document — and did the tool you used actually get it right? I'm genuinely curious what detection gaps people hit in practice. Drop it in the comments 👇

Top comments (0)