Technischer Artikel

Fehlende PDF-Glyphen zur Zeichenzeit in Delphi erkennen

Eine fehlende Glyphe in einem PDF ist kein Fehler. Der Producer fragt ein Zeichen an, das die gewählte Schrift nicht abbilden kann, die Schrift gibt Glyphenindex null zurück, und die herauskommende Datei ist strukturell gültig, öffnet überall und zeigt eine leere Box, wo ein Name oder ein Betrag stehen sollte. Niemand in der erzeugenden Pipeline erfährt es. Der Empfänger schon. HotPDF schließt diese Schleife mit TrackUnresolvedGlyphs: Aktiviert man es, zeichnet der Textzeichnungspfad jeden Codepoint auf, dessen Glyphenauflösung auf Index null läuft, und feuert OnUnresolvedGlyph einmal je einzigartigem Fund mit dem Codepoint, der Schrift, an der er scheiterte, dem Skript, zu dem er gehört, und einem Vorschlag von Schriften, die ihn abdecken würden

Die Erkennung ist die halbe Antwort. Die andere Hälfte ist SetFontFallbackChain, das eine geordnete Schriftliste je Skript registriert, sodass die üblichen Fälle sich selbst auflösen und nur echte Lücken Ihren Handler erreichen. Zusammen machen sie aus einer Fehlerklasse, die früher von Kunden gemeldet wurde, eine Prüfung zur Build-Zeit

Warum löst eine fehlende Glyphe nichts aus?

Weil ISO 32000 einem Producer keine Pflicht auferlegt, die Abdeckung zu verifizieren, und Glyphenindex null eine legitime Glyphe ist. Es ist .notdef, deren Kontur der Schriftdesigner wählt: meist ein leeres oder hohles Rechteck, manchmal gar nichts. Ein Viewer, der sie zeichnet, verhält sich korrekt. Die Textextraktion liefert womöglich sogar die richtigen Zeichen, denn die /ToUnicode-Zuordnung wird aus dem Quelltext geschrieben, nicht aus den Konturen, also besteht eine automatisierte Round-Trip-Prüfung fröhlich ein Dokument, dessen sichtbarer Text Löcher hat

Diagramm, warum eine fehlende PDF-Glyphe still bleibt: Glyphe null zeichnet eine leere Box, während die ToUnicode-Extraktion Round-Trip-Prüfungen besteht
Glyphe null ist eine legitime .notdef-Antwort, und /ToUnicode wird aus dem Quelltext geschrieben, also erfährt nichts in der Pipeline von der Lücke

Die praktische Konsequenz: Die Abdeckung muss im Moment des Zeichnens geprüft werden, wenn die Bibliothek noch weiß, welcher Codepoint angefragt wurde und welche Glyphe die Schrift tatsächlich angeboten hat. Danach ist die Information weg

Der Detektor muss auf den Subset-Zustand schauen, nicht auf den Gerätekontext

Hier ging die erste Implementierung schief, und der Grund ist es wert, verstanden zu werden, denn er gilt für jede Abdeckungsprüfung, die an eine Textpipeline angeflanscht wird. HotPDF hat zwei Textpfade. Der eine emittiert über eine registrierte Unicode-TrueType-Schrift mit einer zur Registrierungszeit aufgebauten Charakterzuordnung im Speicher. Der andere ist ein Legacy-GDI-Pfad, der je Zeichendurchlauf einen frischen Gerätekontext und ein Schrift-Handle erzeugt

Die Abdeckung am GDI-Pfad zu beurteilen ist hoffnungslos. Seine Zuordnung ist nicht die Zuordnung, die im emittierten Content-Stream landet, und die beiden sind nicht synchronisiert, also meldet ein Detektor, der GDI-Ergebnisse liest, den gesamten druckbaren ASCII-Bereich als unaufgelöst. Die autoritative Antwort lebt in der registrierten Schrift: der Charakterzuordnung, die RegisterUnicodeTTF parst, abgefragt über GetUnicodeGlyphForCodepoint. Der Detektor ist deshalb an den Subset-bereit-Zustand gekoppelt, nicht an irgendeine GDI-Bedingung, und er läuft schlicht nicht auf Dokumenten, die nie eine Unicode-Schrift registrierten, was korrekt ist, denn diese Dokumente sind ohnehin auf die Standardkodierungen beschränkt

Eine zweite Falle sitzt daneben. Der GDI-Familienname einer Schrift und der zur Registrierungszeit aus der Schrift-Binärdatei extrahierte PostScript-Name sind unterschiedliche Zeichenketten, und zwar nicht in einer Weise, die man normalisieren könnte: Eine Familie namens Arial Unicode MS trägt den PostScript-Namen ArialMT. Jede Sperre, geschrieben als „Ist die aktuell gewählte Schrift die, die wir registriert haben“, verglichen über Namen, ist toter Code, der nie feuert. Auf Zustand koppeln, nie auf Schrift-Namen

Der Ablauf der HotPDF-Erkennung unaufgelöster Glyphen: die Subset-Zustands-Sperre, die GetUnicodeGlyphForCodepoint-Abfrage und die OnUnresolvedGlyph-Event-Verdrahtung
Die Abdeckung wird aus der registrierten Unicode-Schriftzuordnung beurteilt statt aus GDI, und jeder einzigartige Codepoint feuert ein Event mit einem Schriftvorschlag

Einen Glyphen-Detektor nicht mit Emoji testen

Der naheliegende Testfall ist ein lächelndes Gesicht, und er wird Sie überzeugen, dass der Detektor kaputt ist. Übliche Emoji-Codepoints in den Astralebenen laufen über einen Private-Use-Synthesepfad auf, der sie direkt auf einen Glyphenindex abbildet, also erreichen sie nie den allgemeinen Abdeckungszweig. Der Detektor verhält sich korrekt, und der Test misst den falschen Pfad

Nehmen Sie stattdessen einen nicht zugewiesenen Codepoint. U+0378 ist in Unicode dauerhaft nicht vergeben, also kann keine Schrift ihn legitim abbilden, und er durchläuft exakt den Zweig, den Sie verifizieren wollen. Diese Unterscheidung zwischen „Das Feature ist kaputt“ und „Der Test wählte eine Eingabe, die das Feature umgeht“ kostet echte Stunden, und nicht zugewiesene Codepoints sind der billigste Weg, sie zu vermeiden

type
  TCoverageAudit = class
  private
    FFindings: TStringList;
  public
    procedure Handle(Sender: TObject;
      const Info: THPDFUnresolvedGlyphInfo);
    property Findings: TStringList read FFindings;
  end;

procedure TCoverageAudit.Handle(Sender: TObject;
  const Info: THPDFUnresolvedGlyphInfo);
begin
  // Feuert einmal je einzigartigem Codepoint, nicht einmal je Vorkommen
  FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
    [Info.CodePoint, String(Info.FontName), Ord(Info.Script),
     String(Info.SuggestedFonts)]));
end;

// Eingezogen in einen erzeugenden Job
Pdf := THotPDF.Create(nil);
try
  Pdf.TrackUnresolvedGlyphs := True;
  Pdf.OnUnresolvedGlyph := Audit.Handle;
  Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\arial.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(50, 720, 0, CustomerName);
  Pdf.EndDoc;
  if Audit.Findings.Count > 0 then
    // Den Job scheitern lassen, statt eine Seite voller Boxen auszuliefern
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

Fallback-Ketten gelten je Skript, nicht je Schrift

Der Grund, warum Fallback je Skript statt je Quellschrift abgegrenzt ist: Abdeckungslücken häufen sich nach Schriftsystem. Eine lateinische Textschrift fehlen Devanagari, Thai, Han und Emoji, alles auf einmal, und der Ersatz für jedes davon ist eine andere Schrift. Eine Kette je Skript zu deklarieren beschreibt also das reale Deployment: eine lateinische Schrift für Fließtext, eine CJK-Schrift, eine Emoji-Schrift, ein Sammelbecken

Diagramm des Font-Fallbacks je Skript: Die Skripte hfsCJK, hfsArabic, hfsEmoji und hfsOther sind auf geordnete Ersatz-Schriftketten in HotPDF abgebildet
Jedes Skript bekommt seine eigene geordnete Kette, also fällt eine lateinische Fließtextschrift ohne Han, Arabisch oder Emoji auf eine Schrift durch, die es abdeckt
// THPDFFontScript umfasst hfsCommon, hfsLatin, hfsGreek, hfsCyrillic,
// hfsHebrew, hfsArabic, hfsIndic, hfsSoutheastAsian, hfsCJK, hfsKana,
// hfsHangul, hfsEmoji und hfsOther
Pdf.SetFontFallbackChain(hfsCJK,
  ['Microsoft YaHei', 'SimSun', 'Yu Gothic']);
Pdf.SetFontFallbackChain(hfsArabic, ['Segoe UI', 'Arial']);
Pdf.SetFontFallbackChain(hfsEmoji, ['Segoe UI Emoji']);
Pdf.SetFontFallbackChain(hfsOther, ['Arial Unicode MS']);

Fallback und Erkennung sind komplementär statt alternativ. Ketten behandeln die Abdeckung, die Sie antizipiert haben; der Detektor meldet die Abdeckung, die Sie nicht antizipiert haben, was auf einem System, das beliebige Kundendaten verarbeitet, die interessante Hälfte ist. Beachten Sie, dass der Ersatz einer Schrift die Metriken ändert, also kann ein Absatz, der zurückfällt, umfließen; wenn das Layout zählt, lohnt sich ein Blick auf das Closure- und Subsetting-Verhalten der Ersatzschrift in dem Artikel zur Font-Subset-Closure, und Skripte, die Umordnung oder Verbundung brauchen, behandelt die Shaping-Stufe, beschrieben in Complex-Script-Text-Shaping

Wie man Verhalten nachrüstet, ohne den bestehenden Pfad zu riskieren

Dieselbe Release ergänzte einen Legacy-kern-Table-Fallback für Paarabstände, und die Art, wie er abgegrenzt wurde, ist ein Muster zum Nachahmen. Statt einen neuen Entscheidungspunkt in die Kerning-Logik einzubauen, hängt der Fallback an dem Early-Exit-Zweig, der bereits für Schriften ohne GPOS-Tabelle existierte. Eine moderne Schrift mit GPOS erreicht ihn nie, also ist ihr Verhalten von Konstruktion an unverändert statt durch Tests. Pfade, die keine Unicode-Schrift registrieren, produzieren zwei Null-Offsets, also sind auch sie unverändert

Das ist die allgemeine Gestalt eines risikoarmen Nachrüstens in einer ausgereiften Rendering-Bibliothek: den Zweig finden, der derzeit nichts produziert, und das neue Verhalten dort ablegen. Es verwandelt „Wir glauben, das hat nichts verschlechtert“ in „Das kann nichts verschlechtert haben“, was eine viel bessere Aussage über eine Text-Engine ist, durch die die Rechnungen anderer Leute laufen

Machen Sie daraus eine Sperre, kein Log

Abdeckungsfunde sind nur nützlich, wenn etwas an ihnen scheitert. In einem dokumenterzeugenden Dienst ist die produktive Anordnung, das Tracking im nächtlichen Regressionjob gegen ein Korpus echter Kundennamen, Adressen und Produktbeschreibungen eingeschaltet zu lassen und den Job bei jedem Fund scheitern zu lassen. Weil das Event einmal je einzigartigem Codepoint feuert statt einmal je Vorkommen, bleibt die Ausgabe klein genug zum Lesen, selbst wenn ein ganzes Skript fehlt

In der Produktion ist derselbe Handler besser als Telemetrie genutzt: Codepoint und Schrift aufzeichnen, das Dokument weiter ausliefern und die Aggregation sagen lassen, welches Skript als Nächstes in den Deployment-Schriftsatz gehört. Das Rendering-Verhalten für eingebettete und ersetzte Schriften behandelt weitergehend Rendering eingebetteter Schriftglyphen, und die vollständige Property-Liste einschließlich TrackUnresolvedGlyphs ist auf der Produktseite der HotPDF Delphi PDF-Komponente dokumentiert