Articol tehnic

Închiderea subsetului de fonturi pierde glife shape-uite

Glifele shape-uite se randează ca niște cutii .notdef atunci când un subsetter de fonturi păstrează doar glifele accesibile din code point-urile emise. HotPDF, componenta VCL nativă pentru PDF în Delphi și C++Builder, a purtat exact acest defect până la versiunea 2.435.0: ieșirea OpenType GSUB era înregistrată într-un bitmap intern de utilizare pe care subsetter-ul declara că îl va respecta, dar pe care nu îl citea niciodată de fapt

Aceasta este o defecțiune diferită de cea descrisă în bug-ul EndDoc care dezactiva silențios subsetarea fonturilor. Acel bug ținea de momentul în care rula subsetarea față de serializare și dezactiva subsetarea în întregime. Acesta ține de ce conține subsetul atunci când subsetarea rulează perfect, la momentul potrivit. Pipeline-ul se declanșează la momentul corect, prefixul de șase litere al subsetului apare pe /BaseFont exact așa cum cere ISO 32000-1 §9.6.4, fișierul devine mai mic, fiecare pagină în latină iese impecabil la probă, iar o pagină în arabă iese ca un șir de dreptunghiuri goale. Bug-urile de ordonare sunt zgomotoase de îndată ce te uiți. Bug-urile de închidere rămân tăcute la nesfârșit, pentru că subsetul este structural valid și greșește doar în privința propriei liste de membri

De ce se randează glifele shape-uite ca .notdef?

Pentru că mulțimea code point-urilor pe care le emite un document nu este aceeași cu mulțimea glifelor pe care documentul le desenează, iar un subsetter care confundă cele două elimină fiecare glifă produsă prin shaping. Shaping-ul de text transformă o secvență logică de caractere într-o secvență poziționată de glife, iar întregul lui scop este să producă glife pe care niciun caracter de intrare, luat singur, nu le mapează: un heh arab în poziție mediană, o ligatură fi, un conjunct devanagari, o alternativă contextuală selectată de feature-ul rclt. Fiecare dintre acestea este un ID de glifă produs de un lookup GSUB, nu unul pe care tabela cmap ți-l dă pentru vreun caracter din șirul tău. Un subsetter condus strict de cmap parcurge deci indexul greșit. Păstrează fidel fiecare glifă pe care textul ar fi putut-o folosi înainte de shaping și elimină exact glifele pe care textul chiar le folosește după shaping. Renderer-ul cere apoi fontului încorporat GID 1847, subsetul a golit acea intrare în loca, iar în loc vine indexul de glifă 0. Indexul de glifă 0 este .notdef prin definiția OpenType, motiv pentru care semnătura defecțiunii este o cutie goală și nu o literă greșită sau un crash. Nimic din PDF nu este malformat; fontul pur și simplu nu conține glifa pe care content stream-ul a cerut-o

Code point-urile nu sunt glife: cele trei surse ale unui subset

O închidere corectă a subsetului trebuie să unească trei surse independente, fiecare cu propriul acumulator. Prima este mulțimea derivată din code point-uri: HotPDF acumulează FUnicodeUsedCps pe măsură ce sunt emise caractere BMP și FUnicodeSmpUsed pentru caracterele din planul suplimentar, atinse prin perechi surogat, apoi mapează fiecare, prin FUnicodeCpToGid, la un ID de glifă. A doua este mulțimea derivată din shaping, ID-urile de glife produse de o substituție GSUB, înregistrate prin MarkUnicodeGlyphUsed și EnableShapingFeatureForSubset în FUnicodeExtraUsedGlyphs. A treia este închiderea compozită: o glifă al cărei numberOfContours este -1 în glyf este asamblată din ID-uri de glife componente, iar păstrarea compozitei și eliminarea componentelor ei produce un contur gol în loc de un .notdef, ceea ce e practic mai rău, pentru că se citește ca un bug de spațiere

HotPDF a tratat dintotdeauna prima și a treia sursă. BuildAndApplyUnicodeFontSubset, punctul de intrare al subsetării pe care EndDoc îl apelează înainte de serializare, inițializează array-ul de glife folosite cu GID 0, parcurge code point-urile BMP, parcurge lista de utilizare SMP și predă array-ul unui subset builder care rezolvă intern componentele compozite. A doua sursă era scrisă, dar niciodată consumată, și pentru că cele trei surse eșuează pe conținuturi diferite, gap-ul se poate ascunde ani de zile într-o bază de cod al cărei corpus de regresie e majoritar în latină

Array-ul care era scris și niciodată citit

Contractul era documentat în trei locuri și respectat în niciunul. Declarația lui FUnicodeExtraUsedGlyphs spunea că subsetter-ul din EndDoc îl unește cu utilizarea derivată din code point-uri; comentariul de antet de pe ApplyArabicGSUBRefinement promitea că fiecare GID de substituție emis este trecut prin MarkUnicodeGlyphUsed, astfel încât subsetter-ul să tragă glifa în fontul încorporat; aceeași promisiune apare cuvânt cu cuvânt pe ApplyArabicGSUBContextualRefinement, pentru calea rclt. Ambii apelanți și-au respectat jumătatea lor. Un grep peste fiecare referință la câmp a lămurit cealaltă jumătate în vreo nouăzeci de secunde: o declarație, o alocare SetLength în interiorul lui RegisterUnicodeTTF și scrieri în cele două rutine de marcare. Nicio citire. Acesta e diagnosticul demn de reținut, pentru că se generalizează mult dincolo de fonturi. Când un câmp este scris de mai multe call site-uri și citit de niciunul, feature-ul pe care-l reprezintă nu există, oricât de temeinic ar fi comentat. Pasul 1 al subsetter-ului e destul de mic încât să încapă pe un ecran, iar gap-ul devine evident de îndată ce știi ce să cauți

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

Fix-ul dintr-un singur loop, și marcarea glifelor pe cont propriu

Reparația este o uniune, iar argumentul ei de siguranță vine din direcția operației: doar setează biți, nu îi șterge niciodată, așa că nicio glifă care înainte supraviețuia subsetării nu poate începe brusc să fie eliminată

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

Trei proprietăți fac din asta o schimbare cu risc scăzut, nu o rescriere a motorului de fonturi. Este monotonă, ca mai sus. Este un no-op pe fonturile care nu au făcut niciodată shaping la nimic, pentru că FUnicodeExtraUsedGlyphs rămâne complet False, iar output-ul pe bytes pentru un document doar-latin rămâne neschimbat. Și intervine înainte de Pasul 2, deci ambii subset builder o moștenesc: builder-ul sparse, care păstrează numerotarea originală a GID-urilor, și builder-ul compact _BuildCompactSubsetTTF, pe care HotPDF îl alege sub PDF/A pentru a renumerota glifele păstrate într-un interval dens, a micșora maxp.numGlyphs și a emite maparea de la vechi la nou ca stream-ul /CIDToGIDMap cerut de ISO 32000-1 §9.7.4.2. Ambele apelează intern _TTFWalkCompositeClosure, așa că o glifă shape-uită care se întâmplă să fie compozită își aduce acum și componentele. Închiderea compozită nu a fost niciodată stricată; pur și simplu nu era niciodată atinsă pentru aceste ID-uri de glife, pentru că ID-urile de glife nu erau în mulțimea pe care o parcurge ea. Dacă pilotezi direct motorul GSUB, în loc să te bazezi pe pass-urile de rafinare incluse, închiderea devine responsabilitatea ta, iar fiecare ID de glifă de substituție pe care îl emiți trebuie marcat înainte ca EndDoc să înghețe mulțimea de glife folosite

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 este echivalentul pe lot al apelului cu un singur GID, și e deliberat conservator. Parcurge lista de lookup-uri GSUB pentru lookup-urile legate de un tag de feature pe patru bytes, sub scriptul și calea de limbă selectate curent, și marchează ID-urile de glife de substituție pe care le pot produce acele lookup-uri. E un no-op defensiv atunci când fontul nu are tabelă GSUB sau când feature-ul lipsește de pe acea cale, așa că apelarea lui necondiționată e sigură. Este de asemenea o supra-aproximare, în mod deliberat: poate păstra glife pe care un anumit document nu le desenează niciodată. Pentru subsetare, includerea în exces costă bytes, iar excluderea costă corectitudine, ceea ce face acest compromis ușor de acceptat. Structura acestor lookup-uri, și tabelele de coverage care decid ce glife participă, sunt tratate în ghidul pentru alternativele stilistice GSUB în Delphi pur

Cum dovedești că glifa chiar se află în subset?

Citind fontul emis, nu privind pagina cu ochiul liber într-un viewer care poate substitui pe ascuns un font de sistem. Verificarea care prinde întreaga clasă asta de bug e mecanică: extragi stream-ul /FontFile2 din PDF-ul rezultat, parsezi loca și confirmi că ID-ul de glifă pe care îl aștepți are o intrare ne-goală, adică offset-urile lui de start și de sfârșit diferă. O intrare goală înseamnă că subsetter-ul a decis că glifa nu e folosită. Două obiceiuri fac apoi mult mai greu să livrezi din nou defecțiunea. Păstrează o pagină cu script shape-uit în corpusul de smoke automatizat, nu doar în setul manual de probă, pentru că araba, devanagari și khmerul exercită căi de închidere pe care nicio acoperire în latină nu le va atinge vreodată. Și oriunde există un acumulator, verifică prin assert că ceva îl consumă, pentru că un câmp doar-scris e un feature care compilează, trece testele pe corpusul greșit și nu face nimic

Unde se oprește fix-ul

Închiderea subsetului e necesară pentru ca o glifă shape-uită să se randeze, dar nu e suficientă. Glifa trebuie de asemenea să fie adresabilă din content stream, ceea ce e o problemă separată, cu propria ei graniță. Pass-urile de rafinare arabă incluse în HotPDF confirmă o substituție doar atunci când fiecare ID de glifă de substituție e accesibil printr-un code point de formă de prezentare Unicode, via o scanare inversă cmap peste aproximativ 690 de code point-uri, în U+FB50–U+FDFF și U+FE70–U+FEFF. Când o substituție ajunge pe un ID de glifă din afara acelui interval, fereastra de intrare trece neschimbată, în loc să emită ceva pe care cititorul nu-l poate adresa; alternativele specifice fontului, la ID-uri de glife arbitrare, au nevoie de un code point sintetic de uz privat, alocat în U+E000–U+F8FF, ca să fie duse prin calea de emitere. Deci rezumatul onest e că fix-ul din 2.435.0 a înlăturat un blocaj dur, nu a încheiat povestea. Înainte de el, o glifă putea fi shape-uită corect, emisă corect, și tot să dispară la momentul subsetării, ceea ce însemna că motorul de shaping nu putea fi de încredere de la un capăt la altul, oricât de bune i-ar fi fost lookup-urile. Ce rămâne e adresabilitatea, iar constrângerea asta măcar eșuează vizibil, la punctul de emitere, nu în tăcere, într-un pas de build care rulează după tot ce urmăreai. Pentru partea de emitere a aceluiași pipeline, vezi ghidul pentru shaping de text arab și RTL în PDF-uri Delphi

Subsetarea fonturilor, motorul GSUB și shaping-ul pentru scripturi complexe descrise aici sunt incluse în HotPDF Component standard, pentru Delphi și C++Builder; pagina produsului conține referința API completă pentru apelurile de fonturi Unicode și shaping menționate mai sus