Geshapte Glyphen rendern als .notdef-Kästchen, wenn ein Font-Subsetter nur die von emittierten Codepunkten erreichbaren Glyphen behält. HotPDF, die native VCL-PDF-Komponente für Delphi und C++Builder, trug genau diesen Defekt bis Version 2.435.0: OpenType-GSUB-Ausgaben wurden in einer internen Nutzungs-Bitmap vermerkt, die der Subsetter zu beachten erklärte und dann nie tatsächlich las
Das ist ein anderer Fehler als der im EndDoc-Bug, der Font-Subsetting still deaktivierte beschriebene. Jener Bug betraf wann Subsetting relativ zur Serialisierung lief, und er deaktivierte Subsetting vollständig. Dieser hier betrifft was das Subset enthält, wenn Subsetting perfekt nach Plan läuft. Die Pipeline feuert zum richtigen Zeitpunkt, das sechsbuchstabige Subset-Präfix erscheint auf /BaseFont exakt so, wie ISO 32000-1 §9.6.4 es verlangt, die Datei wird kleiner, jede lateinische Seite prüft sauber, und eine arabische Seite kommt als Reihe leerer Rechtecke heraus. Ordnungsfehler sind laut, sobald man hinsieht. Closure-Fehler bleiben für immer still, weil das Subset strukturell gültig ist und nur bei seiner eigenen Mitgliederliste falschliegt
Warum rendern geshapte Glyphen als .notdef?
Weil die Menge der von einem Dokument emittierten Codepunkte nicht die Menge der Glyphen ist, die das Dokument zeichnet, und ein Subsetter, der beides verwechselt, jede durch Shaping erzeugte Glyphe verwirft. Text-Shaping wandelt eine logische Zeichenfolge in eine positionierte Glyphenfolge um, und sein ganzer Zweck ist es, Glyphen zu erzeugen, auf die kein einzelnes Eingabezeichen abbildet: ein arabisches mediales heh, eine fi-Ligatur, ein Devanagari-Konjunkt, eine über das rclt-Feature gewählte kontextuelle Alternative. Jede davon ist eine Glyph-ID, die ein GSUB-Lookup erzeugt hat, nicht eine, die die cmap-Tabelle für irgendein Zeichen der Zeichenkette liefert. Ein rein von der cmap getriebener Subsetter durchläuft daher den falschen Index. Er behält getreu jede Glyphe, die der Text vor dem Shaping hätte verwenden können, und verwirft genau die Glyphen, die der Text nach dem Shaping tatsächlich verwendet. Der Renderer fragt dann die eingebettete Schrift nach GID 1847, das Subset hat diesen Eintrag in loca genullt, und Glyphenindex 0 kommt stattdessen zurück. Glyphenindex 0 ist per OpenType-Definition .notdef, weshalb die Fehlersignatur ein leeres Kästchen ist statt eines falschen Buchstabens oder eines Absturzes. Am PDF selbst ist nichts fehlerhaft; die Schrift enthält schlicht nicht die Glyphe, nach der der Content-Stream gefragt hat
Codepunkte sind keine Glyphen: die drei Quellen eines Subsets
Eine korrekte Subset-Closure muss drei unabhängige Quellen vereinigen, jede mit ihrem eigenen Akkumulator. Die erste ist die von Codepunkten abgeleitete Menge: HotPDF akkumuliert FUnicodeUsedCps, während BMP-Zeichen emittiert werden, und FUnicodeSmpUsed für über Surrogate-Paare erreichte Zeichen der Zusatzebenen, dann bildet es jedes über FUnicodeCpToGid auf eine Glyph-ID ab. Die zweite ist die vom Shaping abgeleitete Menge, die Glyph-IDs, die eine GSUB-Substitution erzeugt hat, vermerkt über MarkUnicodeGlyphUsed und EnableShapingFeatureForSubset in FUnicodeExtraUsedGlyphs. Die dritte ist die Composite-Closure: Eine Glyphe, deren numberOfContours in glyf gleich -1 ist, wird aus Komponenten-Glyph-IDs zusammengesetzt, und das Composite zu behalten, während man seine Komponenten verwirft, ergibt eine leere Kontur statt eines .notdef, was arguably schlimmer ist, weil es wie ein Abstandsfehler wirkt
HotPDF hat die erste und dritte immer behandelt. BuildAndApplyUnicodeFontSubset, der Subsetting-Einstiegspunkt, den EndDoc vor der Serialisierung aufruft, sät das Array genutzter Glyphen mit GID 0, durchläuft die BMP-Codepunkte, durchläuft die SMP-Nutzungsliste und übergibt das Array an einen Subset-Builder, der Composite-Komponenten intern auflöst. Die zweite Quelle wurde geschrieben, aber nie konsumiert, und weil die drei Quellen bei unterschiedlichen Inhalten versagen, kann sich die Lücke jahrelang in einer Codebasis verstecken, deren Regressionskorpus überwiegend Latein ist
Das Array, das geschrieben und nie gelesen wurde
Der Vertrag war an drei Stellen dokumentiert und an keiner davon eingehalten. Die Deklaration von FUnicodeExtraUsedGlyphs besagte, dass der EndDoc-Subsetter es mit der von Codepunkten abgeleiteten Nutzung vereinigt; der Header-Kommentar auf ApplyArabicGSUBRefinement versprach, dass jede emittierte Ersatz-GID durch MarkUnicodeGlyphUsed geleitet wird, sodass der Subsetter die Glyphe in die eingebettete Schrift zieht; dasselbe Versprechen erscheint wortgleich auf ApplyArabicGSUBContextualRefinement für den rclt-Pfad. Beide Aufrufer hielten ihre Hälfte ein. Ein Grep über jede Referenz auf das Feld klärte die andere Hälfte in etwa neunzig Sekunden: eine Deklaration, eine SetLength-Zuweisung innerhalb von RegisterUnicodeTTF und Schreibvorgänge in den beiden Markierungsroutinen. Kein einziger Lesevorgang. Das ist die Diagnose, die man sich einprägen sollte, denn sie verallgemeinert weit über Schriften hinaus. Wird ein Feld von mehreren Aufrufstellen beschrieben und von keiner gelesen, existiert das Feature, das es repräsentiert, nicht, so gründlich es auch kommentiert ist. Schritt 1 des Subsetters ist klein genug, um auf einen Bildschirm zu passen, und die Lücke ist offensichtlich, sobald man weiß, wonach man suchen muss
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
Der eine Schleifen-Fix, und Glyphen selbst markieren
Die Reparatur ist eine Vereinigung, und ihr Sicherheitsargument ergibt sich aus der Richtung der Operation: Sie setzt nur Bits, löscht nie welche, sodass keine Glyphe, die das Subset zuvor überlebt hat, plötzlich verworfen werden kann
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
Drei Eigenschaften machen dies zu einer risikoarmen Änderung statt einer Neufassung der Font-Engine. Sie ist monoton, wie oben. Sie ist ein No-Op bei Schriften, die nie etwas geshapt haben, da FUnicodeExtraUsedGlyphs durchgehend False bleibt und die Byte-Ausgabe für ein rein lateinisches Dokument unverändert bleibt. Und sie landet vor Schritt 2, sodass beide Subset-Builder sie erben: der sparse Builder, der die ursprüngliche GID-Nummerierung erhält, und der kompakte Builder _BuildCompactSubsetTTF, den HotPDF unter PDF/A wählt, um behaltene Glyphen in einen dichten Bereich umzunummerieren, maxp.numGlyphs zu verkleinern und das Alt-zu-Neu-Mapping als den von ISO 32000-1 §9.7.4.2 geforderten /CIDToGIDMap-Stream auszugeben. Beide rufen intern _TTFWalkCompositeClosure auf, sodass eine geshapte Glyphe, die zufällig ein Composite ist, nun auch ihre Komponenten mit hineinzieht. Die Composite-Closure war nie kaputt; sie wurde für diese Glyph-IDs nur nie erreicht, weil die Glyph-IDs nicht in der Menge lagen, die sie durchläuft. Steuert man die GSUB-Engine direkt an, statt sich auf die eingebauten Refinement-Pässe zu verlassen, wird Closure zur eigenen Verantwortung, und jede emittierte Ersatz-Glyph-ID muss markiert werden, bevor EndDoc die Menge genutzter Glyphen einfriert
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset ist das Batch-Gegenstück zum Einzel-GID-Aufruf, und es ist bewusst konservativ. Es durchläuft die GSUB-Lookup-Liste nach den Lookups, die unter dem aktuell gewählten Skript- und Sprachpfad an ein vier Zeichen langes Feature-Tag gebunden sind, und markiert die Ersatz-Glyph-IDs, die diese Lookups erzeugen können. Es ist ein defensives No-Op, wenn die Schrift keine GSUB-Tabelle trägt oder das Feature auf diesem Pfad fehlt, sodass ein unbedingter Aufruf sicher ist. Es ist auch bewusst eine Überapproximation: Es kann Glyphen behalten, die ein gegebenes Dokument nie zeichnet. Beim Subsetting kostet Über-Inklusion Bytes, und Unter-Inklusion kostet Korrektheit, was diesen Tausch zu einem leichten macht. Die Struktur dieser Lookups und der Coverage-Tabellen, die entscheiden, welche Glyphen teilnehmen, wird im Durchgang zu GSUB-Stilalternativen in purem Delphi behandelt
Wie beweist man, dass die Glyphe tatsächlich im Subset ist?
Indem man die emittierte Schrift liest, nicht indem man die Seite in einem Viewer betrachtet, der heimlich eine Systemschrift ersetzen könnte. Die Prüfung, die diese ganze Fehlerklasse abfängt, ist mechanisch: den /FontFile2-Stream aus dem Ausgabe-PDF extrahieren, loca parsen und bestätigen, dass die erwartete Glyph-ID einen nicht-leeren Eintrag trägt, das heißt, ihre Start- und Endoffsets sich unterscheiden. Ein leerer Eintrag bedeutet, dass der Subsetter die Glyphe als ungenutzt eingestuft hat. Zwei Gewohnheiten machen es dann erheblich schwerer, den Fehler erneut auszuliefern. Eine Seite mit geshaptem Schriftsystem im automatisierten Smoke-Korpus zu halten statt nur im manuellen Proofing-Set, denn Arabisch, Devanagari und Khmer beanspruchen Closure-Pfade, die kein Ausmaß an lateinischer Abdeckung berührt. Und wann immer ein Akkumulator existiert, sicherzustellen, dass ihn etwas konsumiert, denn ein nur beschriebenes Feld ist ein Feature, das kompiliert, im falschen Korpus grün testet und nichts tut
Wo der Fix endet
Subset-Closure ist notwendig, damit eine geshapte Glyphe rendert, und sie ist nicht hinreichend. Die Glyphe muss auch aus dem Content-Stream adressierbar sein, ein separates Problem mit einer eigenen Grenze. Die eingebauten arabischen Refinement-Pässe von HotPDF committen eine Substitution nur, wenn jede Ersatz-Glyph-ID über einen Unicode-Präsentationsform-Codepunkt via einem Rückwärts-cmap-Scan über rund 690 Codepunkte in U+FB50 bis U+FDFF und U+FE70 bis U+FEFF erreichbar ist. Landet eine Substitution auf einer Glyph-ID außerhalb dieses Bereichs, passiert das Eingabefenster unverändert, statt etwas auszugeben, das der Reader nicht adressieren kann; schriftspezifische Alternativen an beliebigen Glyph-IDs benötigen einen synthetischen Private-Use-Codepunkt aus U+E000 bis U+F8FF, um sie durch den Emit-Pfad zu tragen. Die ehrliche Zusammenfassung ist also, dass der Fix in 2.435.0 einen harten Blocker entfernt hat, statt die Geschichte zu vervollständigen. Davor konnte eine Glyphe korrekt geshapt, korrekt emittiert werden und dennoch beim Subsetting verschwinden, was bedeutete, dass der Shaping-Engine end-to-end nicht vertraut werden konnte, wie gut ihre Lookups auch waren. Was bleibt, ist Adressierbarkeit, und diese Einschränkung schlägt wenigstens sichtbar am Punkt der Emission fehl statt still in einem Build-Schritt, der nach allem läuft, was man beobachtet hat. Für die Emit-Seite derselben Pipeline siehe den Leitfaden zu arabischem und RTL-Text-Shaping in Delphi-PDFs
Das hier beschriebene Font-Subsetting, die GSUB-Engine und das Shaping komplexer Schriftsysteme sind Teil der Standard-HotPDF Component für Delphi und C++Builder; die Produktseite führt die vollständige API-Referenz für die oben genannten Unicode-Font- und Shaping-Aufrufe