Un PDF creato da foto scattate con il telefono di solito non è pesante per colpa del testo. Lo è perché ogni pagina è un JPEG da 3000 pixel disegnato su un foglio che ne richiede solo una frazione. Per alleggerirlo bisogna trovare quelle immagini, capire a quale densità vengono disegnate, ricampionarle a un valore sensato e riscriverle nel file. L'editor PDF dell'app desktop osFoundry lo fa in due clic (Tutti gli strumenti, Comprimi un PDF, Ottimizzazione avanzata). Qui sotto trovi la stessa idea in circa 30 righe di Python con PyMuPDF e Pillow, misurata su un file di prova, e il confronto con quello che l'app ha ottenuto dallo stesso file.
Il file di prova è un contratto d'affitto di 6 pagine creato per questo articolo: ogni pagina è un JPEG da 3024×4032 con grana della carta e illuminazione irregolare, posizionato su una pagina da 612×816 pt. Pesa 32.227.412 byte (30,7 MiB). La grana conta: è il rumore a far crescere un JPEG, quindi le dimensioni assolute qui sotto valgono per questo file, mentre una scansione più pulita parte più in basso e si riduce meno.
Scoprire cosa occupa spazio
Prima di cambiare qualsiasi cosa, somma il file per tipo di contenuto. Per una scansione basta una riga:
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")) 32221241 of 32227412 Le immagini sono il 99,98% del file. Il testo, l'albero delle pagine e tutta la contabilità interna del PDF messi insieme pesano 6.171 byte. Se il risultato fosse stato il 20%, ricampionare le immagini non sarebbe stato lo strumento giusto, e nessuna impostazione di nessun compressore avrebbe fatto risparmiare molto. L'app ha questo controllo integrato (Analisi dello spazio utilizzato); la sua tabella per lo stesso file riporta Immagini 32.225.071 byte (99,99%), Flussi di contenuto 714, Overhead del documento 1.627. Non conta esattamente come la riga di codice qui sopra, per cui i due numeri differiscono di qualche kilobyte.
Un'immagine non ha una risoluzione finché non viene disegnata
Un JPEG dentro un PDF è solo una griglia di pixel. La sua densità in pixel per pollice dipende da quanto grande la pagina lo disegna. PyMuPDF restituisce entrambe le metà del dato:
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)) 3024 4032 8.5 356 L'immagine è disegnata larga 8,5 pollici, quindi 3024 pixel su 8,5 pollici fanno 356 ppi. Per leggere una pagina stampata ne servono circa 150, e uno schermo si accontenta di meno. La stessa immagine disegnata a metà dimensione sarebbe a 712 ppi, ed è per questo che il ppi va calcolato per ogni immagine e per ogni posizionamento, non letto da un campo dei metadati.
Quanto costano risoluzione e qualità
Per scegliere i valori, ecco una prova sulle immagini delle prime due pagine: cinque densità di destinazione per quattro qualità JPEG, con la dimensione media di un'immagine e il PSNR rispetto a un riferimento. Il riferimento è l'originale ridimensionato a 1275×1700 (150 ppi) con Lanczos; ogni variante viene ridimensionata su quella stessa griglia prima del confronto, quindi le righe a 100 e 72 ppi includono la sfocatura dovuta all'ingrandimento. Il PSNR è una misura approssimativa e qui non ho eseguito l'OCR. Serve solo a mostrare dove la qualità comincia a calare in fretta.
| ppi di destinazione | q90 | q75 | q60 | q40 |
|---|---|---|---|---|
| 356 (dimensione originale) | 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 |
Due cose saltano all'occhio. Passare da 356 a 150 ppi a parità di qualità elimina circa l'88% dei byte (q75: da 1392 a 225 KiB), mentre abbassare soltanto la qualità JPEG a dimensione piena, da q90 a q40, lascia comunque 590 KiB, più del doppio dei 225 KiB di 150 ppi a q75. E sotto i 150 i decibel crollano: 100 ppi a q90 è sotto 150 ppi a q40. Per una pagina di testo stampato, 150 ppi con una qualità media è il punto di flesso della curva in questi dati.
Lo script
Le regole sono quelle che l'app indica nella sua finestra di dialogo: downsampling a 150 ppi quando un'immagine è disegnata sopra i 225 ppi, JPEG a qualità media, e sostituire un'immagine solo se il risultato è più piccolo.
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])) Sul file di prova, con PyMuPDF e Pillow eseguiti tramite uvx, ci ha messo 2,8 secondi su questo Mac:
30.7 MiB -> 0.92 MiB (97% smaller), 6 images replaced Una trappola mi è costata una prima misura sbagliata. La mia prima versione usava page.replace_image(xref, stream=...), che sembrava la chiamata ovvia, e il risultato veniva 1,83 MiB, il doppio del dovuto. La chiamata aggiungeva la nuova immagine come nuovo oggetto e i vecchi restavano nel file, che quindi conteneva entrambe le versioni di ogni pagina: una scansione degli stream del file ne mostrava 12 grandi, ciascun nuovo con un gemello di dimensione identica. Scrivere lo stream nell'oggetto esistente con update_stream e correggerne le chiavi Width, Height, Filter e ColorSpace dà 0,92 MiB e sei stream da 107 a 172 KB.
Quanto ci va vicino l'app
L'app ha salvato lo stesso file come copia, en (optimized).pdf, a 1.097.087 byte (1,0 MiB), il 97% in meno, con sei immagini ottimizzate. Rileggendo le sue immagini con PyMuPDF risultano 1275×1700 pixel ciascuna, 150 ppi sulla pagina, da 130.834 a 194.130 byte. Lo script qui sopra dà 0,92 MiB, un po' meno.
La differenza sta nell'encoder. Pillow può mostrare la tabella di quantizzazione di un JPEG, e la prima riga di quella dell'app è [13, 9, 8, 13, 19, 32, 41, 49]. Pillow scrive esattamente quella riga con quality=60, quindi «Media» nella finestra dell'app corrisponde qui alla qualità 60 di libjpeg, la stessa predefinita nello script. Le immagini dell'app mantengono inoltre il colore a piena risoluzione (Pillow legge il loro subcampionamento della crominanza come 4:4:4, mentre quello predefinito di Pillow è 4:2:0). Per la pagina 1, Pillow in 4:4:4 con optimize=True dà 178.447 byte e con le impostazioni predefinite 171.678, contro i 194.130 dell'app, che quindi probabilmente non usa nemmeno tabelle di Huffman ottimizzate. Parliamo di pochi punti percentuali di dimensione, e il dettaglio pieno dei colori vale la pena su una scansione di un modulo a colori.
Sostituire solo se è più piccolo
Il controllo out.tell() >= len(raw) non è un ornamento. Prendi l'output dell'app e codifica di nuovo l'immagine della pagina 1 a qualità 90: viene 284.133 byte, contro i 194.130 dell'immagine già presente nel file. Ricodificare un JPEG a una qualità più alta di quella che lo ha prodotto lo rende più grande senza renderlo più nitido. Senza il controllo, eseguire un compressore due volte, o su un file già compresso, può far crescere il file.
Il controllo sul ppi fa il resto del lavoro. Eseguendo lo script sul proprio output e su quello dell'app, in entrambi i casi sono state sostituite 0 immagini: ogni immagine è già a 150 ppi, sotto la soglia di 225, e il file viene soltanto salvato di nuovo (la copia da 1,0 MiB dell'app è passata a 1,05 MiB per la sola riscrittura, senza toccare nessuna immagine).
Dove smette di funzionare
Serve solo quando l'analisi dice che le immagini sono la parte principale del file. Un PDF esportato da un programma di videoscrittura non ha immagini di pagina da ridurre. Inoltre il risultato è con perdita: il calo di qualità della tabella qui sopra è reale, e l'originale va lasciato com'è. Per questo l'app scrive una nuova copia accanto al file, e lo fa anche lo script, che richiede un dst diverso da src.
C'è anche un numero che spiega perché la cosa conta oltre la dimensione mostrata a schermo. La posta allega i file in base64, cioè 4 byte di testo ogni 3 byte di file, più gli a capo. Codificare così il file di prova con base64.encodebytes di Python dà 41,5 MiB per il PDF da 30,7 MiB, quindi un limite di 25 MB può essere superato più di quanto faccia pensare la dimensione del file. Lo stesso calcolo porta a tenere un file da inviare sotto i 18 MB circa.
L'editor PDF fa parte dell'app desktop osFoundry, che gira sul tuo computer ed è gratuita dalla pagina di download di osFoundry. Come mantiene i file sul dispositivo è descritto nella pagina local-first.

![Path Planning and Autonomous Navigation — Ch.11: Towing AMR Navigation [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyvsxdf311sqylulf87m4.png)