Technický článek

Uzávěr subsetu fontu: shapované glyfy zmizelé z PDF

Shapované glyfy se vykreslí jako obdélníky .notdef, když subsetter fontu ponechá jen glyfy dosažitelné z emitovaných kódových bodů. HotPDF, nativní VCL PDF komponenta pro Delphi a C++Builder, nesla přesně tuto vadu až do verze 2.435.0: výstup OpenType GSUB se zaznamenával do interní bitmapy použití, o které subsetter deklaroval, že ji bude respektovat, a pak ji nikdy skutečně nečetl

Toto je jiné selhání než to popsané v chybě EndDoc, která potichu vypínala subsetting fontů. Ona chyba se týkala toho, kdy subsetting proběhl vzhledem k serializaci, a subsetting vypínala úplně. Tahle se týká toho, co subset obsahuje, když subsetting proběhne přesně podle plánu. Pipeline se spustí ve správný okamžik, šestipísmenný prefix subsetu se objeví na /BaseFont přesně tak, jak vyžaduje ISO 32000-1 §9.6.4, soubor se zmenší, každá latinská stránka projde korekturou čistě a arabská stránka vyjde jako řada prázdných obdélníků. Chyby v pořadí jsou hlasité, jakmile se podíváte. Chyby v uzávěru zůstávají tiché navždy, protože subset je strukturálně platný a je špatný jen ve svém vlastním seznamu členství

Proč se shapované glyfy vykreslí jako .notdef?

Protože množina kódových bodů, které dokument emituje, není množinou glyfů, které dokument kreslí, a subsetter, který tyto dvě věci zaměňuje, zahodí každý glyf vyprodukovaný shapingem. Shaping textu proměňuje logickou sekvenci znaků na umístěnou sekvenci glyfů, a celým jeho smyslem je vyprodukovat glyfy, na které nemapuje žádný jednotlivý vstupní znak: arabské mediální heh, ligaturu fi, dévanágarský spřežkový znak (conjunct), kontextovou alternativu vybranou funkcí rclt. Každý z nich je ID glyfu, které vyrobilo vyhledávání GSUB, nikoli takové, které vám pro jakýkoli znak ve vašem řetězci dá tabulka cmap. Subsetter řízený čistě přes cmap proto prochází špatný index. Věrně ponechává každý glyf, který mohl text použít před shapingem, a zahazuje přesně ty glyfy, které text používá po shapingu. Renderer se pak vestavěného fontu zeptá na GID 1847, subset tento záznam v loca vynuloval, a místo toho se vrátí index glyfu 0. Index glyfu 0 je podle definice OpenType .notdef, a proto je signaturou selhání prázdný obdélník, nikoli špatné písmeno nebo pád. V PDF není nic vadné; font prostě neobsahuje glyf, o který content stream požádal

Kódové body nejsou glyfy: tři zdroje subsetu

Správný uzávěr subsetu musí sjednotit tři nezávislé zdroje, každý s vlastním akumulátorem. Prvním je množina odvozená z kódových bodů: HotPDF akumuluje FUnicodeUsedCps, jak se emitují znaky BMP, a FUnicodeSmpUsed pro znaky doplňkové roviny dosažené přes náhradní páry (surrogate pairs), a pak každý namapuje přes FUnicodeCpToGid na ID glyfu. Druhým je množina odvozená ze shapingu, ID glyfů, které vyprodukovala substituce GSUB, zaznamenaná přes MarkUnicodeGlyphUsed a EnableShapingFeatureForSubset do FUnicodeExtraUsedGlyphs. Třetím je uzávěr kompozitních glyfů: glyf, jehož numberOfContours je v glyf rovno -1, je sestaven ze složkových ID glyfů, a ponechat kompozitní glyf při zahození jeho složek vyprodukuje prázdný obrys místo .notdef, což je diskutabilně horší, protože to vypadá jako chyba v rozestupech

HotPDF vždy zvládal první a třetí zdroj. BuildAndApplyUnicodeFontSubset, vstupní bod subsettingu, který volá EndDoc před serializací, osadí pole použitých glyfů hodnotou GID 0, projde kódové body BMP, projde seznam použití SMP a předá pole builderu subsetu, který interně vyřeší kompozitní složky. Druhý zdroj byl zapisován, ale nikdy nebyl čten, a protože tyto tři zdroje selhávají na jiném obsahu, mezera se může skrývat celé roky v codebase, jejíž regresní korpus je převážně latinský

Pole, které se zapisovalo a nikdy nečetlo

Kontrakt byl zdokumentovaný na třech místech a dodržený na žádném z nich. Deklarace FUnicodeExtraUsedGlyphs uváděla, že subsetter EndDoc jej sjednotí s použitím odvozeným z kódových bodů; komentář v hlavičce ApplyArabicGSUBRefinement sliboval, že každé emitované náhradní GID prochází přes MarkUnicodeGlyphUsed, aby subsetter vtáhl glyf do vestavěného fontu; stejný slib se doslovně objevuje i u ApplyArabicGSUBContextualRefinement pro cestu rclt. Oba volající dodrželi svou polovinu. Grep přes každou referenci na toto pole vyřešil tu druhou polovinu asi za devadesát sekund: jedna deklarace, jedna alokace SetLength uvnitř RegisterUnicodeTTF a zápisy ve dvou označovacích rutinách. Ani jedno čtení. To je diagnóza, kterou stojí za to si osvojit, protože se dobře zobecňuje i mimo fonty. Když pole zapisuje několik volajících míst a nečte žádné, funkce, kterou reprezentuje, neexistuje, ať je okomentovaná sebedůkladněji. Krok 1 subsetteru je dost malý na to, aby se přečetl na jednu obrazovku, a mezera je zřejmá, jakmile víte, že po ní máte pátrat

// 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

Oprava jedním cyklem a ruční označování glyfů

Oprava je sjednocení a její argument bezpečnosti vychází ze směru operace: pouze nastavuje bity, nikdy je nemaže, takže žádný glyf, který dřív subset přežíval, nemůže začít být zahazován

// 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;

Tři vlastnosti dělají z tohoto nízkorizikovou změnu, a nikoli přepsání fontového enginu. Je monotónní, jak výše. Je no-opem u fontů, které nikdy nic neshapovaly, protože FUnicodeExtraUsedGlyphs zůstává celé False a výstupní bajty dokumentu jen s latinkou se nemění. A zapadá před Krok 2, takže ji zdědí oba buildery subsetu: řídký (sparse) builder, který zachovává původní číslování GID, a kompaktní builder _BuildCompactSubsetTTF, který HotPDF volí pod PDF/A, aby přečísloval ponechané glyfy do hustého rozsahu, zmenšil maxp.numGlyphs a vyprodukoval mapování starých na nová jako stream /CIDToGIDMap vyžadovaný ISO 32000-1 §9.7.4.2. Oba interně volají _TTFWalkCompositeClosure, takže shapovaný glyf, který náhodou je kompozitní, teď s sebou strhne i své složky. Uzávěr kompozitních glyfů nebyl nikdy rozbitý; jen se pro tato ID glyfů nikdy nedostal ke slovu, protože tato ID glyfů nebyla v množině, kterou prochází. Pokud řídíte engine GSUB přímo místo spoléhání na vestavěné zpřesňovací průchody, uzávěr se stává vaší odpovědností a každé náhradní ID glyfu, které emitujete, musí být označeno dřív, než EndDoc zmrazí množinu použitých glyfů

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 je dávkový protějšek volání pro jedno GID a je záměrně konzervativní. Prochází seznam vyhledávání GSUB pro vyhledávání zapojená na jeden čtyřbajtový tag funkce v rámci aktuálně vybrané cesty skriptu a jazyka a označuje náhradní ID glyfů, která tato vyhledávání mohou vyprodukovat. Je to obranný no-op, když font nenese žádnou tabulku GSUB nebo když daná funkce na této cestě chybí, takže je bezpečné jej volat bezpodmínečně. Je to také záměrně nadhodnocující aproximace: může ponechat glyfy, které daný dokument nikdy nekreslí. U subsettingu stojí nadbytečné zahrnutí bajty a nedostatečné zahrnutí stojí správnost, což z toho dělá snadný kompromis. Struktura těchto vyhledávání a tabulky pokrytí rozhodující, které glyfy se účastní, je popsána v přehledu stylistických alternativ GSUB v čistém Delphi

Jak dokázat, že je glyf skutečně v subsetu?

Čtením vyprodukovaného fontu, nikoli okem odhadem stránky v prohlížeči, který za vašimi zády může nahrazovat systémovým fontem. Kontrola, která zachytí celou tuto třídu chyb, je mechanická: extrahujte stream /FontFile2 z výstupního PDF, naparsujte loca a potvrďte, že očekávané ID glyfu nese neprázdný záznam, tedy že se jeho počáteční a koncový offset liší. Prázdný záznam znamená, že subsetter rozhodl, že glyf je nepoužitý. Dva návyky pak dělají mnohem těžší znovu vypustit stejné selhání. Držte stránku se shapovaným skriptem v automatizovaném smoke korpusu, nejen v ruční sadě pro korektury, protože arabština, dévanágarí a khmerština procvičují cesty uzávěru, kterých se žádné množství latinského pokrytí nedotkne. A kdykoli existuje akumulátor, ověřte assertem, že něco spotřebovává jeho obsah, protože pole jen pro zápis je funkce, která se zkompiluje, projde testy na nesprávném korpusu a nedělá nic

Kde oprava končí

Uzávěr subsetu je nutný, aby se shapovaný glyf vykreslil, ale není dostatečný. Glyf musí být také adresovatelný z content streamu, což je samostatný problém s vlastní hranicí. Vestavěné zpřesňovací průchody HotPDF pro arabštinu potvrdí substituci jen tehdy, když je každé náhradní ID glyfu dosažitelné přes kódový bod prezentační formy Unicode pomocí zpětného skenu cmap přes zhruba 690 kódových bodů v rozsahu U+FB50 až U+FDFF a U+FE70 až U+FEFF. Pokud náhrada přistane na ID glyfu mimo tento rozsah, vstupní okno projde beze změny místo toho, aby emitovalo něco, co čtenář nedokáže adresovat; alternativy specifické pro daný font na libovolných ID glyfů potřebují syntetický kódový bod pro soukromé užití přidělený v U+E000 až U+F8FF, aby je bylo možné protáhnout cestou emitování. Poctivé shrnutí tedy je, že oprava 2.435.0 odstranila tvrdou blokaci, nikoli že by dovyprávěla celý příběh. Před ní mohl být glyf shapován správně, emitován správně, a přesto zmizet ve fázi subsettingu, což znamenalo, že se enginu shapingu nedalo důvěřovat od začátku do konce, ať byla jeho vyhledávání sebelepší. Co zbývá, je adresovatelnost, a toto omezení přinejmenším selhává viditelně v okamžiku emitování, spíše než potichu v build kroku běžícím po všem, co jste sledovali. Pro stranu emitování téže pipeline viz průvodce arabským a RTL shapingem textu v PDF Delphi

Subsetting fontů, engine GSUB a shaping komplexních skriptů popsané zde jsou součástí standardní HotPDF Component pro Delphi a C++Builder; produktová stránka obsahuje kompletní referenci API pro výše uvedená volání Unicode fontů a shapingu