Redact a PDF
What it does. When a document is marked Sensitive, the script reads its text, finds email addresses, UK phone numbers and National Insurance numbers, removes them from a copy of the PDF, and saves the copy in a Redacted folder. The original isn’t changed. If a page is a scanned image with no text to search, the script says so and stops, because a page it couldn’t check can’t be called clean.
What you need.
- The MaSH feature.
- A Classification field on the share, filled by Captur or a person, with the value
Sensitive. - Triggers: Captur Extracted (Captur sets the classification) and Fields Changed (a person sets it). Filter both to
pdfin Extension. See Events. - Script setting:
DEST(the folder for copies, defaultRedacted, created if missing).
Finding the phrases, on its own
Section titled “Finding the phrases, on its own”The detection is plain pattern matching on text, so you can test and tune it without a PDF:
block findPhrases text set patterns to [ "/\\b[A-Z]{2}\\d{6}[A-D]\\b/", "/\\b[\\w.+-]+@[\\w-]+\\.[\\w.-]+\\b/", "/\\b(?:\\+44 ?|0)7\\d{3} ?\\d{6}\\b/" ]
set phrases to [] each patterns as pattern set found to mash.str(text).match(pattern, 1) each found[0] as m phrases[] = mash.str(m).trim().toString() end end
return mash.collect(phrases).unique().values().all()end
set sample to "Contact Sam on 07700 900123 or sam@example.com. NI number AB123456C. Ref 2041."
each findPhrases(sample) as p printline pendAB123456Csam@example.com07700 900123block findPhrases(text) { patterns = [ "/\\b[A-Z]{2}\\d{6}[A-D]\\b/", "/\\b[\\w.+-]+@[\\w-]+\\.[\\w.-]+\\b/", "/\\b(?:\\+44 ?|0)7\\d{3} ?\\d{6}\\b/" ]
phrases = [] each (patterns) as pattern { found = mash.str(text).match(pattern, 1) each (found[0]) as m { phrases[] = mash.str(m).trim().toString() } }
return mash.collect(phrases).unique().values().all()}
sample = "Contact Sam on 07700 900123 or sam@example.com. NI number AB123456C. Ref 2041."
each (findPhrases(sample)) as p { printline(p)}AB123456Csam@example.com07700 900123Order 2041 stays, because no pattern matches it. A pattern that is too loose will remove things you meant to keep. A pattern that is too tight will miss something. Tune them on real text, and look at the result.
The script
Section titled “The script”set errorblock to "onError"block onError printline "Error on line {{mash.error.line}}: {{mash.error.message}}"end
block findPhrases text set patterns to [ "/\\b[A-Z]{2}\\d{6}[A-D]\\b/", "/\\b[\\w.+-]+@[\\w-]+\\.[\\w.-]+\\b/", "/\\b(?:\\+44 ?|0)7\\d{3} ?\\d{6}\\b/" ]
set phrases to [] each patterns as pattern set found to mash.str(text).match(pattern, 1) each found[0] as m phrases[] = mash.str(m).trim().toString() end end
return mash.collect(phrases).unique().values().all()end
set destName to mash.config.get("DEST") ?? "Redacted"
set doc to mash.event.fileif not doc returnendif doc.is_dir returnendif doc.extension is not "pdf" returnend
set classification to mash.str(doc.fields["Classification"] ?? "").lower().toString()if classification is not "sensitive" returnend
set text to mash.pdf.text(doc)set phrases to findPhrases(text)
if count(phrases) is 0 printline "{{doc.name}} is Sensitive but no pattern matched. Check it by hand." returnend
set result to mash.pdf.redact(doc, phrases)printline "Redacted {{result.redacted}} areas in {{doc.name}}"
if count(result.scanned_pages) is more than 0 set pages to mash.collect(result.scanned_pages).join(", ") printline "Not saved: page {{pages}} has no text layer, so it could not be checked. Review {{doc.name}} by hand." returnend
if count(result.missing) is more than 0 printline "Not found on the page: " ~ mash.collect(result.missing).join(" | ")end
set destFolder to doc.parent.make(destName, true)set copy to destFolder.write("redacted-{{doc.name}}", result.file, true)printline "Saved {{copy.path}}"errorblock = "onError"block onError() { printline("Error on line {{mash.error.line}}: {{mash.error.message}}")}
block findPhrases(text) { patterns = [ "/\\b[A-Z]{2}\\d{6}[A-D]\\b/", "/\\b[\\w.+-]+@[\\w-]+\\.[\\w.-]+\\b/", "/\\b(?:\\+44 ?|0)7\\d{3} ?\\d{6}\\b/" ]
phrases = [] each (patterns) as pattern { found = mash.str(text).match(pattern, 1) each (found[0]) as m { phrases[] = mash.str(m).trim().toString() } }
return mash.collect(phrases).unique().values().all()}
destName = mash.config.get("DEST") ?? "Redacted"
doc = mash.event.fileif (not doc) { return}if (doc.is_dir) { return}if (doc.extension != "pdf") { return}
classification = mash.str(doc.fields["Classification"] ?? "").lower().toString()if (classification != "sensitive") { return}
text = mash.pdf.text(doc)phrases = findPhrases(text)
if (count(phrases) == 0) { printline("{{doc.name}} is Sensitive but no pattern matched. Check it by hand.") return}
result = mash.pdf.redact(doc, phrases)printline("Redacted {{result.redacted}} areas in {{doc.name}}")
if (count(result.scanned_pages) > 0) { pages = mash.collect(result.scanned_pages).join(", ") printline("Not saved: page {{pages}} has no text layer, so it could not be checked. Review {{doc.name}} by hand.") return}
if (count(result.missing) > 0) { printline("Not found on the page: " ~ mash.collect(result.missing).join(" | "))}
destFolder = doc.parent.make(destName, true)copy = destFolder.write("redacted-{{doc.name}}", result.file, true)printline("Saved {{copy.path}}")How it works
Section titled “How it works”mash.pdf.textreads the PDF’s text layer. A scanned page has none, so its text is empty. That’s why the script checksscanned_pagesafterwards.mash.pdf.redact(doc, phrases)removes each phrase from the page itself. It isn’t a black box over the text. The text is gone from the copy and can’t be selected, copied or searched. The result hasfile(the redacted copy),redacted(how many areas),missing(phrases it couldn’t find) andscanned_pages.- Matching is exact and ignores case. A phrase that is split over two lines on the page isn’t found, and appears in
missing. - Nothing is written by
redactitself. The copy is a temporary file.destFolder.write(name, result.file, true)saves it, with the run’s own permissions. Thetruereplaces an earlier copy of the same name, so each document keeps one redacted copy. - The original is never touched. That is deliberate. Don’t delete it from the script.
- The scanned-page stop is a safety rule. If any page couldn’t be searched, nothing is saved, and the run log says which page needs a person.
Variations
Section titled “Variations”- Fixed areas. For a form where a photo or signature box never moves, pass areas as the third argument:
mash.pdf.redact(doc, phrases, [{"page": 1, "rect": [140, 20, 190, 70], "unit": "mm"}]). An area is burned whatever is under it, including a picture, so it also works on a scanned page. - Fields as phrases. Add the values of Captur fields such as Name and Address to
phrases. - Share a link to the redacted copy.
copy.sharing.createLink()makes a link, andcopy.sharing.link.urlis its address. The link needs someone to share as, and the share’s rules may hold it for approval. See the Files library page. - Ask Grace for names. Patterns can’t find names or addresses.
mash.grace.structuredcan, at the cost of Grace credits, and with the usual limits on sending personal data to a model. Test it carefully. - A person decides. Instead of
returnon a scanned page, set a field such as Needs review and let people take over.