Ein PDF aus Handyfotos ist selten wegen des Texts groß. Es ist groß, weil jede Seite ein JPEG mit 3000 Pixeln ist, das auf ein Blatt Papier gezeichnet wird, das nur einen Bruchteil davon braucht. Zum Verkleinern muss man diese Bilder finden, ihre tatsächliche Pixeldichte bestimmen, sie auf einen sinnvollen Wert herunterrechnen und zurück in die Datei schreiben. Der PDF-Editor in der Desktop-App osFoundry macht das in zwei Klicks (All tools, Compress a PDF, Advanced optimization). Unten steht dieselbe Idee in rund 30 Zeilen Python mit PyMuPDF und Pillow, gemessen an einer Testdatei, dazu ein Vergleich mit dem, was die App aus derselben Eingabe gespeichert hat.
Die Testdatei ist ein Mietvertrag mit 6 Seiten, der für diesen Beitrag gebaut wurde: Jede Seite ist ein JPEG mit 3024×4032 Pixeln, mit Papierkorn und ungleichmäßiger Beleuchtung, platziert auf einer Seite von 612×816 pt. Die Datei hat 32,227,412 Bytes (30.7 MiB). Das Korn spielt eine Rolle: Rauschen macht ein JPEG groß, die absoluten Größen unten gelten also für diese Datei; ein saubererer Scan beginnt kleiner und schrumpft weniger.
Herausfinden, was den Platz belegt
Bevor man etwas ändert, addiert man die Datei nach Inhaltsart auf. Bei einem Scan genügt eine Zeile:
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
Die Bilder machen 99.98% der Datei aus. Text, Seitenbaum und der gesamte übrige PDF-Verwaltungsaufwand zusammen sind 6,171 Bytes. Wäre die Zahl bei 20% herausgekommen, wäre das Herunterrechnen von Bildern das falsche Werkzeug gewesen, und keine Einstellung in irgendeinem Kompressor hätte viel gebracht. Die App hat diese Prüfung eingebaut (Audit space usage); ihre Tabelle für dieselbe Datei zeigt Images 32,225,071 Bytes (99.99%), Content Streams 714, Document Overhead 1,627. Sie zählt nicht genau wie die Einzeiler oben, deshalb weichen die beiden Zahlen um ein paar Kilobyte voneinander ab.
Ein Bild hat keine Auflösung, bis es gezeichnet wird
Ein JPEG in einem PDF ist nur ein Raster aus Pixeln. Seine Dichte in Pixeln pro Zoll bestimmt die Größe, in der die Seite es zeichnet. PyMuPDF liefert beide Hälften:
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
Das Bild wird 8.5 Zoll breit gezeichnet, 3024 Pixel auf 8.5 Zoll ergeben also 356 ppi. Zum Lesen einer gedruckten Seite braucht man etwa 150, einem Bildschirm genügt weniger. Dasselbe Bild in halber Größe gezeichnet hätte 712 ppi. Deshalb muss man die ppi pro Bild und pro Platzierung berechnen und nicht aus einem Metadatenfeld lesen.
Was Auflösung und Qualität kosten
Um Zahlen auszuwählen, hier ein Durchlauf über die Bilder der ersten beiden Seiten: fünf Zieldichten mal vier JPEG-Qualitäten, mit der durchschnittlichen Größe eines Bilds und dem PSNR gegenüber einer Referenz. Die Referenz ist das Original, mit Lanczos auf 1275×1700 (150 ppi) verkleinert; jede Variante wird vor dem Vergleich auf dieses Raster skaliert, die Zeilen mit 100 und 72 ppi enthalten also die Unschärfe durch das erneute Vergrößern. Der PSNR ist ein grober Wert, und OCR habe ich hier nicht laufen lassen. Er soll nur zeigen, ab wo die Qualität schnell abfällt.
| Ziel-ppi | q90 | q75 | q60 | q40 |
|---|---|---|---|---|
| 356 (Originalgröße) | 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 |
Zwei Dinge fallen auf. Von 356 auf 150 ppi bei gleicher Qualität zu gehen, spart etwa 88% der Bytes (q75: von 1392 auf 225 KiB), während es allein durch Senken der JPEG-Qualität in voller Größe, von q90 auf q40, noch 590 KiB bleiben, mehr als das Doppelte der 225 KiB bei 150 ppi und q75. Und unter 150 fallen die Dezibel steil ab: 100 ppi bei q90 liegt unter 150 ppi bei q40. Für eine Seite mit gedrucktem Text ist 150 ppi bei mittlerer Qualität in diesen Daten der Knick der Kurve.
Das Skript
Die Regeln sind die, die die App in ihrem Dialog nennt: auf 150 ppi herunterrechnen, wenn ein Bild mit mehr als 225 ppi gezeichnet wird, JPEG in mittlerer Qualität, und ein Bild nur ersetzen, wenn das Ergebnis kleiner ist.
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]))
Auf der Testdatei, mit PyMuPDF und Pillow über uvx, dauerte es auf diesem Mac 2.8 Sekunden:
30.7 MiB -> 0.92 MiB (97% smaller), 6 images replaced
Eine Falle hat mich eine falsche erste Messung gekostet. Meine erste Version nutzte page.replace_image(xref, stream=...), was der naheliegende Aufruf zu sein schien, und die Ausgabe kam auf 1.83 MiB, doppelt so viel wie erwartet. Der Aufruf legte das neue Bild als neues Objekt an, und die alten Objekte blieben in der Datei, sie enthielt also beide Versionen jeder Seite: Ein Durchgang über die Streams der Datei zeigte 12 große, jeder neue mit einem gleich großen Zwilling. Schreibt man den Stream mit update_stream in das bestehende Objekt und korrigiert dessen Schlüssel Width, Height, Filter und ColorSpace, kommt man auf 0.92 MiB und sechs Streams von 107 bis 172 KB.
Wie nah die App herankommt
Die App hat dieselbe Eingabe als Kopie gespeichert, en (optimized).pdf, mit 1,097,087 Bytes (1.0 MiB), 97% kleiner, mit sechs optimierten Bildern. Liest man ihre Bilder mit PyMuPDF zurück, hat jedes 1275×1700 Pixel, 150 ppi auf der Seite, 130,834 bis 194,130 Bytes. Das Skript oben kommt auf 0.92 MiB, etwas weniger.
Der Unterschied liegt im Encoder. Pillow kann die Quantisierungstabelle eines JPEG ausgeben, und die erste Zeile der Tabelle der App ist [13, 9, 8, 13, 19, 32, 41, 49]. Pillow schreibt genau diese Zeile bei quality=60, "Medium" im Dialog der App entspricht hier also libjpeg-Qualität 60, genau wie der Standardwert im Skript. Die Bilder der App behalten außerdem die Farbe in voller Auflösung (Pillow liest ihr Chroma-Subsampling als 4:4:4, während Pillows Standard 4:2:0 ist). Für Seite 1 ergibt Pillow mit 4:4:4 und optimize=True 178,447 Bytes und mit dem Standard 171,678, gegenüber 194,130 bei der App, die vermutlich also auch auf optimierte Huffman-Tabellen verzichtet. Das sind ein paar Prozent Größe, und die volle Farbinformation ist bei einem Scan eines farbigen Formulars die Mühe wert.
Nur ersetzen, wenn es kleiner ist
Die Prüfung out.tell() >= len(raw) ist keine Zierde. Nimm die Ausgabe der App und kodiere das Bild von Seite 1 erneut mit Qualität 90: Es kommt auf 284,133 Bytes, gegenüber 194,130 für das Bild, das schon in der Datei liegt. Ein JPEG mit höherer Qualität neu zu kodieren, als die, mit der es entstand, macht es größer und nicht schärfer. Ohne die Prüfung kann die Datei wachsen, wenn man einen Kompressor zweimal oder auf einer bereits komprimierten Datei ausführt.
Den Rest erledigt die ppi-Prüfung. Das Skript auf seine eigene Ausgabe und auf die Ausgabe der App angewendet, ersetzte beide Male 0 Bilder: Jedes Bild hat schon 150 ppi, unter der Schwelle von 225, und die Datei wird nur erneut gespeichert (die 1.0-MiB-Kopie der App wurde 1.05 MiB groß, allein durch das Neuschreiben, ohne dass ein Bild angefasst wurde).
Wo es nicht mehr funktioniert
Das hilft nur, wenn die Prüfung zeigt, dass Bilder den Großteil der Datei ausmachen. Ein PDF, das aus einer Textverarbeitung exportiert wurde, hat keine Seitenbilder zum Herunterrechnen. Außerdem ist das Ergebnis verlustbehaftet: Der Qualitätsverlust in der Tabelle oben ist real, und das Original muss unverändert bleiben. Deshalb schreibt die App eine neue Kopie neben die Datei, und das Skript tut es auch, es braucht dafür ein dst, das sich von src unterscheidet.
Es gibt noch eine Zahl, die erklärt, warum das über die auf dem Bildschirm angezeigte Größe hinaus wichtig ist. E-Mail hängt Dateien als Base64 an, das sind 4 Bytes Text für je 3 Bytes Datei, plus Zeilenumbrüche. Kodiert man die Testdatei so mit Pythons base64.encodebytes, kommen für das 30.7-MiB-PDF 41.5 MiB heraus, ein Limit von 25 MB kann also stärker überschritten werden, als die Dateigröße vermuten lässt. Dieselbe Rechnung legt für eine Datei, die versendet werden soll, eine Grenze von etwa 18 MB nahe.
Der PDF-Editor ist Teil der osFoundry-Desktop-App, die auf deinem Computer läuft und auf der osFoundry-Downloadseite kostenlos erhältlich ist. Wie sie Dateien auf dem Gerät hält, beschreibt die Local-first-Seite.
Top comments (0)