PDF yang sudah disensor bisa gagal dengan lebih banyak cara daripada sekadar "ada yang menggambar kotak hitam". Kotaknya bisa jadi bagian dari halaman atau berupa anotasi di atasnya, tanda redaksi bisa ditambahkan tapi tidak pernah diterapkan, redaksi bisa diterapkan lalu disimpan secara incremental sehingga halaman lamanya masih ada di dalam file, dan nama penulis bisa tetap tersimpan di metadata sejak awal. Di layar, semuanya terlihat sama persis.

Untuk melihat mana yang benar-benar membocorkan data, saya membuat tujuh versi dari formulir satu halaman yang sama, lalu memeriksa masing-masing dengan tiga text extractor (PyMuPDF, pypdf, dan pdfminer.six), scan terhadap setiap stream di dalam file, dan metadatanya. Salah satu dari ketujuh file itu adalah hasil simpan PDF Editor di aplikasi desktop osFoundry setelah Find text and redact, Apply, dan Sanitize. Skripnya ada di bagian akhir; semuanya dijalankan dengan Python 3.12, PyMuPDF 2.1, pypdf 6.21.4, dan pdfminer.six 20260107 di Apple M1.

Formulir uji

Formulirnya adalah formulir otorisasi direct deposit yang dibuat dengan PyMuPDF (data contoh: nama Jordan Ellis, nomor rekening 4471 2093 5568, telepon (503) 555-0142). Nomor rekening muncul dua kali, nama juga dua kali, dan field author di document info diisi dengan nama tersebut. Teksnya memakai font Times bawaan, jadi setiap baris di content stream hanyalah TJ biasa dengan byte yang ditulis sebagai string hex:

BT 1 0 0 1 236 326 Tm /tiro 12.5 Tf [<3434373120323039332035353638>]TJ ET 

34 34 37 31 20 32 30 39 33 ... adalah 4471 2093 ... dalam ASCII. Ingat ini untuk bagian berikutnya.

Kotak yang digambar tidak mengubah teks sama sekali

Versi "tersensor" yang dibuat kebanyakan orang adalah menggambar persegi panjang terisi di atas kedua nomor rekening. Di content stream, itu berarti dua objek baru yang ditambahkan setelah semua isi lainnya:

q 232 322 89.25 17 re h 0 0 0 RG 0 0 0 rg B Q 

TJ di atas tidak tersentuh. Persegi panjangnya digambar belakangan, jadi glyph tertutup di layar maupun saat dicetak, tapi setiap extractor membaca operator teks, bukan piksel.

grep bukan cara mengetes

Cara cek yang paling gampang adalah mencari nomornya di dalam file:

grep -c "4471 2093 5568" form.pdf # 0, for all seven files, including the original 

Hasilnya kosong bahkan untuk file asli yang belum disensor, karena dua alasan. Content stream dikompresi dengan Flate, jadi byte di disk bukan teks. Dan setelah didekompresi pun, nomornya disimpan sebagai string hex <3434373120...>, bukan sebagai 4471 2093 5568. Scan stream harus men-decompress setiap stream dan mencari kedua bentuk penulisan itu, dan itulah yang dilakukan checker di bawah. Untuk font CID (sebagian besar PDF berbahasa Jepang, Tionghoa, dan Korea) cara ini pun gagal, karena string-nya berisi ID glyph yang baru bisa dipetakan kembali ke karakter lewat tabel ToUnicode milik font. Text extractor sungguhan adalah satu-satunya pengecekan yang berlaku di semua kasus; scan stream adalah lapis kedua untuk sisa-sisa data yang tidak terbaca oleh extractor.

Tujuh versi, tiga extractor

Setiap versi diperiksa untuk nomor rekening. Kolom "stream" menghitung stream di bagian mana pun dari byte file, baik yang aktif maupun yang yatim (orphaned), yang setelah di-decompress masih memuat nomor tersebut:

versi PyMuPDF pypdf pdfminer.six stream author di metadata
asli ketemu ketemu ketemu 2 Jordan Ellis
kotak digambar ke halaman ketemu ketemu ketemu 2 Jordan Ellis
kotak sebagai anotasi square ketemu ketemu ketemu 2 Jordan Ellis
tanda redaksi ditambahkan, tidak diterapkan ketemu ketemu ketemu 2 Jordan Ellis
PyMuPDF apply_redactions, full save – – – 0 Jordan Ellis
PyMuPDF apply_redactions, saveIncr() – – – 2 Jordan Ellis
osFoundry PDF Editor, Apply + Sanitize + Save – – – 0 (kosong)

Bagi pembaca PDF mana pun, empat versi pertama adalah file yang sama. Ketiga extractor memberi hasil yang sama di setiap baris, jadi untuk pertanyaan ini tidak penting extractor mana yang dipakai, asalkan memakai salah satunya.

Baris keempat adalah jebakan bagi orang yang sebenarnya sudah tahu soal tool redaksi. Redaksi PDF terdiri dari dua langkah: anotasi Redact yang menandai area, dan langkah apply yang menghapus apa pun di bawahnya. File yang disimpan di antara kedua langkah itu menampilkan garis merah (atau kotak hitam, tergantung viewer-nya) dan masih berisi semuanya.

Jebakan incremental save

Baris keenam lolos dari ketiga extractor tapi tetap bocor. saveIncr(), sama seperti tombol Save biasa di banyak aplikasi PDF desktop, tidak menulis ulang file. Ia menambahkan objek yang berubah dan tabel cross-reference baru setelah %%EOF yang lama, dan pembaca PDF mengikuti tabel yang paling baru. Content stream lama masih ada, hanya saja sudah tidak direferensikan.

Untuk mengambilnya kembali cukup satu baris, karena revisi pertama file berakhir di %%EOF yang pertama:

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 

File incremental-nya berukuran 11,533 byte; 7,163 byte pertamanya adalah formulir asli, utuh. Menghitung jumlah penanda %%EOF adalah pengecekan yang murah: file asli dan hasil full save punya satu, hasil incremental save punya dua.

Apa yang ditulis oleh penghapusan yang benar

File yang disimpan oleh osFoundry PDF Editor tidak punya nomor rekening di stream mana pun, dan teks yang tersisa tidak bergeser. Ini baris yang sama seperti sebelumnya, setelah Apply:

q BT 1 0 0 1 236 326 Tm /tiro 12.5 Tf [-6500] TJ ET Q 

String-nya hilang dan yang tersisa di array TJ hanya sebuah angka. Di dalam array TJ, angka adalah penyesuaian horizontal dalam satuan per seribu ukuran font yang dikurangkan dari posisi, jadi -6500 menggeser pena sejauh 6.5 × 12.5 = 81.25 point ke kanan tanpa menggambar apa pun. Itu persis lebar 4471 2093 5568 dalam Times-Roman:

teks yang dihapus ukuran font lebar dalam Times-Roman nilai di 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 (tanda tangan) 15 71.25 pt -4750

Karena advance-nya dipertahankan, apa pun yang ada setelah teks yang dihapus di baris yang sama tetap berada di tempatnya. Dicek kata per kata, ke-63 kata yang tersisa ada di posisi yang sama seperti di file asli (0 bergeser), dan jumlah kata di halaman turun dari 75 menjadi 63. Kotak hitamnya adalah stream terpisah di bagian akhir, berupa persegi panjang terisi biasa (236 323.5 81.25 12.5 re f) yang ukurannya disesuaikan dengan teks yang dihapus, tanpa apa pun di bawahnya.

Formulir di PDF Editor setelah Apply, dengan kotak hitam di tempat kelima tanda

Metadata adalah langkah tersendiri

Full save dari PyMuPDF menghapus teksnya tapi membiarkan author = Jordan Ellis di document info. Redaksi bekerja pada konten halaman, dan dictionary info bukan konten halaman. Untuk mengosongkannya cukup dua panggilan:

doc.set_metadata({}) doc.del_xml_metadata() doc.save(out, garbage=4, deflate=True) 

Pada formulir ini, hasilnya semua field info kosong dan tidak ada Jordan di mana pun dalam byte file. Langkah Sanitize document di editor melakukan hal yang sama, ditambah menghapus lampiran, komentar, field formulir, layer tersembunyi, bookmark, dan script, dan file yang disimpannya tidak punya metadata sama sekali.

Checker yang bisa disimpan

Semua yang dibahas di atas, dalam satu fungsi yang mengembalikan daftar masalah dan keluar dengan exit code bukan nol kalau ada masalah:

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" 

Pada file incremental, script ini mencetak author, 2 revisions: earlier versions are still in the file, dan lima stream yang masih memuat data rahasia. Pada file dengan kotak yang digambar, ia mencetak 9 temuan dari extractor, author, dan lima stream. Pada file dari osFoundry PDF Editor, ia mencetak clean.

Ada beberapa batasan yang perlu disebutkan. Script ini hanya menemukan string yang Anda berikan, jadi nilai yang ditulis dengan format lain (4471-2093-5568) perlu dimasukkan sendiri. Ia membaca teks, jadi data rahasia yang menjadi bagian dari gambar perlu di-OCR dulu. Dan scan stream tidak menangkap teks berfont CID, meskipun extractor tetap menemukannya.

PDF Editor yang menghasilkan file bersih tadi berjalan secara lokal di dalam aplikasi desktop, sesuai dengan desain osFoundry yang privat dan local-first, jadi formulirnya tidak perlu keluar dari komputer untuk disensor. Aplikasinya gratis dan bisa diunduh dari halaman download osFoundry.