Um herauszufinden, wohin eine PDF-Dateigröße tatsächlich geht, stellt die losLab-PDF-Library AuditDocumentSpace bereit, das jedes indirekte Objekt in zwölf Kategorien einordnet — Bilder, Font-Programme, Font-Dictionaries, Content-Streams, Form-XObjects, Object-Streams, eingebettete Dateien, Metadaten, Strukturbaum, Annotationen, Seitenbaum, Sonstiges — und Objektanzahl, gespeicherte Bytes und Prozentanteil jeder Kategorie meldet
Die Situation, für die das existiert, ist vertraut. Ein 40-seitiger Bericht kommt aus dem eigenen Generator mit 80 MB heraus, der Kunde fragt warum, und alles, was man anbieten kann, ist eine Vermutung. Wahrscheinlich die Bilder. Vielleicht die Schriften. Also schaltet man Downsampling ein, liefert aus, und die Datei landet bei 74 MB, weil das eigentliche Gewicht ganz woanders lag. Unser begleitender Artikel zu Font-Subsetting und Bild-Downsampling behandelt, wie man eine PDF-Datei verkleinert; dieser hier behandelt den Schritt, der zuerst kommen sollte, nämlich zu messen, was man verkleinern will
Warum vor dem Komprimieren messen?
Weil die drei üblichen Optimierungsdurchgänge bei jeder gegebenen Datei wild unterschiedliche Erträge haben, und nichts an der Datei sagt einem, welcher zutrifft, bevor man zählt. Fonts in einem Dokument zu subsetten, dessen Fonts bereits 2 % seiner Bytes ausmachen, ist ein Nachmittag, der damit verbracht wird, einen Rundungsfehler zu verschieben. Bilder in einer Datei herunterzusampeln, deren Masse unkomprimierte Content-Streams sind, erzeugt dieselbe Enttäuschung. Der Optimierer ist nicht der schwierige Teil — jede Bibliothek hat einen. Zu wissen, welchen Optimierer man auf diese Datei ansetzen soll, ist der schwierige Teil, und das ist eine Buchhaltungsfrage, keine Kompressionsfrage. Ein Audit erwischt auch die Fälle, in denen kein Optimierer die Antwort ist: Eine Datei, die sich als zu 60 % eingebettete Anhänge erweist, braucht keine bessere Kompression, sie braucht ein Gespräch darüber, ob diese Anhänge überhaupt ins Dokument gehören, und eine Datei, die zu 30 % Strukturbaum ist, bezahlt für Barrierefreiheits-Tagging, was üblicherweise eine bewusste Kosten ist, die man nicht still entfernen sollte. Sobald die Bytes zugeordnet sind, trifft man eine Produktentscheidung mit Zahlen dahinter, statt zum nächstbesten Schalter zu greifen
Was der Zwölf-Kategorien-Report enthält
AuditDocumentSpace gibt ein String-List-Handle statt eines Records zurück, sodass der Report die flache DLL- und COM-Fassade unverändert überdauert. Die Liste enthält eine Zusammenfassungszeile Total,Objects,Bytes,100.0, gefolgt von genau zwölf Category,Objects,Bytes,Percent-Zeilen in einer festen Reihenfolge, die Teil des Vertrags ist: Images, Font programs, Font dictionaries, Content streams, Form XObjects, Object streams, Embedded files, Metadata, Structure tree, Annotations, Page tree, Other. Dreizehn Zeilen, immer, auch wenn eine Kategorie leer ist
var
Lib: TPDFlib;
ListID, I: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') <> 1 then
Exit;
ListID := Lib.AuditDocumentSpace; // 0 when no document is selected
if ListID = 0 then
Exit;
try
// GetStringListItem is 1-based: items run 1..GetStringListCount
for I := 1 to Lib.GetStringListCount(ListID) do
Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
finally
Lib.ReleaseStringList(ListID);
end;
finally
Lib.Free;
end;
end;
Ein Delphi-Detail in dieser Schleife wird genau einmal zubeißen. GetStringListItem verwendet 1-basierte Element-Indizes, passend zu GetStringListCount, und ein Index außerhalb des Bereichs gibt einen leeren String zurück, statt eine Exception auszulösen. Schreibt man die Schleife aus Gewohnheit als for I := 0 to Count - 1, erhält man eine leere erste Zeile, eine still fallengelassene letzte Zeile, und nirgends eine Exception, die einem sagt, dass die Indizierung falsch ist. Der Report selbst wird fast richtig aussehen, was der schlimmste Fehlermodus ist, den ein Diagnosewerkzeug haben kann
Warum verwendet das Audit gespeicherte Länge statt dekodierter Größe?
Weil die gespeicherte Länge sowohl die Zahl ist, die man will, als auch die Zahl, die günstig zu ermitteln ist. Jedes indirekte Objekt trägt TPDFIndObj.FLength, die rohe Byte-Länge, die das Objekt in der geparsten Datei einnimmt. Sie zu verwenden bedeutet, dass ein 900-KB-DCTDecode-Bild als 900 KB gemeldet wird — die Bytes, die es auf der Platte kostet — statt der 40 MB an RGB-Samples, zu denen es dekodiert. Es bedeutet auch, dass das Audit nie irgendetwas dekodieren muss: verzögert geladene Objekte bleiben verzögert, Filter bleiben ungenutzt, und das Auditieren einer 500-MB-Datei ist ein Durchlauf über Objekt-Header statt ein voller Dekompressionszyklus
Die zweite Regel ist eine Abwehr gegen Doppelzählung. Lebt ein Objekt innerhalb eines komprimierten Object-Streams, angezeigt durch ein von null verschiedenes FObjStrNum, wird seine Byte-Anzahl als null verbucht. Sein Speicherplatz wurde bereits einmal vom Container-Stream bezahlt, den ISO 32000-1 §7.5.7 als einen /Type /ObjStm-Stream definiert, der viele Objekte in einer Flate-komprimierten Nutzlast hält. Jedem Mitglied seinen eigenen Anteil zu berechnen und dann den Container erneut zu berechnen, würde die Summe über die tatsächliche Dateigröße hinaus aufblähen. Das hat eine direkte Konsequenz dafür, wie man die Ausgabe liest, im Folgenden behandelt und ausführlicher in unserem Artikel zu Object Streams und Cross-Reference-Streams
Warum kann sich ein Font-Programm nicht selbst klassifizieren?
Weil eine in eine PDF-Datei eingebettete TrueType-Font-Datei keine Kennzeichnung dafür trägt. ISO 32000-1 §9.8.1 definiert das eingebettete Font-Programm als den Wert von /FontFile, /FontFile2 oder /FontFile3 in einem Font-Descriptor, und das Stream-Dictionary am anderen Ende dieser Referenz trägt /Length1 und Filter-Schlüssel, aber kein /Type und kein /Subtype, das es als Font identifiziert. Isoliert betrachtet ist es ein anonymer Binärstream. Nur der Descriptor, der darauf verweist, weiß, was es ist. Dieselbe Asymmetrie zeigt sich bei Annotationen: §12.5.2 macht /Type /Annot in einem Annotations-Dictionary optional, sodass das verlässliche Signal die Mitgliedschaft in einem Seiten-/Annots-Array ist, nicht das Dictionary selbst
Die Klassifikation läuft daher zweimal. Der erste Durchgang liest den eigenen /Type und /Subtype jedes Objekts und nimmt die einfachen Treffer mit: /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font und /Type /FontDescriptor, /Metadata, /EmbeddedFile und /Filespec, /StructTreeRoot und /StructElem, /Annot, /Page und /Pages. Alles Übrige landet vorläufig bei Other. Der zweite Durchgang durchläuft dann die referenzierende Seite und überschreibt: Jedes Seiten-Dictionary weist seine /Contents den Content-Streams zu, seine /Annots-Einträge den Annotationen und sein /Thumb den Bildern, während jedes Font-Dictionary seine eigene Descriptor-Kette durchläuft
// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
MarkRef(Descriptor.FindValueByKeyName('FontFile'), catFontPrograms);
MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));
Den Report lesen und den nächsten Schritt wählen
Man liest zuerst die Anteile, dann die Objektanzahlen, und behandelt jede große Lücke zwischen beiden als Signal. Eine moderne PDF-Datei packt die meisten ihrer kleinen Dictionaries in Object-Streams, sodass Page tree und Structure tree routinemäßig Dutzende Objekte gegen nahezu null Bytes zeigen — ihre echten Kosten wurden in die Zeile Object streams eingefaltet. Ist Object streams selbst groß, ist die Datei dicht mit metadatenähnlicher Struktur statt mit Inhalt, und der Hebel ist das Entfernen von Objekten, nicht ihr Komprimieren. Appearance-Streams von Annotationen verhalten sich ähnlich: Sie tragen /Subtype /Form, sodass ein stark gestempeltes Dokument sein Gewicht unter Form XObjects zeigt, während die Annotations-Zeile klein bleibt
function CategoryShare(Lib: TPDFlib; ListID: Integer;
const Category: string): Double;
var
I: Integer;
Parts: TArray<string>;
Inv: TFormatSettings;
begin
Result := 0;
Inv := FormatSettings;
Inv.DecimalSeparator := '.'; // the report is locale-independent
for I := 2 to Lib.GetStringListCount(ListID) do // line 1 is Total
begin
Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
if (Length(Parts) = 4) and SameText(Parts[0], Category) then
Exit(StrToFloatDef(Parts[3], 0, Inv));
end;
end;
Zwei Formatierungstatsachen sind wichtig, wenn man die Prozentangaben parst, statt sie nur anzuzeigen. Das Dezimaltrennzeichen ist immer ein literaler Punkt, unabhängig von der Locale der Maschine, sodass ein Parsen mit den umgebenden FormatSettings auf einer deutschen oder französischen Workstation fehlschlagen oder, schlimmer, falsch gelesen wird. Und nachfolgende Nullen werden abgeschnitten, sodass eine Kategorie mit genau 40 % der Bytes als 40 gedruckt wird, nicht als 40.0 — man sollte nie eine feste Nachkommastellenzahl annehmen. Mit dem Anteil in der Hand ist die Weiterleitung mechanisch: Ein dominanter Images-Anteil weist auf DownsampleImages hin, ein dominanter Font-programs-Anteil auf SubsetEmbeddedFonts, und üppige Content streams auf CompressContent
Was das Audit bewusst nicht verrät
Die Summe ist eine Summierung über indirekte Objekte, und eine PDF-Datei ist etwas mehr als ihre Objekte. Der Datei-Header, der Trailer, Leerraum zwischen Objekten und eine klassische Cross-Reference-Tabelle sind keine indirekten Objekte, sodass diese Bytes keiner Kategorie zugeordnet werden und die Audit-Summe etwas unter der Größe auf der Platte landet. Ein Cross-Reference-Stream ist anders — er ist ein echtes Objekt mit /Type /XRef, sodass diese Bytes in einer modernen Datei tatsächlich erscheinen, in der Kategorie Other. Keines der beiden Verhalten ist ein Defekt, aber wer das Audit gegen eine Byte-Anzahl aus dem Dateisystem abgleicht, findet dort die Erklärung für die Lücke
Zwei weitere Grenzen lohnt es, klar zu benennen. Erstens beschreiben die Zahlen eine geladene Datei, keine gerade erstellte: Für im Speicher aufgebaute Objekte, die noch keine gespeicherte Länge haben, fällt die Größe auf die serialisierte Ausgabe mit einer nominellen Zulage für das Stream-Dictionary zurück, was eine Schätzung des künftigen Schreibvorgangs ist, keine Messung. Man auditiert nach einem Speichern-und-neu-Laden, wenn man exakte Zahlen will. Zweitens ist eine dicke Other-Zeile ein Befund, kein Bugreport — sie bedeutet meist verwaiste Objekte, auf die nichts mehr verweist, was eine Aufgabe für Mark-and-Sweep-Garbage-Collection ist, nicht für einen Kompressionsdurchgang
So eingesetzt ändert das Audit die Form des Gesprächs. Statt beim 80-MB-Bericht zu raten, öffnet man ihn, ruft einmal auf und liest, dass Bilder 8 % ausmachen, Font-Programme 61 %, und das Dokument neun vollständige Font-Programme für einen Hausstil einbettet, der drei Schnitte nutzt. Das ist eine behebbare Antwort mit einer Zahl daran. AuditDocumentSpace ist zusammen mit den Optimierungsdurchgängen, auf die es hinweist, Teil der losLab-PDF-Library für Delphi und C++Builder, deren Referenzseiten die vollständige Kategorienliste und die String-List-API drumherum dokumentieren