Technischer Artikel

HotPDF CompressDocument: Kompakte Font-Subsets in Delphi

HotPDF-THotPDF.CompressDocument ist ein einziger Schalter, der BeginDoc die kleinste verlustfreie PDF erzeugen lässt, die die Komponente schreiben kann: FlateDecode auf maximalem Level, ein Cross-Reference-Stream mit Object Streams, Font-Subsetting und kompakte Font-Subsets, die die behaltenen Glyphen hinter einer expliziten /CIDToGIDMap neu nummerieren. EndDoc stellt danach Ihre eigenen Einstellungen wieder her. Ein dreiseitiges Arial-und-SimSun-Testdokument fiel von 10,2 MB auf 20 KB — mit identischem Rendering

Was schaltet CompressDocument überhaupt ein?

CompressDocument überschreibt sechs Writer-Einstellungen plus die Object-Stream-Obergrenze für genau ein Dokument und stellt alles danach wieder her. Bei BeginDoc, bevor sich die PDF-Version festlegt, notiert HotPDF Ihre Werte und setzt Compression auf cmFlateDecode, CompressionLevel auf clMaximum, schaltet EnableFontSubsetting und CompactFontSubsetting ein und aktiviert UseXRefStream samt UseObjectStreams (ISO 32000-1 §7.5.7 und §7.5.8). Object Streams brauchen PDF 1.5, eine ältere Version wird also auf 1.5 angehoben, sofern sie nicht festgeschrieben ist. PDF/A-1 verbietet beide Strukturen, ein PDF/A-1-Dokument behält also seine klassische Cross-Reference-Tabelle und bekommt nur die Flate- und Font-Arbeit. Bilder bleiben exakt so, wie Sie sie eingebettet haben

Diagramm des HotPDF-CompressDocument-Lebenszyklus in Delphi: BeginDoc notiert die eigenen Writer-Werte, überschreibt für ein Dokument sechs Einstellungen inklusive Compression und UseObjectStreams, und EndDoc stellt jeden geliehenen Wert im äußersten finally wieder her, während die CompressDocument-Eigenschaft selbst True bleibt
Sechs Writer-Einstellungen und die Object-Stream-Obergrenze werden für genau ein Dokument geliehen und bei EndDoc zurückgegeben, sodass ein fehlgeschlagener Report die Komponente nie auf maximaler Kompression hängen lässt
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'invoice-2026-1042.pdf';
    Pdf.CompressDocument := True;    // angewendet von BeginDoc, zurückgenommen von EndDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-1042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Die Wiederherstellung geschieht im äußersten finally von EndDoc, eine Exception mitten im Report hinterlässt also keine langlebige Komponente, die beim nächsten Job weiter auf maximaler Kompression hängt. Die Eigenschaft CompressDocument selbst bleibt True; nur die sechs geliehenen Einstellungen gehen zurück. Mit der Version wird es sorgfältiger gehandhabt. HotPDF nimmt seine eigene Anhebung auf 1.5 nur zurück, wenn das Dokument am Ende noch bei 1.5 liegt — hat ein anderes Feature die Datei während des Laufs auf 1.6 geschoben (etwa ein eingebetteter OpenType-Font), bleibt die höhere Version, genau so, als hätte es keine Kompression gegeben

Warum bleiben Font-Subsets ohne Kompaktierung groß?

Ein klassisches TrueType-Subset wirft die Konturen weg, die Sie nie zeichnen, behält aber jede Glyph-ID an ihrem Platz, und genau diese Nummerierung hält es schwer. Der Content-Stream zeigt CIDs, die den ursprünglichen GIDs entsprechen, das Subset muss also für jeden Slot bis zum höchsten behaltenen Glyphen einen loca-Offset und einen hmtx-Eintrag mitführen, leer oder nicht. Bei einer lateinischen Schrift ist dieser Overhead Rauschen. Bei einer CJK-Schrift wie SimSun, deren Ideogramme tief in einer sehr großen Glyphentabelle sitzen, schleppen zwei chinesische Zeichen Tabellen für den ganzen Font mit. Die Font-Subset-Closure-Regeln für geformte Glyphen entscheiden, welche Glyphen überleben; die Kompaktierung fragt, was die Überlebenden kosten

CompactFontSubsetting nummeriert die behaltenen Glyphen in einen dichten Bereich ab null um und schreibt einen /CIDToGIDMap-Stream auf den CIDFont, den ISO 32000-1 §9.7.4.2 als Tabelle aus Zweibyte-GIDs definiert, indiziert per CID. Diese Tabelle ist der ganze Trick. Content-Streams, das /W-Breiten-Array und die ToUnicode-CMap behalten alle die ursprünglichen CIDs, nichts Geschriebenes muss sich also ändern; nur der Blick von der CID zur Glyphe wandert in die Map. In dem Test, der das Feature motiviert hat, ging SimSun mit zwei Zeichen von 24,8 KB Fontdaten auf 3,1 KB

Vergleich eines dünn besetzten HotPDF-Font-Subsets, das loca- und hmtx-Einträge für jede ursprüngliche Glyph-ID bis zur höchsten behaltenen GID vorhält, gegen die CompactFontSubsetting-Ausgabe, die behaltene Glyphen dicht ab null neu nummeriert und CIDs über einen CIDToGIDMap-Stream abbildet, während Content-Streams, /W und ToUnicode unverändert bleiben
Die Umnummerierung verlagert die Kosten aus dem Font-Programm in einen kleinen Map-Stream — zwei SimSun-Zeichen fielen von 24,8 KB auf 3,1 KB, ohne ein Byte bereits geschriebenen Contents anzufassen

Die Kompaktierung hat harte Grenzen und degradiert leise, statt zu scheitern. HotPDF baut kompakte Subsets nur für Type-0-TrueType-Schriften, sowohl für die über SetFont mit eingeschaltetem Subsetting gesetzten als auch für die über RegisterUnicodeTTF registrierte Schrift. Eine einfache TrueType-Schrift findet ihre Glyphen über die cmap im Font-Programm, was die Umnummerierung zerbrechen würde, also behält sie das dünne Subset. OpenType-CFF-Schriften haben ebenfalls keinen kompakten Pfad. Ein scheiternder kompakter Aufbau fällt auf das dünne Subset zurück, statt zu werfen. Die Eigenschaft steht standardmäßig auf aus, bestehende Ausgabe bleibt also byte-identisch, während unter PDF/A die registrierte Unicode-Schrift immer ein kompaktes Subset bekommt

Pdf.EnableFontSubsetting := True;
Pdf.CompactFontSubsetting := True;   // auch ohne CompressDocument nutzbar
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('SimSun', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, WideString('Total: '#$4E2D#$6587));
Pdf.EndDoc;

Wie drückt der gepackte Writer die Dateistruktur zusammen?

Sind Fonts und Streams erst einmal klein, werden die Dictionaries und die Cross-Reference-Daten zum größten verbleibenden Posten, also schneidet der Object-Stream-Writer hinter CompressDocument auch dort zu. Der Leitfaden zu Object Streams und inkrementellen Updates behandelt das Containerformat selbst; der Kompressionspfad legt vier Verfeinerungen obendrauf:

  • Kompakte Syntax nach ISO 32000-1 §7.2.2: Ein Leerzeichen wird nur zwischen zwei Tokens geschrieben, die sonst als reguläre Zeichen zusammenlaufen würden, aus /Type /Page wird also /Type/Page
  • Cross-Reference-Stream-Felder nehmen jede Breite, die §7.5.8.2 erlaubt, eine Datei unter 16 MB speichert jeden Offset also in 3 Bytes statt 4
  • Bis zu 250 Objekte wandern in jeden Object Stream statt der üblichen 100, außer Sie setzen über ConfigureAdaptiveObjectStreamPacking eine eigene Obergrenze
  • Ist die Datei nicht verschlüsselt, wandern Catalog und Info-Dictionary ebenfalls in Object Streams; verschlüsselte Ausgabe behält sie auf der obersten Ebene

Die kompakte Syntax kam mit einer Falle, die Sie kennen sollten, wenn Sie den Writer erweitern. Das Signieren füllt die Signatur aus, nachdem die Datei geschrieben ist, indem es die Bytes nach den Literal-Platzhaltern /ByteRange ( und /Contents < durchsucht — kompakte Schreibweise würde daraus /ByteRange( und /Contents< machen, was die Suche nie findet. Signatur-Dictionarys (Type Sig oder DocTimeStamp, FT Sig) und das Verschlüsselungs-Dictionary behalten deshalb das Layout mit Leerzeichen. Ein verwandter Defekt betraf Builds vor v2.766.41: Jeder Object-Stream-Save, CompressDocument eingeschlossen, begann mit zwei %PDF--Headerzeilen — upgraden Sie also, wenn ein strenger Validator Ihre Ausgabe anmeckert

Können Sie eine bereits geladene PDF komprimieren?

Ja, über den Options-Overload CompressLoadedDocument(Options, Info), der dieselben verlustfreien Schritte auf einer bestehenden Datei fährt. Mit THPDFLoadedDocumentCompressionOptions.Default entfernt er ungenutzte Seitenressourcen, führt identische Fonts und Formen zusammen, subsettet eingebettete Fonts mit eingeschalteten kompakten Subsets, komprimiert unfilterte, Flate-, LZW-, ASCII- und RunLength-Streams mit Flate neu, wenn das Ergebnis kleiner ausfällt, und stellt das nächste Speichern auf Object Streams um. HighRatioFlate ist standardmäßig aus, und Object Streams entfallen bei PDF/A-1 und inkrementellen Saves. Der parameterlose CompressLoadedDocument-Overload ist der ältere, schmalere Aufruf, der nur unkomprimierte Streams Flate-komprimiert

Ablauf von HotPDF CompressLoadedDocument in Delphi: Der Aufruf entfernt ungenutzte Seitenressourcen, führt identische Fonts und Formen zusammen, subsettet eingebettete Fonts mit kompakten Subsets, komprimiert Streams nur mit Flate neu, wenn das Ergebnis kleiner ist, und schaltet Object Streams für das nächste Speichern ein — während Signaturfelder RefusedBySignaturePolicy auslösen und die Datei unangetastet lassen
Jeder Schritt schreibt Bytes um, die eine Signatur abdeckt, also wird das ganze Dokument abgelehnt, solange Sie die Invalidierung nicht ausdrücklich erlauben — Info.BytesSaved summiert dann nur die Ressourcen-, Font- und Stream-Arbeit
var
  Doc: THotPDF;
  Options: THPDFLoadedDocumentCompressionOptions;
  Info: THPDFLoadedDocumentCompressionInfo;
begin
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    Doc.LoadFromFile('quarterly-report.pdf');
    Options := THPDFLoadedDocumentCompressionOptions.Default;
    Doc.CompressLoadedDocument(Options, Info);
    if Info.RefusedBySignaturePolicy then
      Writeln(Format('Left untouched: %d signature fields', [Info.SignatureCount]))
    else
    begin
      Writeln(Format('Compact fonts: %d, stream bytes saved: %d',
        [Info.Fonts.CompactSubsetFontCount, Info.BytesSaved]));
      Doc.SaveLoadedDocument('quarterly-report-compact.pdf');
    end;
  finally
    Doc.Free;
  end;
end;

Auf dem Loaded-Pfad zählen zwei Grenzen. Jeder Schritt schreibt Bytes um, die eine Signatur abdeckt, ein Dokument mit Signaturfeldern wird also als Ganzes abgelehnt: Der Aufruf gibt 0 zurück, setzt RefusedBySignaturePolicy und ändert nichts — außer Sie setzen AllowSignatureInvalidation, wonach Info.SignaturesInvalidated verrät, was Sie aufgegeben haben. Die Kompaktierung ist hier auch konservativer als auf dem Erzeugungspfad. HotPDF kompaktiert nur Font-Programme, die ausschließlich von CIDFontType2-Fonts mit Identity-/CIDToGIDMap benutzt werden, wo CID gleich GID ist, und überspringt Programme mit vorhandenem Map-Stream, einem /CIDSet oder Farbglyphen-Tabellen wie COLR, sbix, CBDT oder SVG, weil der kompakte Neuaufbau die Farbebenen fallen ließe. Beachten Sie außerdem, dass Info.BytesSaved nur die Ressourcen-, Font- und Stream-Schritte summiert; der Object-Stream-Gewinn zeigt sich erst beim Schreiben der Datei

Welche Ergebnisse sollten Sie in der Praxis erwarten?

Die Gewinne richten sich danach, wie viel einer Datei unkomprimierte Struktur und übergroße Fontdaten sind, nicht danach, wie viele Seiten sie hat. Das dreiseitige Arial-und-SimSun-Muster schrumpfte bei der Erzeugung mit CompressDocument von 10,2 MB auf 20 KB, und von 10,2 MB auf 19,8 KB, als das unkomprimierte Original geladen und durch CompressLoadedDocument geschickt wurde — beidemal mit identischem Rendering. Eine bereits kompakte PDF bewegt sich kaum: In der Regressionssuite sparten solche Dateien zwischen -0,07 % und +0,06 % ihrer ursprünglichen Größe. Fotoreiche Dateien gewinnen wenig, denn keiner der Pfade fasst Bilddaten an

Erzeugen Sie dieselben CJK-Reports jede Nacht, kombinieren Sie kompakte Subsets mit dem persistenten Font-Subset-Cache auf der Platte, damit die Subsetting-Arbeit nicht pro Lauf wiederholt wird, und diffen Sie komprimierte Ausgaben nach Objektinhalt statt nach Bytes, denn ein einziges geändertes Feld Flate-át einen ganzen Object Stream neu. Die vollständige Referenz zu Eigenschaften und Records steht auf der HotPDF Delphi PDF component Produktseite