Technical Article

Der EndDoc-Fehler, der das Font-Subsetting lautlos deaktivierte

Erstellen Sie einen Bericht, betten Sie eine TrueType-Schriftart ein, und die Ausgabe öffnet sich korrekt in jedem Viewer, den Sie ausprobieren. Die Glyphen stimmen, der Text ist auswählbar, die Datei ist gültig. Das Einzige, was nicht stimmt, ist die Größe. Ein Dokument, das ein paar Dutzend lateinische Zeichen verwendet, trägt die gesamte 350 KB große Schriftart. Ein Dokument, das einen Absatz auf Chinesisch druckt, trägt eine 14 MB große CJK-Schriftart anstelle des benötigten halben Megabytes. Es wurde keine Ausnahme ausgelöst, keine Warnung protokolliert und die Datei hat die Validierung bestanden. So sieht ein fehlerhaft geordneter Finalisierungsschritt von außen aus: Nichts schlägt fehl, und der einzige Beweis ist eine Zahl, die zu groß ist

Der Fehler, der dies verursacht hat, existierte in HotPDF für eine Release-Linie und wurde inzwischen behoben. Es lohnt sich, dies nicht als Mängelrüge, sondern als Lektion aufzuschreiben, da die Form des Fehlers allgemeiner Natur ist. Jede Dokumenten-Engine verfügt über eine Finalisierungsphase, die Objekte unmittelbar vor dem Schreiben verändert, und die Korrektheit dieser Phase hängt vollständig von der Reihenfolge ihrer Schritte im Verhältnis zur Serialisierung ab. Wenn ein Schritt auf der falschen Seite des Schreibvorgangs landet, bewirkt er einfach nichts, und zwar lautlos

Was das Font-Subsetting eigentlich tun soll

Eine Untergruppe (Subset) einer Schriftart ist der Teil einer TrueType-Datei, den ein Dokument tatsächlich verwendet. ISO 32000-1 §9.9 beschreibt, wie ein eingebettetes Schriftprogramm in einem Stream liegt, auf den der Schriftart-Deskriptor verweist, und für ein TrueType-Programm ist dieser Stream /FontFile2 mit einer /Length1, die die unkomprimierte Byte-Anzahl angibt. Das Subsetting schreibt die Tabellen glyf und loca so um, dass sie nur die vom Dokument referenzierten Glyphen enthalten, nummeriert die Glyphen-IDs neu und stellt dem Namen /BaseFont ein Präfix aus sechs Buchstaben wie ABCDEF+ voran, um die Schriftart als Subset zu kennzeichnen, genau wie es die Spezifikation verlangt. Eine lateinische Schriftart, die auf zehn oder fünfzehn Kilobyte reduziert wird, macht den Unterschied zwischen einem schlanken PDF und einem PDF aus, das eine ganze Schriftart für eine einzige Überschrift mitliefert

Der Zeitpunkt, an dem dies geschieht, ist entscheidend. Subsetting ist keine Transformation, die Sie auf bereits auf der Festplatte befindliche Bytes anwenden. Es bearbeitet den Objektgraphen im Speicher: Es verkleinert den Inhalt des /FontFile2-Streams, korrigiert /Length1 und schreibt den String /BaseFont um. All das muss vorhanden sein, wenn der Serialisierer den Graphen durchläuft und Bytes ausgibt. Wenn die Bearbeitungen erst nach dem Schreiben der Bytes erfolgen, aktualisieren sie Objekte, die niemand jemals lesen wird

Das Symptom und warum keine Fehlermeldung ausgegeben wurde

Das gemeldete Verhalten waren vollständige Schriftarten in der Ausgabe ohne jegliche Diagnose. Ein Benutzer, der eine Unicode-TrueType-Schriftart registrierte und ein normales Dokument erstellte, stellte fest, dass das eingebettete Schriftartobjekt dieselbe Länge wie die ursprüngliche .ttf-Datei hatte und dass der Name /BaseFont kein sechsstelliges Subset-Präfix trug. Die Ausgabe schrumpfte nie zwischen Durchläufen, die zehn Glyphen verwendeten, und solchen, die zehntausend verwendeten

Das Fehlen jeglicher Fehlermeldungen macht diese Art von Fehlern so kostspielig. Eine Subsetting-Routine, die zum falschen Zeitpunkt ausgeführt wird, läuft trotzdem. Sie durchläuft die gesammelte Codepoint-Nutzung, erstellt ein absolut korrektes Subset und wendet es auf den Objektgraphen im Speicher an. Intern ist die Arbeit erledigt und der Aufruf wird sauber beendet. Das Einzige, was nicht stimmt, ist, dass der bearbeitete Objektgraph nicht mehr derjenige ist, der geschrieben wird, da der Schreiber bereits fertig war. Aus Sicht des Aufrufers wurde das Dokument ohne Zwischenfälle erstellt und gespeichert, was genau den Eindruck eines lautlosen Fehlers erweckt

Die Ursache war die Reihenfolge der Finalisierung

In HotPDF finden die Abschlussarbeiten innerhalb von EndDoc statt. Der Subsetting-Schritt ist eine interne Routine namens BuildAndApplyUnicodeFontSubset. Es liest die pro Dokument verwendete Menge an Codepoints, die in einer Bitmap gespeichert wird, die beim Zeichnen von Glyphen gefüllt wird. Sie ordnet jeden verwendeten Codepoint über die zwischengespeicherte Codepoint-zu-Glyphe-Tabelle einer echten Glyphen-ID zu und schreibt das Schriftprogramm entsprechend um. Wenn eine Unicode-TrueType-Schriftart registriert wird, setzt der Ausgabepfad ein Bit im Satz der verwendeten Codepoints für jedes gezeichnete Zeichen, sodass die Engine beim Schließen des Dokuments genau weiß, welche Glyphen das Subset behalten muss

Der Fehler bestand darin, dass BuildAndApplyUnicodeFontSubset aufgerufen wurde, nachdem SaveToStream oder SaveToFile das Dokument bereits serialisiert hatten. Die Änderungen des Subsetters an /FontFile2, seine korrigierte /Length1 und das sechsstellige /BaseFont-Präfix werden alle für einen Objektgraphen berechnet, der bereits in Bytes umgewandelt worden war. Die Behebung war eine einzeilige Umstellung: Verschieben des Subset-Aufrufs vor die Serialisierung, sodass der Schreiber die subsetierte Schriftart anstelle der ursprünglichen ausgibt. Die korrigierte Sequenz führt zuerst den Subsetter aus und serialisiert danach

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\\Fonts\\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

Mit korrigierter Reihenfolge ändert sich nichts am aufrufenden Code. Das Subsetting ist standardmäßig aktiviert, sobald eine Unicode-TrueType-Schriftart registriert wurde. Sie registrieren die Schriftart, beginnen das Dokument, zeichnen und beenden es, und das Subset wird aus den von Ihnen verwendeten Glyphen erstellt, bevor die Bytes den Speicher verlassen

Warum ein einziger falsch platzierter Schritt eine ganze Kategorie darstellt

Der Grund, warum dies eine Lektion und keine Fußnote wert ist, liegt darin, dass EndDoc eine Liste von Abschlussschritten ausgibt, von denen jeder einzelne empfindlich auf seine Position relativ zum Schreibvorgang reagiert. Das Font-Subsetting ist einer davon. Die PDF/A-Ausgabe erfordert einen /CIDSet-Stream, der genau die im Subset vorhandenen Glyphen-IDs auflistet - eine Einschränkung, die ISO 19005 auferlegt, damit ein Validator bestätigen kann, dass das eingebettete Programm mit den Angaben des Schriftart-Deskriptors übereinstimmt. Dieser Stream wird im selben Finalisierungsfenster ausgegeben und hängt davon ab, dass das Subset zuerst erstellt wurde. PDF/UA-1 erfordert gemäß ISO 14289-1 §7.18.3, dass jede Seite, die eine Anmerkung trägt, /Tabs mit dem Wert /S deklariert, und eine interne Routine namens EnsurePDFUATabsOnAnnotatedPages stempelt diesen Schlüssel während derselben Phase auf. Dort laufen auch Kontrollen zur Ausgabeabsicht (Output Intents)

Derselbe Sortierfehler, der das Subsetting deaktivierte, verwarf auch den PDF/UA-Tabulatorreihenfolgen-Schlüssel auf kommentierten Seiten, da dieser Schritt auf derselben falschen Seite des Schreibvorgangs lag. veraPDF und PAC melden ein fehlendes /Tabs /S als Verletzung des Matterhorn-Protokoll-Prüfpunkts 21-001. Ein einziger falsch platzierter Aufruf hat also nicht nur die Dateigröße aufgebläht, sondern gleichzeitig stillschweigend eine Anforderung an die Barrierefreiheit verletzt, ohne dass ein Fehler gemeldet wurde. Das ist die Gefahr einer Finalisierungsphase: Ihre Schritte teilen sich eine Vorbedingung, und ein einziger Reihenfolgefehler kann mehrere von ihnen gleichzeitig außer Kraft setzen, während jeder Aufruf weiterhin Erfolg zurückgibt

Wie ein lautloser Ausgabefehler tatsächlich erkannt wird

Ein Fehler, der keine Ausnahme auslöst, wird nicht durch Ausführen des Programms erkannt. Er wird erkannt, indem die Ausgabe überprüft und mit dem verglichen wird, was die Eingabe hätte erzeugen sollen. Für das Font-Subsetting sind die Prüfungen konkret. Vergleichen Sie die Ausgabedateigröße mit einer groben Erwartung: Ein Dokument, das nur eine Handvoll Glyphen verwendet, sollte nicht die Größe einer vollständigen Schriftart haben. Öffnen Sie das eingebettete Schriftartobjekt und lesen Sie dessen Bytelänge; ein subsetiertes /FontFile2 für eine lateinische Schriftart ist ein kleiner Bruchteil der Quelldatei. Lesen Sie den Namen /BaseFont und bestätigen Sie, dass das sechsstellige Präfix vorhanden ist, da dessen Fehlen ein direktes Signal dafür ist, dass kein Subset angewendet wurde

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\\Fonts\\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

Für die PDF/A-Ausgabe ist die Prüfung noch strenger, da ein Validator die Arbeit für Sie erledigt. Legen Sie die Konformitätsstufe fest und führen Sie das Ergebnis durch veraPDF aus: Ein fehlendes /CIDSet oder ein Subset, das nicht mit dem Deskriptor übereinstimmt, wird als fehlgeschlagene Klausel gemeldet und bleibt nicht Ihrer visuellen Prüfung überlassen. Die Konformitätsschalter, die diese Finalisierungsarbeit steuern, sind Eigenschaften des Dokuments. PDFACompliance akzeptiert einen String wie '2B' für PDF/A-2 Level B, und PDFUACompliance is ein Boolean, der die Anforderungen für getaggtes PDF und die Tabulatorreihenfolge einschaltet

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  Pdf.RegisterUnicodeTTF('C:\\Fonts\\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

Die Lektion für Entwickler

Daraus ergeben sich zwei Regeln. Die erste ist, dass jeder Finalisierungsschritt, der Objekte verändert, vor der Serialisierung dieser Objekte ausgeführt werden muss, und dass die Abschlussphase einer Dokumenten-Engine als eine geordnete Pipeline verstanden werden sollte, in der die Serialisierung die letzte Aktion ist und nicht nur eine Aktion unter vielen. Die zweite Regel hat hier am meisten Zeit gekostet: Bei einem Ausgabeschritt ist das Fehlen eines Fehlers kein Beweis für den Erfolg. Eine Routine, die das richtige Subset erstellt und auf den falschen, bereits geschriebenen Graphen anwendet, meldet keinen Fehler, da aus ihrer eigenen Sicht alles korrekt war. Die Überprüfung muss das Artefakt betrachten, nicht den Rückgabecode. Überprüfen Sie die Ausgabegröße, lesen Sie die Bytelänge der eingebetteten Schriftart und ihr /BaseFont-Präfix und lassen Sie veraPDF die PDF/A-Ausgabe beurteilen, bei der ein fehlendes /CIDSet ein lautloses Defizit in einen konkreten Fehler verwandelt

Die Erstellerseite der Schriftarthandhabung, also wie Schriftarten registriert und für die Berichtsausgabe eingebettet werden, wird in unserem Artikel über Schriftarten und Bilder in der Berichtsausgabe behandelt. Die Validierungsseite, auf der diese Finalisierungsschritte mit den Standards abgeglichen werden, wird in der Anleitung zur PDF/A- und PDF/UA-Validierung beschrieben. Beide ergänzen sich mit der hier beschriebenen Subsetting- und Konformitätsarbeit, die als Teil der HotPDF-Komponente für Delphi und C++Builder zusammen mit den APIs zum Laden, Bearbeiten, Verschlüsseln und Signieren ausgeliefert wird, die an anderer Stelle auf diesem Blog behandelt werden