keptpdf
v1.19.0
Published
Bulk PDF redaction that runs entirely on your own machine. Finds names, dates, account numbers and more, burns them into the page, and writes a verifiable certificate. No upload, no account, no network.
Maintainers
Readme
keptpdf
Bulk PDF redaction that runs entirely on your own machine.
It finds names, dates of birth, account and case numbers, addresses, emails,
phone numbers and more, burns them out of the page, and writes a certificate for
each file. Scanned pages are OCR'd first. Nothing is uploaded, there is no
account, and it makes no network calls of any kind, including for its license.
The one exception is opt-in and yours to invoke: keptpdf update asks
keptpdf.com for the newest version number (sending only the version you have
and your operating system), then installs it from npm, and only when you run it.
npx keptpdf redact ./intake -o ./produced -rInstall
npm install -g keptpdfNode 18.17 or newer, on Windows, macOS or Linux.
Two dependencies, 27 packages, no compiler and no build step. npm audit
reports no known vulnerabilities. The PDF and OCR engines ship inside the
package rather than being pulled from the registry, so what you install is what
was tested.
Redaction is real, and it is checked
Every output is re-opened and re-read before it is written. If any of the text
that was supposed to be gone can still be extracted, the file is not written at
all. The certificate beside it (.cert.txt and .cert.json, plus a branded
.cert.pdf on licensed runs) records the counts, the SHA-256 of the input and
output, and that verification result. On licensed runs the certificate also
names the organization the license is signed to, on the document and on its
online verify page, so every deliverable shows whose license produced it.
A scanned page inside an otherwise typed file is read by OCR like any other. A
page that still cannot be read (with --no-ocr, or when recognition fails) is
never passed off as clean: the line for that file ends with N pages could not
be read in yellow, the summary counts those files apart from the clean ones,
and the certificate states that the page numbers were not checked for personal
information. With --json, every file carries a top-level unreadPages array
of 1-based page numbers (empty when every page was read), and the summary
carries withUnreadPages.
Redacted pages are flattened to images, which makes them safe but not
searchable. --searchable adds a text layer back: it is read off the finished
redacted page images by OCR, after the zero-character check above, so it cannot
contain what was removed. The certificate records the layer.
No flags needed: the menu
Typing keptpdf alone in a terminal opens an interactive menu. Every place a
path is needed opens a file browser: arrow keys move, Enter opens a folder or
picks a file, left arrow goes up, and typing a few letters finds a file by
name. No path is ever typed unless you want to (a "Type a path" row, with Tab
completion, is always there). It walks through everything the flags do: redact
a file or folder, review before burning, split, Bates-number, enter a license key, and set
up watched folders. Redact and watch runs can stamp an exemption code in each
box: the 14 standard FOIA codes are offered as a pick list with plain-English
hints, and any other code can be typed. None is ever preselected; which code
applies is your legal call. Each run prints the equivalent command line, so the menu
also teaches the flags as you use it.
Watched folders are the server story. Save one in the menu (which folder, where
output goes, how thorough) and the menu can write a ready-to-install service
file for the machine it is running on: a systemd unit on Linux, a Task
Scheduler command on Windows, a launchd plist on macOS. Install it with the
printed one-liner and every PDF dropped in that folder comes out redacted, from
boot, with no one logged in. A saved setup can also be run by hand with
keptpdf watch --name <its-name>.
Scripts see no change: keptpdf with no arguments in a pipe or a cron job
still prints the help and exits 2, exactly as before.
The commands
| Command | What it does |
| --- | --- |
| keptpdf | Open the interactive menu (terminal only) |
| keptpdf redact <input...> -o <dir> | Redact files, directories or globs |
| keptpdf review <input...> -o <file.csv> | Write one row per candidate for a person to approve |
| keptpdf watch <in-dir> -o <out-dir> | Redact each new file dropped in a folder |
| keptpdf watch --name <saved> | Run a watch setup saved from the menu |
| keptpdf split <input...> -o <dir> | Copy pages out: --pages "1,3-5" or --every <n>, or find the documents inside with --auto (--read-scans for scanned paper) |
| keptpdf bates <input...> -o <dir> | Stamp continuous Bates numbers across PDFs (--combined for one file; --restart-each to start every file at 1; --prefix / --designation / --restamp) |
| keptpdf categories | List the category ids for --only / --skip |
| keptpdf license | Show the license status |
| keptpdf update | Install the newest version. --check reports it and installs nothing |
keptpdf --help documents every option.
Word, Excel and PowerPoint inputs
Office files (.doc .docx .xls .xlsx .ppt .pptx .odt .ods .odp
.rtf) are accepted on redact, review and watch: as file arguments, in directories, in
globs and in watch folders. bates is PDF-only in this version. Each one is converted to a PDF on your machine
first, then redacted like any other input. The conversion uses your installed
copy of LibreOffice (nothing bundled,
nothing uploaded); if it is missing, the run says so up front. Spreadsheet
columns are auto-widened before conversion so text a narrow column would clip
(the tail of an SSN) cannot be hidden from detection.
Split a packet into the documents inside it
One PDF that is really eleven documents is the file nobody meant to make: a
scanned intake folder, a discovery production, a court filing with its exhibits
bound in. --auto reads every page, works out where each document starts, and
writes one PDF per document named after that document's own title.
keptpdf split ./production -o ./documents --auto --dry-run # look first
keptpdf split ./production -o ./documents --auto # then writeA dry run prints the documents, their page ranges and the reason each break was
called, and writes nothing. Add --verbose and it also prints the breaks it
nearly called, which is the difference between "raise the sensitivity" and
"this really is one document".
+ production.pdf 4 documents in 87 pages 6.2s
1. pages 1-12 Master Services Agreement -> production-01-master-services-agreement.pdf
2. pages 13-40 Exhibit A Statement of Work -> production-02-exhibit-a-statement-of-work.pdf
3. pages 41-79 Pages 41-79 -> production-03.pdf
4. pages 80-87 Letter to Dana Reed -> production-04-letter-to-dana-reed.pdfA document that starts on a scanned page still gets a real name: that one page
is read so the title can come off it. Only the first page of each document, so a
forty-page scanned block costs two pages of reading, not forty. --no-ocr turns
that off and those documents keep their page range as a name. Nothing is ever
given an invented one.
--auto-sensitivity low | medium | high decides how hard it looks. Medium is
the default. Lower finds fewer, clearer breaks; higher finds most of them and
some wrong ones. A wrong break costs you one merge, a missed one has to be
found first, which is why the default leans toward finding them.
Scanned pages are not skipped. A page with no text layer is fingerprinted by the shape of its ink (how dark the paper is, where the margins fall, how far apart the lines sit), so a block of scans still gets split where the documents change. Only those pages are rendered, so a born-digital file pays nothing for it.
When the file is mostly scans, --read-scans goes further: it reads every page
that has no text before looking for the breaks, so the detector has the words
instead of just the ink.
keptpdf split ./backfile -o ./documents --auto --read-scans --dry-runIt usually finds more documents, and it costs minutes per file rather than seconds, so it is asked for rather than assumed. It buys nothing on a PDF that already has text. A run that could have used it says so at the end.
Bookmark it instead of cutting it
Cutting is one way to spend that answer. The other is to cut nothing and write ONE PDF whose sidebar lists the documents. A resident handed a 300-page scanned property folder wants to jump to the building permit, not to receive forty loose files.
keptpdf split ./folders -o ./out --auto --output-shape bookmark # one file, bookmarked
keptpdf split ./folders -o ./out --auto --output-shape both # and the separate files too--output-shape files is the default and is what has always happened, so a
script written before this flag existed still means what it meant. bookmark
writes <name>-bookmarked.pdf: every page of the input, in order, nothing
removed and nothing reordered, with a top-level bookmark at the first page of
each document carrying that document's own title.
+ Property-Folder.pdf 2 documents in 15 pages 1.8s
1. pages 1-10 Master Services Agreement page 1
2. pages 11-15 Exhibit A Statement of Work page 11
wrote Property-Folder-bookmarked.pdf 2 bookmarks, one file, nothing cutNo table of contents page is inserted. Adding one would push every page down by
one, and the page numbers you already have on the packet would stop matching the
file. --dry-run names the file it would write and counts the bookmarks;
--verbose prints each entry and the page it jumps to.
Sort them into piles: --by-type
--by-type adds the third question, the one a records office asks first: what
kind of paper is this. Each document is read and dropped into one of thirteen
piles (permit, application, inspection, certificate, enforcement,
decision, plans, survey, recorded, property-card, submittal,
correspondence, and other for anything it will not guess at). It works with
either shape and changes only where things land: with files each document is
written into a folder named for its pile, and with bookmark the sidebar gets
a heading per pile with that pile's documents under it. --review shows the
pile beside each document and t changes it before anything is written.
keptpdf split ./folder -o ./out --auto --by-type
keptpdf split ./folder -o ./out --auto --output-shape bookmark --by-typeout/
permit/ Property-Folder-01-building-permit.pdf
survey/ Property-Folder-04-certificate-of-survey.pdf
other/ Property-Folder-07-pages-22-24.pdfThe folder is the pile's id, lower-case and ASCII, so it is a safe folder name
everywhere and a stable key for a script. --json reports pile and
pileConfidence for every document whether or not you asked for --by-type.
The index columns
Every document also comes back with the four things a records clerk types into the index so a file is findable years later, read off its own first page:
| field | what it is |
| --- | --- |
| vendor | the outside organization the document is from or names as the other party |
| date | the document's own date, as YYYY-MM-DD |
| amount | the sum it commits, as printed ($322,520.00) |
| reference | the number it carries: a resolution, permit, quote or invoice number |
A field is an empty string when it was not found, and that is a real answer
rather than a failure. Nothing is inferred: each value is copied from a caption
the paper actually prints, so a resolution numbered by hand at the meeting comes
back with an empty reference instead of an invented one. The listing prints
the fields it found under each document's name, --review shows them for the
document under the pointer, and --json carries all four on every document.
Nothing can be read off a page with no text layer, so a scanned backfile needs
--read-scans for these to fill in, the same as the names do. Recognition is
imperfect on a logo and reliable on printed captions, which is why the vendor is
the field a scan most often leaves blank.
This is the same detector the KeptPDF website runs, and the same bookmark writer, so the two cannot propose different splits for the same file. Like everything else here, it reads your file on your machine and sends nothing anywhere.
Bates numbers on a production
Stamp a continuous PREFIX+number+SUFFIX identifier on every page of one or more PDFs, the same engine the website runs. PDF files only: Office inputs are not accepted here.
keptpdf bates ./production -o ./stamped --prefix SMITH
keptpdf bates ./production -o ./stamped --prefix SMITH --combined --designation CONFIDENTIAL
keptpdf bates ./production -o ./stamped --prefix SMITH --restart-eachSeparate files (the default) write <name>-bates.pdf, numbered continuously: if
the first file ends at 000010, the second starts at 000011. Add --restart-each
and every separate file starts again at --start instead, so two 3-page files
read 000001-000003 and 000001-000003; the range report lists each file's own range
(and the numbers repeat across files, so give each its own prefix if you will load
them into a review platform). --combined writes one
<stem>-bates-production.pdf and numbering does not restart per file, so
--restart-each cannot be used with it. A
plain-text range report <stem>-bates-report.txt is always written. --restamp
covers an existing number with a box on standard white-background pages; it is
not a deletion of the old stamp.
Trial output carries the same footer as redact. A license key removes it. This version does not write Concordance load files, slip sheets, per-file prefix maps, or skipped pages.
Review before you burn
Redaction is not reversible, so a person can approve it first.
keptpdf review ./intake -o decisions.csv
# open decisions.csv, change any wrong "keep" to "redact" (or the reverse)
keptpdf redact ./intake -o ./produced --apply decisions.csvRows above the sensitivity threshold arrive marked redact, rows below it
arrive keep. An untouched file applies exactly what a plain run would have
done, so adopting the step can never quietly stop redacting something.
Each row also has a code column. Type an exemption code there, like
(b)(7)(C), and that one box gets that code at burn, beating --label and
--labels. That is how one document cites different exemptions on different
redactions.
The review file holds the detected text, because a human has to read it to judge it. It is a working file. Keep it with the sources and never ship it with the production.
Keep the last four digits: --keep-last
Court filing rules (FRCP 5.2(a), Bankruptcy Rule 9037) let a filing show the last
four digits of a Social Security, taxpayer or financial account number. They allow
no more than four: --keep-last accepts 1 to 12, but a larger number leaves more
visible than those rules permit, and the certificate then cites no rule for it.
keptpdf redact ./filings -o ./produced --keep-last 4With --keep-last 4, only the digits before the last four are boxed on SSNs,
ITINs, EINs, card numbers, account numbers and IBANs. Every other category is
boxed whole, as before, and nothing changes without the flag. The box edge is
placed on the printed page, not estimated from the text layer. If the digits do
not separate cleanly there (a wrapped or tilted number, glyphs that touch), or the
last digits hold a letter, or the number has four digits or fewer, the whole
number is boxed instead; the log's note column says why. A number that already
prints a mask (XXX-XX-1234) is left as it is.
The redaction log gains a kept column (the digits still visible) and a note
column. The certificate counts how many numbers keep their last digits and how
many were boxed in full, and states that those values are not HIPAA Safe Harbor;
it never lists the digits themselves.
With --searchable, the kept digits may not come out as searchable text: the
text layer is read back off the finished page by OCR, and a short run of digits
next to a black box is the kind of text OCR can misread or skip.
Exemption codes and the redaction log
FOIA and most production protocols want each black bar to say why it is there.
keptpdf redact ./records -o ./produced --label "(b)(6)" --log production.csv--label stamps the code inside the box, clipped to it, never growing the
redaction. --labels <file> gives a different code per category. The log records
one row per box: file, page, category, code, method, confidence, position, and
the certificate id. It carries no redacted text, on purpose: the log travels
with the production, so listing what you hid would undo the redaction.
KeptPDF ships no exemption preset. Which one applies is your legal call, not a default.
Licensing
Install the key from your license email with keptpdf license --set <key>: it
checks the signature and, only if the key is good, saves it in
~/.keptpdf/license.txt for you. To place a key yourself instead, or to keep
one per client folder, the older routes all still work.
Set KEPTPDF_LICENSE, or put the key in ./keptpdf-license.txt or
~/.keptpdf/license.txt, or point one run at a specific key with
--license <file> (it beats all of the above; a missing file refuses the run
instead of falling back to another key). Every licensed run prints which key it
used, and keptpdf license lists every place a key was found and which one
wins. The key is checked with an offline signature, so a
batch that runs overnight cannot be stopped by someone else's server.
A trial key is free from https://keptpdf.com/cli. It raises the 100 free files to
500 in total (files already processed count toward the 500) and stops working
after 30 days; install it with the same keptpdf license --set <key>. It needs a
release newer than 1.17.0, so run npm install -g keptpdf@latest first if the
command says the key is not valid. A full license always wins over a trial key.
A license covers the documents of the organization named in the key. If you are a service provider running it for clients, each client organization needs a license naming them, and you may operate the software for them under it. Partner terms are at https://keptpdf.com/cli.
Working for multiple clients
The key file in the current directory wins over the machine-wide one, so keep one folder per client with that client's key beside the documents:
clients/
├── acme-legal/
│ ├── keptpdf-license.txt # Acme Legal's key
│ └── intake/
└── harbor-cpa/
├── keptpdf-license.txt # Harbor CPA's key
└── intake/Run the tool from the client's folder and every certificate names that client's organization. Nothing to switch, no wrong-client mistakes. To be explicit instead, or to run from anywhere, pass the key directly:
keptpdf redact intake/ -o done/ --license clients/acme-legal/keptpdf-license.txtEither way the run prints Licensed to ... (key from ...), and
keptpdf license run in any folder shows which key that folder would use.
Without a key the trial processes your first 100 files. That is a running
total, not a per-run cap: the count lives in ~/.keptpdf/trial.json, is
written only on unlicensed runs, and never leaves the machine. A free trial key
from https://keptpdf.com/cli#trial raises the 100 to 500 files.
split --auto has one more limit on it. Detection is never limited: the trial
reads every page of a file of any length and lists every document it finds,
because whether the boundaries are right is the thing you are evaluating and a
partial read cannot show you that. What it writes is capped at the documents
that fit inside the first 50 pages, whole documents only, so a document
straddling page 50 is listed and not written. --dry-run is never capped: it
writes nothing, so there is nothing to limit, and it prints the full answer on
a file of any size. Everything else the trial does is uncapped.
Trial output carries one small footer line naming it as trial output, so it is fine for judging the redaction and not fit to file or produce. A key removes it.
The footer is stamped into the page as an image, not as a text object, so there is nothing to select, search for or delete, and the audit certificate still reads 0 extractable characters with nothing excluded from the count.
Updates
keptpdf update (or "Check for updates" in the menu) asks keptpdf.com for the
newest version number, prints what changed in it, and then
installs it for you by running npm install -g keptpdf as a visible step. The
menu asks before it installs; the command, which you typed to update, just
does it. keptpdf update --check stops after the report and installs nothing,
which is what scripts and locked-down machines want.
That version lookup is the only network request this tool makes on its own: it
happens only when you run the command. It goes to keptpdf.com, which counts
the check and passes along npm's answer, and it sends only the version you have
and your operating system name: nothing about you, this machine, or any
document. If keptpdf.com cannot be reached, it asks npm's public registry
directly instead. (Installing the
update is npm downloading a package, as it would if you typed the install
yourself.) The answer is cached in ~/.keptpdf, so later runs can show a
one-line "Update available" notice under the banner without touching the
network again.
Prefer it automatic? Set KEPTPDF_UPDATE_CHECK=1 and the CLI refreshes that
cache in the background at most once a day. Nothing is ever checked, sent, or
suggested without one of those two opt-ins, so an air-gapped box behaves
exactly as before.
Exit codes
0 done, 1 at least one file failed, 2 usage error, 3 license problem.
Questions: [email protected] or https://keptpdf.com
