Geshapete glyphs renderen als .notdef-boxen wanneer een font-subsetter alleen de glyphs behoudt die bereikbaar zijn vanaf uitgezonden code points. HotPDF, het native VCL PDF-component voor Delphi en C++Builder, droeg precies dat defect tot en met versie 2.435.0: OpenType-GSUB-output werd vastgelegd in een interne usage-bitmap die de subsetter beweerde te zullen respecteren en vervolgens nooit daadwerkelijk las
Dit is een andere fout dan die beschreven in de EndDoc-bug die font subsetting stilzwijgend uitschakelde. Die bug ging over wanneer subsetting draaide ten opzichte van serialisatie, en schakelde subsetting volledig uit. Deze gaat over wat de subset bevat wanneer subsetting perfect op schema draait. De pipeline vuurt op het juiste moment, het zesletterige subset-prefix verschijnt op /BaseFont precies zoals ISO 32000-1 §9.6.4 vereist, het bestand wordt kleiner, elke Latijnse pagina proeft schoon, en een Arabische pagina komt eruit als een rij lege rechthoeken. Volgordebugs zijn luid zodra je kijkt. Closure-bugs blijven voor altijd stil, omdat de subset structureel geldig is en alleen fout is over zijn eigen lidmaatschapslijst
Waarom renderen geshapete glyphs als .notdef?
Omdat de verzameling code points die een document uitzendt niet de verzameling glyphs is die het document tekent, en een subsetter die de twee door elkaar haalt, laat elke door shaping geproduceerde glyph vallen. Text shaping zet een logische tekenreeks om in een gepositioneerde glyphreeks, en het hele doel ervan is glyphs te produceren waar geen enkel invoerteken naar mapt: een Arabische mediale heh, een fi-ligatuur, een Devanagari-conjunct, een contextueel alternatief geselecteerd door de rclt-feature. Elk daarvan is een glyph-ID vervaardigd door een GSUB-lookup, niet een die de cmap-tabel je geeft voor enig teken in je string. Een subsetter die puur gestuurd wordt door de cmap loopt dus de verkeerde index af. Hij behoudt trouw elke glyph die de tekst vóór shaping had kunnen gebruiken en verwerpt precies de glyphs die de tekst ná shaping gebruikt. De renderer vraagt de embedded font vervolgens om GID 1847, de subset heeft dat item in loca op nul gezet, en glyph-index 0 komt in plaats daarvan terug. Glyph-index 0 is .notdef volgens de OpenType-definitie, wat is waarom de faalsignatuur een lege box is in plaats van een verkeerde letter of een crash. Niets in de PDF is misvormd; de font bevat simpelweg niet de glyph waar de content stream om vroeg
Code points zijn geen glyphs: de drie bronnen van een subset
Een correcte subset closure moet drie onafhankelijke bronnen samenvoegen, elk met zijn eigen accumulator. De eerste is de van code points afgeleide set: HotPDF accumuleert FUnicodeUsedCps naarmate BMP-tekens uitgezonden worden en FUnicodeSmpUsed voor supplementary-plane-tekens bereikt via surrogate pairs, en mapt elk vervolgens via FUnicodeCpToGid naar een glyph-ID. De tweede is de van shaping afgeleide set, de glyph-ID's die een GSUB-substitutie produceerde, vastgelegd via MarkUnicodeGlyphUsed en EnableShapingFeatureForSubset in FUnicodeExtraUsedGlyphs. De derde is composiete closure: een glyph waarvan numberOfContours -1 is in glyf wordt samengesteld uit componentglyph-ID's, en het behouden van de composiet terwijl zijn componenten vallen, levert een leeg omtrek op in plaats van een .notdef, wat aantoonbaar erger is omdat het als een spatiëringsbug leest
HotPDF heeft de eerste en derde altijd afgehandeld. BuildAndApplyUnicodeFontSubset, het subsetting-instappunt dat EndDoc aanroept vóór serialisatie, zaait de gebruikte-glyph-array met GID 0, loopt door de BMP-code points, loopt door de SMP-usage-lijst, en geeft de array door aan een subset-builder die composietcomponenten intern oplost. De tweede bron werd geschreven maar nooit geconsumeerd, en omdat de drie bronnen falen op verschillend materiaal, kan het gat zich jarenlang verschuilen in een codebase waarvan het regressiecorpus grotendeels Latijns is
De array die geschreven werd en nooit gelezen
Het contract werd op drie plaatsen gedocumenteerd en op geen enkele nageleefd. De declaratie van FUnicodeExtraUsedGlyphs stelde dat de EndDoc-subsetter hem samenvoegt met het van code points afgeleide gebruik; de headercommentaar op ApplyArabicGSUBRefinement beloofde dat elke uitgezonden vervang-GID door MarkUnicodeGlyphUsed geleid wordt zodat de subsetter de glyph in de embedded font trekt; dezelfde belofte verschijnt woordelijk op ApplyArabicGSUBContextualRefinement voor het rclt-pad. Beide aanroepers hielden zich aan hun helft. Een grep over elke referentie naar het veld beslechtte de andere helft in ongeveer negentig seconden: één declaratie, één SetLength-allocatie binnen RegisterUnicodeTTF, en writes in de twee markeringsroutines. Geen enkele read. Dat is de diagnose die het waard is om te internaliseren, want ze generaliseert goed voorbij fonts. Wanneer een veld door meerdere aanroeppunten geschreven wordt en door geen enkele gelezen, bestaat de feature die het vertegenwoordigt niet, hoe grondig het ook becommentarieerd is. Stap 1 van de subsetter is klein genoeg om in één scherm te lezen, en het gat is duidelijk zodra je weet waar je op moet letten
// 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
De fix in één lus, en zelf glyphs markeren
De reparatie is een union, en het veiligheidsargument komt van de richting van de bewerking: het zet alleen bits, wist ze nooit, dus geen glyph die de subset vroeger overleefde kan nu ineens vallen
// 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;
Drie eigenschappen maken dit een risicoarme verandering in plaats van een herschrijving van de font-engine. Het is monotoon, zoals hierboven. Het is een no-op op fonts die nooit iets geshaped hebben, aangezien FUnicodeExtraUsedGlyphs volledig False blijft en de byte-output voor een puur Latijns document ongewijzigd is. En het landt vóór stap 2, dus erven beide subset-builders het: de sparse builder die de originele GID-nummering behoudt, en de compacte builder _BuildCompactSubsetTTF die HotPDF selecteert onder PDF/A om behouden glyphs te hernummeren naar een dicht bereik, maxp.numGlyphs te verkleinen, en de oud-naar-nieuw-mapping uit te zenden als de /CIDToGIDMap-stream vereist door ISO 32000-1 §9.7.4.2. Beide roepen intern _TTFWalkCompositeClosure aan, dus een geshapete glyph die toevallig composiet is, sleept nu ook zijn componenten mee. Composiete closure was nooit stuk; hij werd simpelweg nooit bereikt voor deze glyph-ID's, omdat de glyph-ID's niet in de set zaten die hij afloopt. Als je de GSUB-engine zelf aanstuurt in plaats van te vertrouwen op de ingebouwde refinement-passes, wordt closure jouw verantwoordelijkheid, en moet elke vervang-glyph-ID die je uitzendt gemarkeerd worden voordat EndDoc de gebruikte-glyph-set bevriest
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 is de batch-tegenhanger van de enkele-GID-aanroep, en is bewust conservatief. Hij loopt door de GSUB-lookuplijst voor de lookups verbonden aan één vier-byte feature-tag onder het momenteel geselecteerde script- en taalpad, en markeert de vervang-glyph-ID's die die lookups kunnen produceren. Het is een defensieve no-op wanneer de font geen GSUB-tabel draagt of wanneer de feature afwezig is op dat pad, dus is het onvoorwaardelijk aanroepen ervan veilig. Het is ook opzettelijk een overschatting: het kan glyphs behouden die een gegeven document nooit tekent. Voor subsetting kost over-inclusie bytes en kost onder-inclusie correctheid, wat die afweging tot een makkelijke maakt. De structuur van deze lookups, en de coverage-tabellen die bepalen welke glyphs meedoen, wordt behandeld in de doorloop van GSUB stylistic alternates in pure Delphi
Hoe bewijs je dat de glyph daadwerkelijk in de subset zit?
Door de uitgezonden font te lezen, niet door de pagina met het blote oog te bekijken in een viewer die stiekem een systeemfont substitueert. De controle die deze hele klasse bugs vangt, is mechanisch: extraheer de /FontFile2-stream uit de output-PDF, parse loca, en bevestig dat de glyph-ID die je verwacht een niet-lege entry draagt, wat betekent dat zijn start- en eindoffsets verschillen. Een lege entry is de subsetter die besloten heeft dat de glyph ongebruikt is. Twee gewoontes maken het vervolgens veel moeilijker om de fout opnieuw uit te leveren. Houd een geshapete-script-pagina in het geautomatiseerde smoke-corpus in plaats van alleen in de handmatige proefreeks, want Arabisch, Devanagari, en Khmer oefenen closure-paden uit die geen hoeveelheid Latijnse dekking zal raken. En wanneer er een accumulator bestaat, assert dan dat iets hem consumeert, want een write-only-veld is een feature die compileert, groen test op het verkeerde corpus, en niets doet
Waar de fix stopt
Subset closure is noodzakelijk voor een geshapete glyph om te renderen, en is niet voldoende. De glyph moet ook adresseerbaar zijn vanuit de content stream, wat een apart probleem is met zijn eigen grens. De ingebouwde Arabische refinement-passes van HotPDF committeren een substitutie alleen wanneer elke vervang-glyph-ID bereikbaar is via een Unicode-presentatievorm-code-point via een omgekeerde cmap-scan over ongeveer 690 code points in U+FB50 tot U+FDFF en U+FE70 tot U+FEFF. Wanneer een vervanging op een glyph-ID buiten dat bereik landt, gaat het invoervenster ongewijzigd door in plaats van iets uit te zenden waar de lezer niet bij kan; font-specifieke alternatieven op willekeurige glyph-ID's hebben een synthetisch private-use-code-point nodig, toegewezen in U+E000 tot U+F8FF, om ze door het emit-pad te dragen. De eerlijke samenvatting is dus dat de fix van 2.435.0 een harde blokkade wegnam in plaats van het verhaal te voltooien. Ervoor kon een glyph correct geshaped, correct uitgezonden worden, en toch verdwijnen bij subsetting, wat betekende dat de shaping-engine niet end-to-end vertrouwd kon worden, hoe goed zijn lookups ook waren. Wat overblijft is adresseerbaarheid, en die beperking faalt tenminste zichtbaar op het punt van uitzending in plaats van stilletjes in een buildstap die draait nadat je alles al bekeken had. Voor de emit-kant van dezelfde pipeline, zie de handleiding voor Arabische en RTL-tekstshaping in Delphi-PDF's
De font subsetting, GSUB-engine, en complex-script-shaping die hier beschreven worden, worden geleverd in de standaard HotPDF Component voor Delphi en C++Builder; de productpagina bevat de volledige API-referentie voor de hierboven genoemde Unicode-font- en shaping-aanroepen