before-you-send 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. before_you_send-0.2.0/.github/workflows/ci.yml +28 -0
  2. before_you_send-0.2.0/.github/workflows/release.yml +74 -0
  3. before_you_send-0.2.0/.gitignore +15 -0
  4. before_you_send-0.2.0/LICENSE +21 -0
  5. before_you_send-0.2.0/PKG-INFO +382 -0
  6. before_you_send-0.2.0/README.md +338 -0
  7. before_you_send-0.2.0/before_you_send/__init__.py +22 -0
  8. before_you_send-0.2.0/before_you_send/annotations.py +60 -0
  9. before_you_send-0.2.0/before_you_send/checks/__init__.py +60 -0
  10. before_you_send-0.2.0/before_you_send/checks/history.py +157 -0
  11. before_you_send-0.2.0/before_you_send/checks/metadata.py +237 -0
  12. before_you_send-0.2.0/before_you_send/checks/payload.py +218 -0
  13. before_you_send-0.2.0/before_you_send/checks/protection.py +106 -0
  14. before_you_send-0.2.0/before_you_send/checks/visibility.py +463 -0
  15. before_you_send-0.2.0/before_you_send/cli.py +108 -0
  16. before_you_send-0.2.0/before_you_send/composite.py +165 -0
  17. before_you_send-0.2.0/before_you_send/content.py +983 -0
  18. before_you_send-0.2.0/before_you_send/document.py +129 -0
  19. before_you_send-0.2.0/before_you_send/findings.py +237 -0
  20. before_you_send-0.2.0/before_you_send/fontmetrics.py +257 -0
  21. before_you_send-0.2.0/before_you_send/report.py +191 -0
  22. before_you_send-0.2.0/before_you_send/run.py +112 -0
  23. before_you_send-0.2.0/examples/make_examples.py +96 -0
  24. before_you_send-0.2.0/pyproject.toml +50 -0
  25. before_you_send-0.2.0/run4.log +1 -0
  26. before_you_send-0.2.0/tests/conftest.py +356 -0
  27. before_you_send-0.2.0/tests/pdfbuild.py +339 -0
  28. before_you_send-0.2.0/tests/test_checks.py +164 -0
  29. before_you_send-0.2.0/tests/test_cli.py +145 -0
  30. before_you_send-0.2.0/tests/test_corpus_defects.py +607 -0
  31. before_you_send-0.2.0/tests/test_regressions.py +413 -0
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ${{ matrix.os }}
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ os: [ubuntu-latest, macos-latest, windows-latest]
19
+ python-version: ["3.9", "3.11", "3.13"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ - run: python -m pip install --upgrade pip
26
+ - run: pip install -e ".[dev]"
27
+ - run: ruff check .
28
+ - run: pytest -q
@@ -0,0 +1,74 @@
1
+ # Publishes a tagged version to PyPI.
2
+ #
3
+ # There is no password and no API token anywhere in this repository, on anyone's
4
+ # machine, or in GitHub's secrets. Publishing uses PyPI's trusted publishing: PyPI
5
+ # is told once that this specific workflow, in this specific repository, may publish
6
+ # this specific project, and GitHub proves the job's identity at run time. Nothing
7
+ # is stored, so nothing can leak, and a stolen token cannot exist because there is
8
+ # no token.
9
+ #
10
+ # It runs only on a version tag, and only after the full test suite passes on the
11
+ # exact commit being published. A release that cannot pass its own tests does not
12
+ # reach anybody.
13
+
14
+ name: Release
15
+
16
+ on:
17
+ push:
18
+ tags: ["v*"]
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ test:
25
+ name: Test before publishing anything
26
+ runs-on: ${{ matrix.os }}
27
+ strategy:
28
+ fail-fast: true
29
+ matrix:
30
+ os: [ubuntu-latest, macos-latest, windows-latest]
31
+ python-version: ["3.9", "3.13"]
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+ - uses: actions/setup-python@v5
35
+ with:
36
+ python-version: ${{ matrix.python-version }}
37
+ - run: python -m pip install --upgrade pip
38
+ - run: pip install -e ".[dev]"
39
+ - run: ruff check .
40
+ - run: pytest -q
41
+
42
+ build:
43
+ name: Build the distribution
44
+ needs: test
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ - uses: actions/setup-python@v5
49
+ with:
50
+ python-version: "3.13"
51
+ - run: python -m pip install --upgrade pip build
52
+ - run: python -m build
53
+ - name: Check the built metadata renders
54
+ run: |
55
+ pip install twine
56
+ twine check dist/*
57
+ - uses: actions/upload-artifact@v4
58
+ with:
59
+ name: distribution
60
+ path: dist/
61
+
62
+ publish:
63
+ name: Publish to PyPI
64
+ needs: build
65
+ runs-on: ubuntu-latest
66
+ environment: pypi
67
+ permissions:
68
+ id-token: write # the only permission trusted publishing needs
69
+ steps:
70
+ - uses: actions/download-artifact@v4
71
+ with:
72
+ name: distribution
73
+ path: dist/
74
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
12
+ .DS_Store
13
+ # Example documents are generated by examples/make_examples.py and never
14
+ # committed, so this repository carries no PDF of any kind.
15
+ *.pdf
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Waiga Arya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,382 @@
1
+ Metadata-Version: 2.5
2
+ Name: before-you-send
3
+ Version: 0.2.0
4
+ Summary: Reads a PDF and reports what is still inside it that you may not mean to send. Runs entirely on your machine.
5
+ Project-URL: Homepage, https://github.com/Waiga/before-you-send
6
+ Project-URL: Issues, https://github.com/Waiga/before-you-send/issues
7
+ Author: Waiga Arya
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Waiga Arya
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: leak,metadata,pdf,privacy,pypdf,redaction,review
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Environment :: Console
33
+ Classifier: Intended Audience :: End Users/Desktop
34
+ Classifier: Intended Audience :: Legal Industry
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Topic :: Utilities
38
+ Requires-Python: >=3.9
39
+ Requires-Dist: pypdf>=5.1
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=7; extra == 'dev'
42
+ Requires-Dist: ruff>=0.5; extra == 'dev'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # Before You Send
46
+
47
+ Reads a PDF and tells you what is still inside it that you may not mean to send.
48
+
49
+ ```
50
+ $ before-you-send letter.pdf
51
+
52
+ Before You Send — letter.pdf
53
+ ========================================================================
54
+ Read 1 page(s). 10 finding(s): 5 high, 4 medium, 1 low.
55
+ 1 place(s) could not be seen into.
56
+
57
+ HIGH
58
+ ------------------------------------------------------------------------
59
+ HIGH page 1 (72, 657)-(257, 669) [covered_text]
60
+ 34 characters of text have an opaque shape painted over them,
61
+ covering 100% of the run.
62
+
63
+ HIGH document file structure [earlier_versions_retained]
64
+ The file contains 1 earlier version(s) of itself, kept in full
65
+ alongside the current one.
66
+
67
+ HIGH document attachments [embedded_files]
68
+ 1 whole file(s) are attached inside this document.
69
+
70
+ HIGH page 1 (72, 627)-(277, 639) [invisible_text]
71
+ 40 characters are set to render mode 3, which draws nothing on
72
+ the page.
73
+
74
+ COULD NOT SEE
75
+ ------------------------------------------------------------------------
76
+ - page 1 (70, 554)-(290, 570)
77
+ an image was painted over 1 run(s) of text. Whether the image
78
+ hides that text, or is simply drawn across it, cannot be decided
79
+ without looking at the picture, which this tool does not do.
80
+ ```
81
+
82
+ A black box drawn over a name does not remove the name. The characters are still
83
+ in the file, and anyone can select them, copy them, or pull them out in one
84
+ command. The same is true of a page you deleted and saved, a comment you thought
85
+ nobody would open, and the spreadsheet somebody attached to the document six
86
+ versions ago.
87
+
88
+ None of this is exotic. It is the ordinary result of treating a PDF as a picture
89
+ of a document when it is actually a container.
90
+
91
+ This tool does not tell you a file is safe to send. It tells you what it found,
92
+ where it found it, and — separately, and always — where it could not see.
93
+
94
+ One fact is reported once. Something painted in the same place on every page is a
95
+ header, a footer or a watermark, and printing it once per page buries the finding on
96
+ page 137 that actually matters. Every run also states how many pages are mostly
97
+ picture, findings or none, because a document flattened into images comes back with
98
+ nothing found and is not empty.
99
+
100
+ ## Install
101
+
102
+ Python 3.9 or newer. The only dependency is `pypdf`.
103
+
104
+ ```bash
105
+ pip install before-you-send
106
+ ```
107
+
108
+ Or from source:
109
+
110
+ ```bash
111
+ git clone https://github.com/Waiga/before-you-send
112
+ cd before-you-send
113
+ pip install -e .
114
+ ```
115
+
116
+ ## Use
117
+
118
+ ```bash
119
+ before-you-send letter.pdf # where things are, not what they say
120
+ before-you-send letter.pdf --verbose # add why each finding matters
121
+ before-you-send letter.pdf --show-content # include what was actually found
122
+ before-you-send letter.pdf --format json # for scripts
123
+ ```
124
+
125
+ Exit codes, for a pipeline: `0` nothing at or above the threshold, `1` something
126
+ found, `2` the file could not be read. The threshold is `--fail-on high|medium|low|never`
127
+ and defaults to `medium`.
128
+
129
+ Try it on the examples, which the repository generates rather than stores:
130
+
131
+ ```bash
132
+ python examples/make_examples.py
133
+ before-you-send examples/leaky-letter.pdf --verbose
134
+ before-you-send examples/careful-letter.pdf
135
+ ```
136
+
137
+ ## What it checks
138
+
139
+ | Check | Level | What it means |
140
+ |---|---|---|
141
+ | `covered_text` | high | Something opaque was painted over text *after* the text. The text was never removed. |
142
+ | `invisible_text` | high | Text set to a render mode that draws nothing. Extracts normally. |
143
+ | `text_matching_background` | high | Text the same colour as the page or the shape behind it. |
144
+ | `unapplied_redaction_marks` | high | Passages marked for redaction where the redaction was never applied. |
145
+ | `embedded_files` | high | Whole files carried inside the document. |
146
+ | `active_content` | high | Scripts, launch actions, or automatic form submissions. |
147
+ | `form_field_values` | high / medium | Form fields still holding what somebody typed. High when the field is hidden. |
148
+ | `earlier_versions_retained` | high / medium / low | Previous versions of the file kept inside it. Low when a signature explains it. |
149
+ | `text_clipped_away` | high | Text excluded by a clipping path, so none of it is drawn. |
150
+ | `text_too_small_to_read` | high | Text scaled to effectively zero size. |
151
+ | `text_outside_page` | medium | Text parked entirely outside the visible page. |
152
+ | `hidden_layers` | medium | Layers switched off. The content is still there. |
153
+ | `annotation_authors` | medium | Comments and markup, and the names attached to them. |
154
+ | `document_author` | medium | A named author in the document properties. |
155
+ | `build_path_in_metadata` | medium | A filesystem path left in the properties, naming a user or a client folder. |
156
+ | `xmp_metadata` | medium / low | A second author record, which editing tools often forget to update. |
157
+ | `descriptive_metadata` | low | Title, subject or keywords — frequently the original filename. |
158
+ | `encryption_without_a_password` | low | Restrictions the file asks for but cannot enforce. |
159
+ | `scanned_text_layer` | low | Invisible text under a page-sized image: the searchable layer of a scan, reported so you know it extracts. |
160
+
161
+ ## What it does not check
162
+
163
+ Stated in the output of every run, not just here.
164
+
165
+ **Anything inside a picture.** Images are not examined. Text in a screenshot, a
166
+ scanned page, or a chart saved as an image is invisible to this tool. A document
167
+ flattened into pictures will look empty here and will not be.
168
+
169
+ **Whether what it found is actually a secret.** It reports that something is
170
+ present and not visible. Whether that matters is a judgement about the content,
171
+ and the tool does not read for meaning.
172
+
173
+ **Whether the visible text should be visible.** Content plainly on the page is
174
+ never a finding, however confidential. This looks only for what a sender does not
175
+ know is there.
176
+
177
+ **Runs of one or two characters.** Too short to carry a name, a number of
178
+ consequence, or a word, and on real documents almost always a plot marker, a table
179
+ rule or a mathematical glyph. Measured over 450 published PDFs they were 57% of
180
+ every covered-text finding and 100% of every "too small to read" one, and all of
181
+ them were wrong. The count of runs passed over is printed in every report, because a
182
+ threshold nobody is told about is just an undocumented bug.
183
+
184
+ **What text says, when a font gives no way to know.** A composite font addresses
185
+ glyphs by number. Where it carries no map from those numbers to characters, runs in
186
+ it are located and measured exactly and their text is not guessed at. Composite fonts
187
+ using an encoding other than Identity keep having their widths estimated, for the
188
+ same reason: a width read against the wrong glyph is worse than an admitted estimate.
189
+
190
+ When an image is painted over text, the tool says so as a **blind spot** — a
191
+ located place it can prove something is drawn at and cannot see under. The same
192
+ goes for a shape whose colour the file names indirectly, through a pattern or a
193
+ spot colour, where whether it conceals anything cannot be decided from the
194
+ drawing instructions at all. Blind spots are printed separately from findings and
195
+ are never counted as findings, because a report that says "no problems" about a
196
+ page it could not read is worse than no report.
197
+
198
+ ## How it tells a redaction from a design choice
199
+
200
+ This is the one thing worth explaining, because it is where a tool like this
201
+ usually becomes useless.
202
+
203
+ A black box over black text, and white heading text on a black bar, are the same
204
+ overlap. Geometry cannot separate them. What separates them is the order the two
205
+ things were painted, which the file records:
206
+
207
+ ```
208
+ text, then box -> the box was put there to hide the text reported
209
+ box, then text -> the box is a background the text sits on not reported
210
+ ```
211
+
212
+ Order alone is not enough, because a shape is not painted everywhere its path
213
+ reaches. Three things bound it, and all three had to be modelled before this was
214
+ usable on real documents:
215
+
216
+ - **a clipping path**, which trims everything drawn after it. Without it, every
217
+ chart from matplotlib or a browser's print-to-PDF reports its own caption as a
218
+ covered secret.
219
+ - **a form's bounding box**, a hard limit on what that form draws. Without it, a
220
+ small logo stamp whose artwork is larger than its box appears to cover the page.
221
+ - **blending and soft masks**, which let what is underneath show through. A
222
+ flattened highlighter mark is an opaque yellow rectangle drawn over text, and
223
+ reading only its alpha value reports every highlight in a document.
224
+
225
+ The order is a fact from the file. The verdict is not purely a fact: it is gated
226
+ by a coverage threshold, an opacity threshold, a colour tolerance, and — for any
227
+ font that does not declare its character widths, which includes Helvetica and
228
+ Times — an estimate of how wide a line of text really is. Where that estimate is
229
+ load-bearing the report says "about", and it is listed under what was not checked.
230
+
231
+ The test suite holds every innocent twin as a matched pair against the case it
232
+ resembles: an outlined box that covers nothing, a see-through highlight, a
233
+ multiply-blended highlighter, a panel clipping the end of a line, a clipped
234
+ chart, a bounded logo stamp, a spot-colour brand bar, a white caption on a
235
+ photograph, a scanned page's searchable text layer, text bleeding off an edge,
236
+ and a signature that explains an extra revision. Each must stay silent, and a run
237
+ that loses one of them fails.
238
+
239
+ ## Against real documents
240
+
241
+ The suite passes, and that was never the question. A tool like this can be green on
242
+ every test it wrote for itself and still be useless on the first real file it meets,
243
+ so it was pointed at 931 published PDFs it had nothing to do with: the US Federal
244
+ Register, arXiv, gov.uk, the World Health Organization, US court filings, and
245
+ scanned FOIA releases from the FBI's reading room. Six producers, which matters more
246
+ than six sources — a Word document, a LaTeX paper and an InDesign report fail in
247
+ different ways.
248
+
249
+ It found thirteen classes of defect. The first pass produced **4,280 findings across
250
+ 450 documents, 3,126 of them HIGH**, and almost none of them worth reading.
251
+
252
+ | | before | after |
253
+ |---|---|---|
254
+ | findings, same 450 documents | 4,280 | 1,703 |
255
+ | of which HIGH | 3,126 | 954 |
256
+ | median per document | 6 | 3 |
257
+ | worst document | 604 | 177 |
258
+
259
+ Across the full 931, the median document now reports 3 findings and the 90th
260
+ percentile reports 4. Nothing crashed, timed out, or came back unreadable.
261
+
262
+ Four are worth naming, because none of them could have been found any other way:
263
+
264
+ **The check most likely to hide a real leak never ran.** `earlier_versions_retained`
265
+ began by asking the parsed trailer for `/Prev`. A parser only surfaces that key for a
266
+ classic cross-reference table, and every modern PDF — Word, Acrobat, InDesign,
267
+ Chrome, every linearized government file — uses a cross-reference stream instead, so
268
+ the check returned on its first line and never reached the byte walk written for
269
+ exactly this question. Measured over the 887 documents collected at that point, it
270
+ was silent on **256 of them**. Most of those are
271
+ linearization, which it knows how to excuse; 34 are retained earlier versions with no
272
+ benign explanation, and 5 are serious. Every fixture in the suite used a classic xref
273
+ table, so no test could have seen it.
274
+
275
+ **A border was being read as a block.** One 175-page government table produced
276
+ **8,638** covered-text findings, 68% of every such finding in the corpus. Rendered,
277
+ the page is an ordinary Word table: white cells, black gridlines, entirely readable.
278
+ A word processor draws a cell edge as an outer outline and an inner one in a single
279
+ path; measured as one rectangle, a hollow frame becomes a solid block of ink over
280
+ everything inside it. That document now reports 4 findings, all true.
281
+
282
+ **Composite fonts were being guessed at.** A Type0 font addresses glyphs by number,
283
+ two bytes at a time, and keeps its widths on a descendant font. Read as though the
284
+ bytes were characters, a run measures about twice as wide as it is — and that width
285
+ is the denominator of the coverage fraction that decides whether a passage was
286
+ redacted. Twice too wide halves the coverage, drops it under the threshold, and the
287
+ finding never appears. On the Word and InDesign slice, documents relying on estimated
288
+ widths fell from 78% to 47%.
289
+
290
+ **One fact was being reported once per page.** The Federal Register prints a
291
+ typesetter's control line and an operator's account name in white in the margin of
292
+ every page. Both are real, and one of them names a person. Reported per page they
293
+ came to 62 HIGH findings on a 31-page notice and 470 on the longest document in the
294
+ corpus, which is the same as reporting nothing: a genuine single-page leak could not
295
+ have been found in that. It now reports 3, and the two HIGH ones are true.
296
+
297
+ ### What that does and does not establish
298
+
299
+ It establishes that the tool survives real-world PDFs, and it measures how often it
300
+ cries wolf. Every number above is a false-positive number.
301
+
302
+ It is much weaker evidence about the failure that actually hurts somebody, which is
303
+ the one where a document *is* leaking and the report says nothing. Ordinary published
304
+ documents are overwhelmingly documents where nobody tried to hide anything, so they
305
+ exercise that path barely at all.
306
+
307
+ So the corpus was also run through an independent check for it: every page extracted
308
+ with `pdftotext` and separately rendered and read with OCR, on the theory that text
309
+ which extracts but is not on the rendered page is text somebody cannot see. Across
310
+ 450 documents that turned up two candidates, and both were OCR failing on dense
311
+ numeric tables rather than the tool missing anything. That is real evidence and it is
312
+ not proof. It is one independent check, on a population where concealment is rare.
313
+
314
+ Concretely: this has not been validated against a corpus of documents where people
315
+ actually attempted redaction and got it wrong. If you have one, that is the most
316
+ useful thing you could point this at.
317
+
318
+ ## Privacy
319
+
320
+ The file is read on your machine and nothing is sent anywhere. There is no
321
+ account, no API key, and no network call in the tool at all.
322
+
323
+ The report withholds what it found by default. You get `page 1 (72, 657)-(258, 669)`
324
+ and a character count, not the account number underneath. That is deliberate: the
325
+ report of a document you are worried about is itself the thing most likely to be
326
+ pasted into a chat window. Use `--show-content` when you actually want to see it.
327
+
328
+ Control characters in anything recovered from the document are stripped before
329
+ printing, so a hostile file cannot use its own title to repaint your terminal.
330
+
331
+ The report header echoes the path you gave it, which may itself name a client or
332
+ a matter. Worth knowing before pasting one.
333
+
334
+ It never writes to the file it is reading, and it has no repair mode. A tool that
335
+ silently strips something you needed is a data-loss tool wearing a safety label.
336
+
337
+ ## How this compares
338
+
339
+ The detection here is not new. Several of these problems have been known for as
340
+ long as the format has existed, and there is good software for parts of it.
341
+
342
+ For **metadata specifically**, [ExifTool](https://exiftool.org/) reads and writes
343
+ it comprehensively, and [mat2](https://0xacab.org/jvoisin/mat2) removes it across
344
+ many formats. Both are mature, free, and better at that one job than this is.
345
+
346
+ For **covered and invisible text**, the tools that exist are mostly web services
347
+ you upload the document to — which is the wrong shape for a file you are worried
348
+ about — or paid consistency-checking add-ins sold to firms rather than people.
349
+ [pdfalyzer](https://pypi.org/project/pdfalyzer/) is free and local but aimed at
350
+ malware forensics, and it is GPL-licensed.
351
+
352
+ What this puts together in one place: local only, permissively licensed, one
353
+ dependency, content withheld by default, an exit code for CI, and a report that
354
+ separates what it found from what it could not see.
355
+
356
+ If a check here is wrong, or a document is misreported, that is the most useful
357
+ issue you can open.
358
+
359
+ ## Development
360
+
361
+ ```bash
362
+ pip install -e ".[dev]"
363
+ pytest -q
364
+ ruff check .
365
+ ```
366
+
367
+ Every test fixture is built from literal bytes in `tests/pdfbuild.py`. No PDF is
368
+ committed to this repository, and none of the examples came from a real document.
369
+
370
+ `tests/test_regressions.py` holds one test per defect found by deliberately
371
+ attacking the tool after the first suite was already passing. Most of those
372
+ defects were false positives on entirely ordinary documents, which is the failure
373
+ worth guarding hardest against.
374
+
375
+ `tests/test_corpus_defects.py` holds one test per defect found afterwards, by
376
+ running the finished tool over 931 real published PDFs it had never seen. Every one
377
+ of them names the document shape that produced it, and every one was checked to fail
378
+ without its fix — a test that passes either way is not a test.
379
+
380
+ ## Licence
381
+
382
+ MIT.