DEV Community

Cover image for Cómo reducir el tamaño de un PDF escaneado bajando la resolución de sus imágenes con Python

Cómo reducir el tamaño de un PDF escaneado bajando la resolución de sus imágenes con Python

Un PDF hecho con fotos del móvil rara vez pesa por el texto. Pesa porque cada página es un JPEG de 3000 píxeles dibujado sobre una hoja que solo necesita una fracción de eso. Reducirlo consiste en encontrar esas imágenes, calcular con qué densidad se dibujan, remuestrearlas a algo razonable y volver a escribirlas en el archivo. El Editor de PDF de la aplicación de escritorio osFoundry lo hace en dos clics (All tools, Compress a PDF, Advanced optimization). Aquí tienes la misma idea en unas 30 líneas de Python con PyMuPDF y Pillow, medida sobre un archivo de prueba, y una comparación con lo que guardó la aplicación a partir del mismo original.

El archivo de prueba es un contrato de alquiler de 6 páginas creado para este artículo: cada página es un JPEG de 3024×4032 con grano de papel e iluminación irregular, colocado en una página de 612×816 pt. Pesa 32,227,412 bytes (30.7 MiB). El grano importa: el ruido es lo que hace grande a un JPEG, así que los tamaños absolutos de abajo valen para este archivo; un escaneo más limpio partirá de menos y se reducirá menos.

Averigua qué ocupa el espacio

Antes de cambiar nada, suma el archivo por tipo de contenido. Para un escaneo basta una línea:

import os, pymupdf
doc = pymupdf.open("scan.pdf")
img = sum(len(doc.xref_stream_raw(i[0])) for p in doc for i in p.get_images(full=True))
print(img, "of", os.path.getsize("scan.pdf"))
Enter fullscreen mode Exit fullscreen mode
32221241 of 32227412
Enter fullscreen mode Exit fullscreen mode

Las imágenes son el 99.98% del archivo. El texto, el árbol de páginas y toda la contabilidad interna del PDF suman 6,171 bytes. Si el resultado hubiera sido un 20%, remuestrear imágenes no habría sido la herramienta adecuada y ningún ajuste de ningún compresor habría ahorrado mucho. La aplicación trae esta comprobación integrada (Audit space usage); su tabla para el mismo archivo dice Images 32,225,071 bytes (99.99%), Content Streams 714, Document Overhead 1,627. No cuenta exactamente igual que la línea de arriba, por eso las dos cifras difieren en unos kilobytes.

Una imagen no tiene resolución hasta que se dibuja

Un JPEG dentro de un PDF es solo una cuadrícula de píxeles. Su densidad en píxeles por pulgada la decide el tamaño con que la página lo dibuja. PyMuPDF da las dos mitades:

page = doc[0]
xref, w, h = page.get_images(full=True)[0][0], *page.get_images(full=True)[0][2:4]
rect = page.get_image_rects(xref)[0]          # where the page draws it, in points
ppi = w / (rect.width / 72)
print(w, h, round(rect.width / 72, 2), round(ppi))
Enter fullscreen mode Exit fullscreen mode
3024 4032 8.5 356
Enter fullscreen mode Exit fullscreen mode

La imagen se dibuja con 8.5 pulgadas de ancho, así que 3024 píxeles repartidos en 8.5 pulgadas son 356 ppi. Para leer una página impresa hacen falta unos 150, y a una pantalla le basta con menos. La misma imagen dibujada a la mitad de tamaño tendría 712 ppi, y por eso el ppi hay que calcularlo por imagen y por colocación, no leerlo de un campo de metadatos.

Lo que cuestan la resolución y la calidad

Para elegir números, aquí hay un barrido sobre las imágenes de las dos primeras páginas: cinco densidades objetivo por cuatro calidades JPEG, con el tamaño medio de una imagen y el PSNR frente a una referencia. La referencia es el original reescalado a 1275×1700 (150 ppi) con Lanczos; cada variante se reescala a esa misma cuadrícula antes de comparar, así que las filas de 100 y 72 ppi incluyen el desenfoque de volver a ampliarlas. El PSNR es una cifra aproximada y aquí no ejecuté OCR. Está solo para mostrar dónde la calidad empieza a caer rápido.

ppi objetivo q90 q75 q60 q40
356 (tamaño original) 3713 KiB / 44.0 dB 1392 KiB / 41.7 dB 941 KiB / 40.3 dB 590 KiB / 39.5 dB
225 985 KiB / 41.5 dB 491 KiB / 39.6 dB 333 KiB / 38.5 dB 244 KiB / 37.7 dB
150 431 KiB / 38.8 dB 225 KiB / 36.6 dB 167 KiB / 35.1 dB 135 KiB / 33.8 dB
100 210 KiB / 33.1 dB 119 KiB / 31.7 dB 94 KiB / 30.6 dB 76 KiB / 29.4 dB
72 118 KiB / 28.3 dB 72 KiB / 27.6 dB 57 KiB / 26.9 dB 45 KiB / 26.0 dB

Destacan dos cosas. Pasar de 356 a 150 ppi con la misma calidad elimina cerca del 88% de los bytes (q75: de 1392 a 225 KiB), mientras que bajar solo la calidad JPEG a tamaño completo, de q90 a q40, aún deja 590 KiB, más del doble de los 225 KiB de 150 ppi a q75. Y por debajo de 150 los decibelios se desploman: 100 ppi a q90 queda por debajo de 150 ppi a q40. Para una página de texto impreso, 150 ppi con una calidad intermedia es el codo de la curva en estos datos.

El script

Las reglas son las que la aplicación indica en su diálogo: reducir a 150 ppi cuando una imagen se dibuja por encima de 225 ppi, JPEG con calidad media, y sustituir una imagen solo si el resultado pesa menos.

import io, os, sys, pymupdf
from PIL import Image

def shrink(src, dst, target=150, above=225, quality=60):
    doc = pymupdf.open(src)
    seen = set()
    for page in doc:
        for info in page.get_images(full=True):
            xref, w, h = info[0], info[2], info[3]
            if xref in seen:
                continue
            seen.add(xref)
            rect = page.get_image_rects(xref)[0]
            ppi = w / (rect.width / 72)
            if ppi <= above:
                continue
            raw = doc.xref_stream_raw(xref)
            im = Image.open(io.BytesIO(raw)).convert("RGB")
            im = im.resize((round(w * target / ppi), round(h * target / ppi)), Image.LANCZOS)
            out = io.BytesIO()
            im.save(out, "JPEG", quality=quality)
            if out.tell() >= len(raw):
                continue                      # not smaller: keep the original
            doc.update_stream(xref, out.getvalue(), compress=False)
            doc.xref_set_key(xref, "Width", str(im.width))
            doc.xref_set_key(xref, "Height", str(im.height))
            doc.xref_set_key(xref, "Filter", "/DCTDecode")
            doc.xref_set_key(xref, "ColorSpace", "/DeviceRGB")
    doc.save(dst, garbage=4, deflate=True)

shrink(sys.argv[1], sys.argv[2])
print(os.path.getsize(sys.argv[1]), "->", os.path.getsize(sys.argv[2]))
Enter fullscreen mode Exit fullscreen mode

Sobre el archivo de prueba, con PyMuPDF y Pillow lanzados desde uvx, tardó 2.8 segundos en este Mac:

30.7 MiB -> 0.92 MiB (97% smaller), 6 images replaced
Enter fullscreen mode Exit fullscreen mode

Una trampa me costó una primera medición errónea. Mi primera versión usaba page.replace_image(xref, stream=...), que parecía la llamada obvia, y el resultado salió en 1.83 MiB, el doble de lo esperable. Añadía la imagen nueva como un objeto nuevo y los objetos viejos se quedaban en el archivo, de modo que contenía las dos versiones de cada página: al revisar los streams del archivo aparecían 12 grandes, cada uno nuevo con un gemelo del mismo tamaño. Escribir el stream en el objeto existente con update_stream y corregir sus claves Width, Height, Filter y ColorSpace da 0.92 MiB y seis streams de 107 a 172 KB.

Qué tan cerca queda la aplicación

La aplicación guardó el mismo original como copia, en (optimized).pdf, con 1,097,087 bytes (1.0 MiB), un 97% menos, con seis imágenes optimizadas. Al leer sus imágenes con PyMuPDF salen 1275×1700 píxeles cada una, 150 ppi en la página, de 130,834 a 194,130 bytes. El script de arriba da 0.92 MiB, algo menos.

La diferencia está en el codificador. Pillow puede informar de la tabla de cuantización de un JPEG, y la primera fila de la tabla de la aplicación es [13, 9, 8, 13, 19, 32, 41, 49]. Pillow escribe exactamente esa fila con quality=60, así que "Medium" en el diálogo de la aplicación equivale aquí a calidad 60 de libjpeg, igual que el valor por defecto del script. Las imágenes de la aplicación además conservan el color a resolución completa (Pillow lee su submuestreo de crominancia como 4:4:4, mientras que el de Pillow por defecto es 4:2:0). Para la página 1, Pillow con 4:4:4 y optimize=True da 178,447 bytes y con el valor por defecto 171,678, frente a los 194,130 de la aplicación, así que probablemente la aplicación tampoco usa tablas de Huffman optimizadas. Son unos pocos puntos porcentuales de tamaño, y el detalle de color completo vale la pena en el escaneo de un formulario a color.

Sustituir solo si pesa menos

La comprobación out.tell() >= len(raw) no es decorativa. Toma la salida de la aplicación y vuelve a codificar la imagen de la página 1 con calidad 90: sale en 284,133 bytes, frente a los 194,130 de la imagen que ya estaba en el archivo. Recodificar un JPEG con una calidad mayor que la que lo produjo lo hace más grande y no más nítido. Sin la comprobación, ejecutar un compresor dos veces, o sobre un archivo ya comprimido, puede hacer crecer el archivo.

La comprobación del ppi hace el resto del trabajo. Al ejecutar el script sobre su propia salida, y sobre la de la aplicación, se sustituyeron 0 imágenes en ambos casos: todas las imágenes ya están a 150 ppi, por debajo del umbral de 225, y el archivo solo se vuelve a guardar (la copia de 1.0 MiB de la aplicación salió en 1.05 MiB, solo por la reescritura, sin tocar ninguna imagen).

Dónde deja de funcionar

Esto solo ayuda cuando la auditoría indica que las imágenes son la mayor parte del archivo. Un PDF exportado desde un procesador de textos no tiene imágenes de página que reducir. Además, el resultado tiene pérdida: la pérdida de calidad de la tabla de arriba es real, y el original debe conservarse tal cual. Por eso la aplicación escribe una copia nueva junto al archivo, y también lo hace el script, que necesita un dst distinto de src.

Hay además una cifra que explica por qué esto importa más allá del tamaño que se ve en pantalla. El correo adjunta los archivos en base64, que son 4 bytes de texto por cada 3 bytes de archivo, más los saltos de línea. Codificar así el archivo de prueba con base64.encodebytes de Python da 41.5 MiB para el PDF de 30.7 MiB, de modo que un límite de 25 MB puede superarse por más de lo que sugiere el tamaño del archivo. La misma aritmética lleva a mantener por debajo de unos 18 MB un archivo que vayas a enviar.

El Editor de PDF forma parte de la aplicación de escritorio osFoundry, que se ejecuta en tu ordenador y es gratuita en la página de descarga de osFoundry. Cómo mantiene los archivos en el dispositivo se explica en la página de privacidad local.

Top comments (0)