Technischer Artikel

Bereits eingebettete PDF-Fonts aus Delphi subsetten

PDFium Component subsettet Fonts, die bereits in einem bestehenden PDF eingebettet sind, aus Delphi über SubsetPdfDocumentFonts in der Unit FPdfDocumentFontSubset. Die Funktion ersetzt ein eingebettetes TrueType-/FontFile2-Programm nur dann durch ein kleineres, wenn die Font-Ressourcen selbst jede Glyphe belegen, die das Dokument referenzieren kann; ein Font, dessen Glyph-Domain sich nicht belegen lässt, bleibt exakt, wie er war

Der übliche Grund für dieses Bedürfnis ist ein Archiv, das man nicht selbst produziert hat. Rechnungen, Kontoauszüge und Reports älterer Generatoren tragen oft eine vollständige Arial oder einen ganzen CJK-Schnitt in jeder Datei, und ein paar hundert Kilobyte Font pro zweiseitigem Dokument summieren sich schnell über ein paar Millionen Dokumente. Neu zu rendern kommt selten infrage. Was man hat, ist das PDF, und die einzige sichere Frage ist, wie viel von jedem eingebetteten Font weg kann, ohne dass eine einzige Seite ein Zeichen verliert

Warum riskiert das Subsetten eines bestehenden PDFs Glyphenverlust?

Das Subsetten eines bestehenden PDFs riskiert Glyphenverlust, weil der Nachbearbeiter nicht weiß, welche Glyphen der ursprüngliche Writer verwendet hat. Der Produzent, der den Font einbettete, kannte seinen eigenen Text; ein Werkzeug, das danach läuft, muss dieses Wissen rekonstruieren, und jede Glyphe, die es übersieht, wird zu einer leeren Box oder einer .notdef-Form auf einer Seite, die niemand ansieht — bis ein Kunde es tut

Der naheliegende Ansatz ist, jeden Content-Stream, jedes Form XObject, jede Annotation-Appearance und jede Field-Default-Appearance zu parsen, die mit jedem Font gezeigten Codes zu sammeln und diese Glyphen zu behalten. Das funktioniert, bis es auf eine über Seiten und Formulare geteilte Font-Ressource trifft, auf ein Formularfeld, dessen Darstellung ein Viewer aus /DA neu erzeugt, oder auf einen Content-Stream mit einer Operatorsequenz, die der Scanner nicht modelliert. SubsetPdfDocumentFonts durchsucht bewusst keine Content-Streams. Es leitet die Glyph-Domain aus den Metadaten ab, die das PDF ohnehin zu jedem Font tragen muss: der /CIDToGIDMap, den /W- und /W2-Breiten-Arrays, der /CIDSet-Bitmap, der /ToUnicode-CMap, dem /Widths-Array der Simple-Font und dem /Encoding. Begrenzen diese Metadaten die Domain nicht, bleibt der Font unangetastet

Wie PDFium Component die Glyph-Domain beim Subsetten eingebetteter TrueType-Fonts aus Delphi belegt: /CIDToGIDMap, /W- und /W2-Breiten, /CIDSet, /ToUnicode, /Widths und /Encoding begrenzen die Domain, ohne Content-Streams zu durchsuchen, und ein nicht belegbarer Font bleibt unangetastet erhalten
SubsetPdfDocumentFonts macht aus Font-Metadaten eine belegte Glyph-Domain, bevor irgendetwas geschnitten wird, und ein Font, dessen Ressourcen die Domain nicht begrenzen, wird gemeldet und unangetastet gelassen

Original-Glyph-IDs behalten, damit Content-Streams unberührt bleiben

Das Subset-Backend hinter SubsetPdfDocumentFonts, TPdfTrueTypeSubsetBackend, behält jeden originalen Glyph-ID-Slot, sodass nie ein Content-Stream, eine CID oder eine /CIDToGIDMap umgeschrieben werden muss. Es baut die glyf- und loca-Tabellen neu, gibt jeder ungenutzten Glyphe einen nulllangen loca-Bereich und lässt maxp.numGlyphs unverändert. Glyphe 4711 ist nach dem Subsetten weiterhin Glyphe 4711; sie hat nur dann Outlines, wenn das Dokument sie erreichen kann

Diese Wahl entfernt das riskanteste Stück am Subsetten eines fertigen Dokuments. Glyphen umzunummerieren hieße, jeden String in jedem Content-Stream, der den Font verwendet, umzuschreiben, dazu den Mapping-Stream und alles, was Glyph-IDs cached. Behaltene IDs kosten ein wenig Größe, weil loca einen Eintrag pro Originalglyphe behält, aber die Masse eines TrueType-Programms sind Outline-Daten, und genau die verschwindet. Composite-Glyphen werden zu ihren Komponenten verfolgt, sodass ein akzentuierter Buchstabe den Basisbuchstaben und den Akzent behält, aus dem er gebaut ist; zyklische oder zu tiefe Composite-Referenzen lassen den Font scheitern, statt einen kaputten Abschluss zu produzieren

Behaltene Glyph-IDs nach dem Subsetten eingebetteter TrueType-Fonts mit PDFium Component in Delphi: Glyphe 4711 behält ihre Nummer und Outlines, ungenutzte Glyphen bekommen nulllange loca-Bereiche, während maxp.numGlyphs unverändert bleibt, sodass Content-Streams, CIDs und /CIDToGIDMap nie umgeschrieben werden
Weil Glyph-IDs das Subset überleben, brauchen Content-Streams, CIDs und die /CIDToGIDMap nie ein Umschreiben - der Größengewinn kommt aus fallengelassenen Outlines, nicht aus dem Umnummerieren

Der umbenannte Font folgt zudem ISO 32000-1 §9.6.4, das verlangt, dass ein Subset-Fontname mit sechs Großbuchstaben und einem Pluszeichen beginnt. Die Komponente leitet die sechs Buchstaben aus einem SHA-256-Digest des neuen Font-Programms ab, streift zunächst ein vorhandenes ABCDEF+-Präfix, löst Kollisionen zwischen Fonts im selben Dokument auf und schreibt den neuen Namen sowohl an den Descriptor-/FontName als auch an das Font-Dictionary /BaseFont, eingeschlossen den Type0-Eltern eines CIDFont

uses
  System.SysUtils, System.Classes, FPdfDocumentFontSubset;

procedure SubsetEmbeddedFonts(const SourceName, TargetName: string);
var
  Source, Target: TFileStream;
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Source := TFileStream.Create(SourceName, fmOpenRead or fmShareDenyWrite);
  try
    if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
      raise Exception.Create('Font subsetting failed: ' + Report.ErrorMessage);
  finally
    Source.Free;
  end;
  // Output ist stets ein vollständiges PDF: die neu gebaute Datei bei Report.Applied
  // = True, sonst eine Byte-für-Byte-Kopie der Quelle
  Target := TFileStream.Create(TargetName, fmCreate);
  try
    if Length(Output) > 0 then
      Target.WriteBuffer(Output[0], Length(Output));
  finally
    Target.Free;
  end;
end;

Wie belegt SubsetPdfDocumentFonts die Glyph-Domain eines CIDFont?

SubsetPdfDocumentFonts belegt die Glyph-Domain eines CIDFontType2-Fonts über sein CID-zu-Glyph-Mapping, und das Mapping entscheidet, wie viel Zusatzbeweis nötig ist. Ein expliziter /CIDToGIDMap-Stream (ISO 32000-1 §9.7.4.2) ist bereits eine Liste jeder Glyphe, die der Font adressieren kann, also kommt jede GID im Stream in die Domain. Ein Mapping-Stream mit mehr als 65536 Einträgen oder mit ungerader Bytezahl lässt das begrenzte Profil scheitern, und der Font wird erhalten

/CIDToGIDMap /Identity ist der harte Fall und zugleich der häufigste in modernen Dateien. Identity sagt CID gleich GID, was nichts darüber verrät, welche CIDs das Dokument verwendet. Die Komponente verlangt daher Primärbeweise: ein explizites /W- oder /W2-Breiten-Array (§9.7.4.3), dessen CID-Einträge Glyphen markieren, für die der Produzent Metriken deklariert hat, oder einen /CIDSet-Stream im Font-Descriptor (§9.8.3) — eine Bitmap mit einem Bit pro CID, höchstwertiges Bit zuerst. Ein nackter Identity-Font ohne beides wird mit dem Grund Identity CIDToGIDMap requires an explicit W array or CIDSet stream erhalten

Die Falle beim Verwenden von /W als Beweis ist /DW. Ein Produzent darf jede CID weglassen, deren Breite der Default-Breite entspricht, und ein gut optimierter Writer tut genau das für die häufigsten Glyphenbreiten. Käme die Domain allein aus /W, verschwänden diese Glyphen still aus dem Subset. Um diese Lücke zu schließen, durchläuft die Komponente jeden Type0-Font, dessen /DescendantFonts auf den CIDFont zeigt, verlangt, dass sein /Encoding /Identity-H oder /Identity-V ist, sodass ein 2-Byte-Code die CID ist, und führt die Quellcodes jedes beginbfchar- und beginbfrange-Eintrags aus seiner /ToUnicode-CMap (§9.10.3) in die Domain zusammen. Jede andere Kodierung oder ein /ToUnicode-Stream, der sich innerhalb des Budgets nicht dekodieren lässt, erhält den Font

Beweisentscheidung für eine Identity CIDToGIDMap beim Font-Subsetting von PDFium Component für Delphi: ein expliziter Mapping-Stream listet bereits jede Glyphe, Identity verlangt ein /W- oder /W2-Breiten-Array oder eine /CIDSet-Bitmap, und /ToUnicode-Quellcodes schließen den /DW-Default-Breiten-blinden Fleck
Eine Identity CIDToGIDMap sagt für sich genommen nichts über die Verwendung, also fordert der Subsetter /W- oder /CIDSet-Beweise und führt /ToUnicode-Codes zusammen, um die Glyphen abzudecken, die /W bei /DW auslässt

Schlichte TrueType-Fonts: von Widths zu Glyph-IDs

Single-Byte-/Subtype /TrueType-Fonts werden über ihren Codebereich und ihre Kodierung belegt, dann über den eigenen cmap des Font-Programms zu Glyph-IDs aufgelöst. Die Komponente verlangt 0 <= FirstChar <= LastChar <= 255 und ein /Widths-Array, dessen Länge exakt LastChar - FirstChar + 1 ist; ein zu kurzes, zu langes oder nicht numerisches Array bedeutet, dass die Code-Domain nicht belegt ist, und der Font bleibt, wie er ist

Jeder Code in diesem Bereich, dazu jeder Code, den /Differences nennt oder /ToUnicode abdeckt, wird dann so gemappt, wie ISO 32000-1 §9.6.6 es beschreibt. Codes unter /WinAnsiEncoding, /MacRomanEncoding oder /StandardEncoding laufen über die passende Tabelle nach Unicode. /Differences-Glyphennamen werden über die Adobe Glyph List aufgelöst, uniXXXX- und uXXXX-Namen, die 258 standardmäßigen Macintosh-Glyphennamen und die eigene post-Format-2.0-Namentabelle des Fonts. Die resultierenden Werte werden in Format-0-, 4-, 6- und 12-cmap-Subtables nachgeschlagen, eingeschlossen Unicode (3,1), (3,10) und (0,*), Mac Roman (1,0) und Windows Symbol (3,0) bei F000 plus Code

Hat ein Font überhaupt keine Basiskodierung, markiert die Komponente die Vereinigung jeder plausiblen Interpretation, statt eine zu raten. Das behält ein paar Glyphen, die vermutlich ungenutzt sind — die richtige Richtung, in der man sich irren sollte. Der umgekehrte Fall ist strikt: Ein /Differences-Name, der auf keine belegte Glyph-ID mappt, weist den ganzen Font ab, denn ein unaufgelöster Name bedeutet, dass die Domain unvollständig ist

procedure ReportFontSubset(Source: TStream; Log: TStrings);
var
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
  I: Integer;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Options.SubsetOptions.MinimumSavingPercent := 10;  // pro Font
  Options.MinimumDocumentSavingPercent := 5;         // ganze Datei
  if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
  begin
    Log.Add('Rejected: ' + Report.ErrorMessage);
    Exit;
  end;
  Log.Add(Format('Fonts %d, subset %d, preserved %d, glyphs %d',
    [Report.FontCount, Report.SubsetFontCount, Report.PreservedFontCount,
     Report.GlyphCount]));
  Log.Add(Format('Font bytes %d -> %d, file bytes %d -> %d',
    [Report.OriginalFontBytes, Report.SubsetFontBytes,
     Report.SourceByteCount, Report.OutputByteCount]));
  for I := 0 to High(Report.Issues) do
    Log.Add(Format('  object %d kept: %s',
      [Report.Issues[I].ObjectNumber, Report.Issues[I].Reason]));
end;

Was lässt SubsetPdfDocumentFonts in Ruhe?

SubsetPdfDocumentFonts lässt jeden Font in Ruhe, den es nicht belegen kann, und meldet jeden davon in Report.Issues mit Objektnummer und Grund, statt das Dokument scheitern zu lassen. Report.Complete ist True nur, wenn kein Font erhalten wurde. Die erhaltenen Fälle sollte man kennen, bevor man eine Größenersparnis verspricht:

  • Type-1-/FontFile und CFF- oder OpenType-/FontFile3-Programme, gemeldet als Only TrueType FontFile2 programs are supported
  • Identity-CIDFonts ohne /W, /W2 oder /CIDSet, und CIDFonts ohne Mapping-Stream und ohne /Identity
  • Font-Programme, auf die von irgendwo anders als ihren Font-Deskriptoren verwiesen wird, und Deskriptoren, die sich einen Konsumenten teilen, der nicht CIDFontType2 oder schlichtes TrueType ist
  • Fonts, deren Subset SubsetOptions.MinimumSavingPercent verfehlt, und jeder Font, wenn die neu gebaute Datei MinimumDocumentSavingPercent verfehlt; in dem Fall ist Report.Applied auf False und die Ausgabe sind die Originalbytes

Zwei Eingaben scheitern rundheraus, statt erhalten zu werden. Signierte Dokumente und Dokumente mit Signaturfeldern werden abgewiesen, denn der Ersatz von Font-Objekten würde den Bytebereich jeder Signatur ungültig machen, und verschlüsselte Dateien brauchen zuerst eine entschlüsselte Quelle. Ein erfolgreicher Lauf schreibt die Ersatzobjekte als Sparse-Update und baut die Datei dann durch denselben Erreichbarkeits-Durchlauf neu, der im Leitfaden zum Entfernen verwaister Objekte und Deduplizieren von Ressourcen beschrieben ist, sodass die alten Font-Programme nicht als unerreichbare Objekte überleben und das Ergebnis eine einzelne saubere Revision ist

Neuen Text beim Speichern subsetten mit saSubsetNewFonts

Fonts, die PDFium Component während der aktuellen Sitzung einbettet, sind ein eigener Fall, gehandhabt über die Save-Option saSubsetNewFonts statt über SubsetPdfDocumentFonts. saSubsetNewFonts mappt auf das PDFium-Flag FPDF_SUBSET_NEW_FONTS, das den nativen HarfBuzz-Subsetter über Fonts laufen lässt, die native Aufrufe wie TPdf.AddText aus dem System geladen haben, eingeschlossen OpenType-CFF-Schnitte. Upstream-PDFium etikettiert das Flag weiterhin als experimentell. Es berührt keine Fonts, die bereits in der Datei waren

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(0, 595, 842);
    Pdf.AddText('Quarterly statement', 'Arial', 14, 72, 770, clBlack, $FF, 0.0);
    if not Pdf.SaveAs('statement.pdf', saSubsetNewFonts) then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

In der Sonde der Komponente sank ein Dokument mit Arial-Text unter saSubsetNewFonts von 590 KB auf 22 KB, und die Textextraktion aus der gespeicherten Datei passte weiterhin exakt. Ein COLR-v0-Farbschrift — Segoe UI Emoji mit 7,0 MB — ging denselben Pfad auf 10,5 KB, gerendert als seine Basis-Outlines, da PDF keinen Begriff von COLR-Layern hat. Variable Fonts sind die Ausnahme, und der Block sitzt vor dem Subsetter: GDI-GetFontData lieferte einen 18,3-MB-Ersatzstream für die 1,85-MB-Datei von Segoe UI Variable, und die Erzeugung des Textobjekts scheiterte, bevor irgendein Subsetting begann. Der Durchgang durch das CID-Type-2-Font-Einbetten aus Delphi behandelt den Einbettungspfad, den diese Fonts zuerst durchlaufen

Wo der Beweis endet

Der Beweis in SubsetPdfDocumentFonts ist exakt so stark wie die Font-Metadaten, die der Produzent geschrieben hat. Ein Identity-CIDFont, dessen Beweis allein auf /W beruht, hat weiterhin einen blinden Fleck: eine Glyphe, deren Breite /DW entspricht, die keinen /ToUnicode-Eintrag hat und die der Descriptor in keiner /CIDSet auflistet. Eine solche Glyphe lässt sich aus den Ressourcen nicht belegen, und die Komponente kann nicht wissen, dass sie existiert — ohne den Content-Stream-Scan, den sie absichtlich vermeidet. Produzenten, die sowohl /ToUnicode als auch /CIDSet weglassen, während sie /W komprimieren, sind selten, aber es gibt sie; rendern Sie bei einem Archiv aus einem unbekannten Generator also eine Probe vorher und nachher und vergleichen Sie Pixel und extrahierten Text

Vor einem Batch hilft es außerdem zu wissen, womit man es zu tun hat. Der Ansatz in Lesen von PDF-Font-Eigenschaften mit PDFium Component verrät, welche Fonts überhaupt eingebettet sind, und Report.Issues aus einem Trockenlauf verrät, welche davon erhalten bleiben und warum. Die Default-Optionen begrenzen auch die Arbeit: 1024 Fonts, rund eine Million Glyphen insgesamt, 128 MB Quelle und ein festes Arbeitsbudget, mit optionalem CancellationToken für lange Batch-Jobs

FAQ

Ändert SubsetPdfDocumentFonts den Text oder das Layout des PDFs?

Nein. Original-Glyph-IDs, CIDs, Breiten und Content-Streams bleiben erhalten, Textextraktion und Rendering verwenden also dieselben Glyphen wie zuvor; nur unerreichbare Outlines werden entfernt und die Fontnamen bekommen ein neues Subset-Präfix

Warum wurde ein eingebetteter Font nicht subsettet?

Lesen Sie Report.Issues. Jeder erhaltene Font trägt einen Grund, etwa ein fehlendes /W oder /CIDSet an einem Identity-CIDFont, ein Nicht-TrueType-Programm, ein fehlerhaftes /Widths-Array oder eine Ersparnis unter der Schwelle

Ist saSubsetNewFonts dasselbe wie SubsetPdfDocumentFonts?

Nein. saSubsetNewFonts subsettet Fonts, die in der aktuellen Sitzung eingebettet wurden, beim Speichern, während SubsetPdfDocumentFonts auf einem bestehenden PDF-Stream arbeitet und Fonts ersetzt, die bereits von einem anderen Produzenten eingebettet worden waren

Dokument-Font-Subsetting, Save-time-Font-Subsetting und der dahinterstehende Orphan-Pruning-Durchlauf kommen alle mit dem PDFium Component for Delphi and C++Builder, das PDFium für Delphi-, C++Builder- und Lazarus-Projekte umhüllt