Un PDF censurado puede fallar de más maneras que «alguien dibujó un recuadro negro». El recuadro puede formar parte de la página o ser una anotación encima de ella, las marcas de censura pueden añadirse y no aplicarse nunca, la censura puede aplicarse y luego guardarse de forma incremental, de modo que la página antigua sigue dentro del archivo, y el nombre del autor puede estar todo el tiempo en los metadatos. En pantalla, todos estos casos se ven exactamente igual.
Para ver cuáles filtran datos de verdad, generé siete versiones del mismo formulario de una página y revisé cada una con tres extractores de texto (PyMuPDF, pypdf y pdfminer.six), un escaneo de todos los streams del archivo y los metadatos. Una de las siete es el archivo guardado por el PDF Editor de la app de escritorio de osFoundry después de Find text and redact, Apply y Sanitize. Los scripts están al final; todo se ejecutó con Python 3.12, PyMuPDF 2.1, pypdf 6.21.4 y pdfminer.six 20260107 en un Apple M1.
El formulario de prueba
El formulario es una autorización de depósito directo generada con PyMuPDF (datos de ejemplo: nombre Jordan Ellis, número de cuenta 4471 2093 5568, teléfono (503) 555-0142). El número de cuenta aparece dos veces y el nombre también dos, y el campo de autor de la información del documento contiene el nombre. El texto usa la fuente Times integrada, así que cada línea del content stream es un simple TJ con los bytes escritos como cadena hexadecimal:
BT 1 0 0 1 236 326 Tm /tiro 12.5 Tf [<3434373120323039332035353638>]TJ ET 34 34 37 31 20 32 30 39 33 ... es 4471 2093 ... en ASCII. Tenlo presente para la siguiente sección.
Un recuadro dibujado no cambia nada en el texto
La versión «censurada» que hace la mayoría de la gente dibuja rectángulos rellenos sobre los dos números de cuenta. En el content stream eso son dos objetos nuevos añadidos después de todo lo demás:
q 232 322 89.25 17 re h 0 0 0 RG 0 0 0 rg B Q El TJ de arriba queda intacto. El rectángulo se pinta después, así que tapa los glifos en pantalla y en papel, pero todos los extractores leen los operadores de texto, no los píxeles.
grep no sirve como prueba
La comprobación obvia es buscar el número en el archivo:
grep -c "4471 2093 5568" form.pdf # 0, for all seven files, including the original No devuelve nada ni siquiera para el original sin censurar, por dos motivos. Los content streams están comprimidos con Flate, así que los bytes en disco no son texto. Y aun después de descomprimirlos, el número está guardado como la cadena hexadecimal <3434373120...>, no como 4471 2093 5568. Un escaneo de streams tiene que descomprimir cada stream y buscar las dos formas, que es lo que hace el comprobador de abajo. Con fuentes CID (la mayoría de los PDF en japonés, chino y coreano) ni siquiera eso funciona, porque la cadena contiene IDs de glifo que solo se traducen a caracteres a través de la tabla ToUnicode de la fuente. Un extractor de texto de verdad es la única comprobación que funciona en todos los casos; el escaneo de streams es una segunda línea para restos que los extractores no leen.
Siete versiones, tres extractores
Cada versión, comprobada buscando el número de cuenta. «Streams» cuenta los streams en cualquier parte de los bytes del archivo, en uso o huérfanos, que todavía lo contienen después de descomprimirlos:
| versión | PyMuPDF | pypdf | pdfminer.six | streams | autor en los metadatos |
|---|---|---|---|---|---|
| original | encontrado | encontrado | encontrado | 2 | Jordan Ellis |
| recuadro dibujado en la página | encontrado | encontrado | encontrado | 2 | Jordan Ellis |
| recuadro como anotación cuadrada | encontrado | encontrado | encontrado | 2 | Jordan Ellis |
| marcas de censura añadidas, sin aplicar | encontrado | encontrado | encontrado | 2 | Jordan Ellis |
PyMuPDF apply_redactions, guardado completo | – | – | – | 0 | Jordan Ellis |
PyMuPDF apply_redactions, saveIncr() | – | – | – | 2 | Jordan Ellis |
| osFoundry PDF Editor, Apply + Sanitize + Save | – | – | – | 0 | (vacío) |
Para cualquier lector, las cuatro primeras son el mismo archivo. Los tres extractores coincidieron en todas las filas, así que para esta pregunta da igual cuál uses, siempre que uses alguno.
La cuarta fila es la que pilla a quienes ya conocen las herramientas de censura. Censurar un PDF son dos pasos: una anotación Redact que marca una zona y un paso de aplicación que elimina lo que hay debajo. Un archivo guardado entre los dos muestra contornos rojos (o recuadros negros, según el visor) y sigue conteniéndolo todo.
La trampa del guardado incremental
La sexta fila pasa los tres extractores y aun así filtra datos. saveIncr(), igual que el Guardar normal de muchas herramientas PDF de escritorio, no reescribe el archivo. Añade los objetos modificados y una nueva tabla de referencias cruzadas después del %%EOF antiguo, y los lectores siguen la tabla más reciente. Los content streams antiguos siguen ahí, solo que ya nada los referencia.
Recuperarlos cuesta una línea, porque la primera revisión del archivo termina en el primer %%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 El archivo incremental ocupa 11.533 bytes; sus primeros 7.163 bytes son el formulario original, intacto. Contar los marcadores %%EOF es una comprobación barata: el original y los guardados completos tienen uno, el guardado incremental tiene dos.
Qué escribe en su lugar una eliminación real
El archivo guardado por el PDF Editor de osFoundry no tiene el número de cuenta en ningún stream, y el resto del texto no se ha movido. Esta es la misma línea de antes, después de Apply:
q BT 1 0 0 1 236 326 Tm /tiro 12.5 Tf [-6500] TJ ET Q La cadena ha desaparecido y en el array de TJ solo queda un número. En un array de TJ, un número es un ajuste horizontal en milésimas del tamaño de fuente que se resta de la posición, así que -6500 mueve el cursor 6,5 × 12,5 = 81,25 puntos a la derecha sin dibujar nada. Es exactamente el ancho de 4471 2093 5568 en Times-Roman:
| texto eliminado | tamaño de fuente | ancho en Times-Roman | valor en 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 (firma) | 15 | 71,25 pt | -4750 |
Conservar el avance hace que todo lo que va después del fragmento eliminado en la misma línea se quede donde estaba. Comprobado palabra por palabra, las 63 palabras que quedan tienen la misma posición que en el original (0 movidas), y la página pasó de 75 palabras a 63. Los recuadros negros son un stream aparte al final, rectángulos rellenos sin más (236 323.5 81.25 12.5 re f) del tamaño del texto eliminado, sin nada debajo.
Los metadatos son un paso aparte
El guardado completo de PyMuPDF eliminó el texto y dejó author = Jordan Ellis en la información del documento. La censura actúa sobre el contenido de la página, y el diccionario de información no es contenido de la página. Limpiarlo son dos llamadas:
doc.set_metadata({}) doc.del_xml_metadata() doc.save(out, garbage=4, deflate=True) En este formulario, eso deja vacíos todos los campos de información y ningún Jordan en ningún byte del archivo. El paso Sanitize document del editor hace lo mismo y además quita adjuntos, comentarios, campos de formulario, capas ocultas, marcadores y scripts, y su archivo guardado no tenía ningún metadato.
Un comprobador para tener a mano
Todo lo anterior en una sola función que devuelve una lista de problemas y sale con un código distinto de cero si hay alguno:
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" Con el archivo incremental imprime el autor, 2 revisions: earlier versions are still in the file y cinco streams que todavía contienen un dato secreto. Con el archivo del recuadro dibujado imprime 9 coincidencias de los extractores, el autor y cinco streams. Con el archivo del PDF Editor de osFoundry imprime clean.
Tiene límites que conviene dejar claros. Solo encuentra las cadenas que le pasas, así que un valor escrito de otra forma (4471-2093-5568) necesita su propia entrada. Lee texto, así que un dato secreto que forma parte de una imagen necesita OCR primero. Y el escaneo de streams no ve el texto en fuentes CID, aunque los extractores sí lo detectan.
El PDF Editor que generó el archivo limpio se ejecuta en local dentro de la app de escritorio, en línea con el diseño privado y local-first de osFoundry, así que el formulario no sale del equipo para censurarlo. La app es gratuita desde la página de descarga de osFoundry.



