Ein geschwärztes PDF kann auf mehr Arten scheitern als „jemand hat einen schwarzen Kasten gezeichnet“. Der Kasten kann Teil der Seite sein oder eine Annotation darüber, Schwärzungsmarkierungen können gesetzt und nie angewendet werden, die Schwärzung kann angewendet und danach inkrementell gespeichert werden, sodass die alte Seite noch in der Datei steckt, und der Name des Autors kann die ganze Zeit in den Metadaten stehen. Auf dem Bildschirm sehen all diese Fälle gleich aus.
Um herauszufinden, welche davon tatsächlich Daten preisgeben, habe ich sieben Versionen desselben einseitigen Formulars erzeugt und jede mit drei Text-Extraktoren (PyMuPDF, pypdf und pdfminer.six), einem Scan aller Streams in der Datei und den Metadaten geprüft. Eine der sieben ist die Datei, die der PDF Editor in der Desktop-App von osFoundry nach Find text and redact, Apply und Sanitize gespeichert hat. Die Skripte stehen am Ende; alles lief mit Python 3.12, PyMuPDF 2.1, pypdf 6.21.4 und pdfminer.six 20260107 auf einem Apple M1.
Das Testformular
Das Formular ist eine mit PyMuPDF erzeugte Einwilligung zur Direktüberweisung (Direct Deposit) (Beispieldaten: Name Jordan Ellis, Kontonummer 4471 2093 5568, Telefon (503) 555-0142). Die Kontonummer kommt zweimal vor, der Name ebenfalls zweimal, und das Autorfeld in den Dokumentinformationen enthält den Namen. Der Text nutzt die eingebaute Schrift Times, deshalb ist jede Zeile im Content Stream ein einfaches TJ, dessen Bytes als Hex-String geschrieben sind:
BT
1 0 0 1 236 326 Tm
/tiro 12.5 Tf [<3434373120323039332035353638>]TJ
ET
34 34 37 31 20 32 30 39 33 ... ist 4471 2093 ... in ASCII. Das ist für den nächsten Abschnitt wichtig.
Ein gezeichneter Kasten ändert nichts am Text
Die „geschwärzte“ Version, die die meisten erstellen, zeichnet gefüllte Rechtecke über die beiden Kontonummern. Im Content Stream sind das zwei neue Objekte, die hinter allem anderen angehängt werden:
q
232 322 89.25 17 re
h
0 0 0 RG 0 0 0 rg B
Q
Das TJ von oben bleibt unverändert. Das Rechteck wird später gemalt, verdeckt also die Glyphen auf dem Bildschirm und im Druck, aber jeder Extraktor liest die Textoperatoren, nicht die Pixel.
grep ist kein Test
Die naheliegende Prüfung ist, die Datei nach der Nummer zu durchsuchen:
grep -c "4471 2093 5568" form.pdf # 0, for all seven files, including the original
Selbst beim ungeschwärzten Original kommt nichts zurück, und das hat zwei Gründe. Die Content Streams sind mit Flate komprimiert, die Bytes auf der Platte sind also kein Text. Und auch nach dem Dekomprimieren steht die Nummer als Hex-String <3434373120...> da, nicht als 4471 2093 5568. Ein Stream-Scan muss jeden Stream entpacken und nach beiden Schreibweisen suchen, und genau das macht der Prüfer weiter unten. Bei CID-Schriften (die meisten japanischen, chinesischen und koreanischen PDFs) reicht nicht einmal das, weil der String Glyph-IDs enthält, die sich nur über die ToUnicode-Tabelle der Schrift auf Zeichen zurückführen lassen. Ein echter Text-Extraktor ist die einzige Prüfung, die überall funktioniert; der Stream-Scan ist eine zweite Linie für Reste, die Extraktoren nicht lesen.
Sieben Versionen, drei Extraktoren
Jede Version, geprüft auf die Kontonummer. „Streams“ zählt die Streams irgendwo in den Bytes der Datei, aktiv oder verwaist, die sie nach dem Entpacken noch enthalten:
| Version | PyMuPDF | pypdf | pdfminer.six | Streams | Autor in den Metadaten |
|---|---|---|---|---|---|
| Original | gefunden | gefunden | gefunden | 2 | Jordan Ellis |
| Kasten in die Seite gezeichnet | gefunden | gefunden | gefunden | 2 | Jordan Ellis |
| Kasten als Square-Annotation | gefunden | gefunden | gefunden | 2 | Jordan Ellis |
| Schwärzungsmarkierungen gesetzt, nicht angewendet | gefunden | gefunden | gefunden | 2 | Jordan Ellis |
PyMuPDF apply_redactions, vollständig gespeichert |
– | – | – | 0 | Jordan Ellis |
PyMuPDF apply_redactions, saveIncr()
|
– | – | – | 2 | Jordan Ellis |
| osFoundry PDF Editor, Apply + Sanitize + Save | – | – | – | 0 | (leer) |
Für jedes Leseprogramm sind die ersten vier dieselbe Datei. Die drei Extraktoren waren sich in jeder Zeile einig, für diese Frage ist es also egal, welchen man nimmt, solange man überhaupt einen nimmt.
An der vierten Zeile scheitern gerade Leute, die Schwärzungswerkzeuge kennen. Schwärzen in PDF besteht aus zwei Schritten: einer Redact-Annotation, die einen Bereich markiert, und einem Anwenden-Schritt, der entfernt, was darunter liegt. Eine Datei, die zwischen diesen beiden Schritten gespeichert wird, zeigt rote Umrisse (oder schwarze Kästen, je nach Viewer) und enthält trotzdem noch alles.
Die Falle beim inkrementellen Speichern
Die sechste Zeile besteht alle drei Extraktoren und gibt trotzdem Daten preis. saveIncr() schreibt die Datei, genau wie das normale Speichern in vielen Desktop-PDF-Programmen, nicht neu. Es hängt die geänderten Objekte und eine neue Querverweistabelle hinter das alte %%EOF an, und Leseprogramme folgen der neuesten Tabelle. Die alten Content Streams sind noch da, sie werden nur nicht mehr referenziert.
Sie zurückzuholen kostet eine Zeile, denn die erste Revision der Datei endet beim ersten %%EOF:
raw = open("pymupdf-incremental.pdf", "rb").read()
first = raw[: raw.index(b"%%EOF") + 5]
print("4471 2093 5568" in pymupdf.open(stream=first, filetype="pdf")[0].get_text()) # True
Die inkrementelle Datei ist 11.533 Bytes groß; ihre ersten 7.163 Bytes sind das Originalformular, vollständig erhalten. %%EOF-Marker zu zählen ist ein billiger Test: Das Original und die vollständig gespeicherten Dateien haben einen, die inkrementell gespeicherte hat zwei.
Was ein echtes Entfernen stattdessen schreibt
Die vom osFoundry PDF Editor gespeicherte Datei enthält die Kontonummer in keinem Stream, und der übrige Text hat sich nicht verschoben. Das ist dieselbe Zeile wie oben, nach Apply:
q
BT
1 0 0 1 236 326 Tm
/tiro 12.5 Tf
[-6500] TJ
ET
Q
Der String ist weg, im TJ-Array steht nur noch eine Zahl. In einem TJ-Array ist eine Zahl eine horizontale Korrektur in Tausendsteln der Schriftgröße, die von der Position abgezogen wird. -6500 schiebt den Stift also um 6,5 × 12,5 = 81,25 Punkt nach rechts, ohne etwas zu zeichnen. Das ist genau die Breite von 4471 2093 5568 in Times-Roman:
| entfernter Text | Schriftgröße | Breite in Times-Roman | Wert in TJ
|
|---|---|---|---|
| Jordan Ellis | 12,5 | 59,375 pt | -4750 |
| (503) 555-0142 | 12,5 | 78,11 pt | -6249 |
| 4471 2093 5568 | 12,5 | 81,25 pt | -6500 |
| Jordan Ellis (Unterschrift) | 15 | 71,25 pt | -4750 |
Weil der Vorschub erhalten bleibt, bleibt alles, was in derselben Zeile hinter dem entfernten Abschnitt steht, an seinem Platz. Wort für Wort geprüft haben alle 63 verbliebenen Wörter dieselbe Position wie im Original (0 verschoben), und die Seite ging von 75 auf 63 Wörter zurück. Die schwarzen Kästen sind ein eigener Stream am Ende, einfache gefüllte Rechtecke (236 323.5 81.25 12.5 re f) in der Größe des entfernten Texts, unter denen nichts liegt.
Metadaten sind ein eigener Schritt
Das vollständige Speichern mit PyMuPDF hat den Text entfernt und author = Jordan Ellis in den Dokumentinformationen stehen lassen. Schwärzen wirkt auf den Seiteninhalt, und das Info-Dictionary ist kein Seiteninhalt. Es zu leeren sind zwei Aufrufe:
doc.set_metadata({})
doc.del_xml_metadata()
doc.save(out, garbage=4, deflate=True)
Bei diesem Formular bleiben danach alle Info-Felder leer, und in den Bytes der Datei steht nirgends mehr Jordan. Der Schritt Sanitize document im Editor macht dasselbe und entfernt zusätzlich Anhänge, Kommentare, Formularfelder, ausgeblendete Ebenen, Lesezeichen und Skripte; seine gespeicherte Datei hatte überhaupt keine Metadaten.
Ein Prüfer zum Aufheben
Alles oben in einer Funktion, die eine Liste von Problemen zurückgibt und mit einem Exit-Code ungleich null endet, falls es welche gibt:
import re, sys, zlib
import pymupdf
from pypdf import PdfReader
from pdfminer.high_level import extract_text
def problems(path, secrets):
raw = open(path, 'rb').read()
doc = pymupdf.open(path)
flat = lambda t: re.sub(r'\s+', ' ', t.replace('\xa0', ' '))
texts = {
'pymupdf': flat(''.join(p.get_text() for p in doc)),
'pypdf': flat(''.join(p.extract_text() for p in PdfReader(path).pages)),
'pdfminer': flat(extract_text(path)),
}
out = []
for s in secrets:
out += [f'{name}: text contains {s!r}' for name, t in texts.items() if s in t]
for p in doc:
for a in p.annots():
if s in (a.info.get('content') or ''):
out.append(f'annotation on page {p.number + 1} contains {s!r}')
out += [f'metadata {k} = {v!r}' for k, v in doc.metadata.items() if v and s in v]
if raw.count(b'%%EOF') > 1:
out.append(f'{raw.count(b"%%EOF")} revisions: earlier versions are still in the file')
if any(p.first_annot and p.first_annot.type[1] == 'Redact' for p in doc):
out.append('redaction annotations were never applied')
for m in re.finditer(rb'stream\r?\n(.*?)\r?\nendstream', raw, re.S):
try:
body = zlib.decompress(m.group(1))
except zlib.error:
body = m.group(1)
for s in secrets:
if s.encode() in body or s.encode().hex().encode() in body.lower():
out.append(f'stream at byte {m.start()} still holds {s!r}')
return out
if __name__ == '__main__':
found = problems(sys.argv[1], sys.argv[2:])
print('\n'.join(found) or 'clean')
sys.exit(1 if found else 0)
uvx --with pymupdf --with pypdf --with pdfminer.six python assert_redacted.py out.pdf \
"4471 2093 5568" "Jordan Ellis" "(503) 555-0142"
Bei der inkrementellen Datei gibt er den Autor aus, 2 revisions: earlier versions are still in the file und fünf Streams, die noch ein Geheimnis enthalten. Bei der Datei mit gezeichnetem Kasten gibt er 9 Treffer der Extraktoren, den Autor und fünf Streams aus. Bei der Datei aus dem osFoundry PDF Editor gibt er clean aus.
Er hat Grenzen, die man kennen sollte. Er findet nur die Strings, die man ihm übergibt, ein anders geschriebener Wert (4471-2093-5568) braucht also einen eigenen Eintrag. Er liest Text, ein Geheimnis, das Teil eines Bildes ist, braucht also zuerst OCR. Und der Stream-Scan übersieht Text in CID-Schriften, den die Extraktoren aber trotzdem finden.
Der PDF Editor, der die saubere Datei erzeugt hat, läuft lokal in der Desktop-App, passend zum privaten Local-first-Ansatz von osFoundry. Das Formular verlässt zum Schwärzen also nicht den Rechner. Die App gibt es kostenlos auf der Download-Seite von osFoundry.

Top comments (0)