Teknisk artikel

Font-subset-closure: shape-glyfer försvinner i Delphi-PDF:er

Shapade glyfer renderas som .notdef-rutor när en font-subsetter bara behåller de glyfer som är nåbara från utsända kodpunkter. HotPDF, den native VCL-PDF-komponenten för Delphi och C++Builder, bar precis den defekten fram till version 2.435.0: OpenType GSUB-utdata registrerades i en intern användningsbitmapp som subsettern deklarerade att den skulle respektera och sedan aldrig faktiskt läste

Detta är ett annat fel än det som beskrivs i EndDoc-buggen som tyst inaktiverade font-subsetting. Den buggen handlade om när subsetting kördes i förhållande till serialisering, och den inaktiverade subsetting helt och hållet. Den här handlar om vad subsetet innehåller när subsetting körs perfekt enligt schemat. Pipelinen utlöses vid rätt tillfälle, sexbokstavsprefixet för subsetet visas på /BaseFont exakt som ISO 32000-1 §9.6.4 kräver, filen blir mindre, varje latinsk sida korrekturläses ren, och en arabisk sida kommer ut som en rad tomma rektanglar. Ordningsbuggar är högljudda så fort man tittar. Closure-buggar förblir tysta för alltid, eftersom subsetet är strukturellt giltigt och bara fel om sin egen medlemslista

Varför renderas shapade glyfer som .notdef?

Därför att mängden kodpunkter ett dokument sänder ut inte är mängden glyfer dokumentet ritar, och en subsetter som blandar ihop de två tappar varje glyf som shaping producerat. Textshaping omvandlar en logisk teckensekvens till en positionerad glyfsekvens, och hela dess syfte är att producera glyfer som ingen enskild indatakaraktär mappar till: ett arabiskt medialt heh, en fi-ligatur, en devanagari-konjunkt, ett kontextuellt alternativ valt av funktionen rclt. Var och en av dessa är ett glyf-ID som en GSUB-uppslagning tillverkade, inte ett som cmap-tabellen ger dig för något tecken i din sträng. En subsetter som drivs enbart av cmap vandrar därför genom fel index. Den behåller troget varje glyf texten skulle kunnat använda före shaping och kastar precis de glyfer texten faktiskt använder efter shaping. Renderaren frågar sedan den inbäddade fonten efter GID 1847, subsetet har nollat den posten i loca, och glyfindex 0 kommer tillbaka i stället. Glyfindex 0 är .notdef enligt OpenType-definitionen, vilket är varför felsignaturen är en tom ruta snarare än fel bokstav eller en krasch. Ingenting i PDF:en är felformat; fonten innehåller helt enkelt inte den glyf innehållsströmmen bad om

Kodpunkter är inte glyfer: subsetets tre källor

En korrekt subset-closure måste slå ihop tre oberoende källor, var och en med sin egen ackumulator. Den första är den kodpunkts-härledda mängden: HotPDF ackumulerar FUnicodeUsedCps när BMP-tecken sänds ut och FUnicodeSmpUsed för tecken i tilläggsplan som nås genom surrogatpar, och mappar sedan var och en genom FUnicodeCpToGid till ett glyf-ID. Den andra är den shaping-härledda mängden, de glyf-ID:n en GSUB-substitution producerade, registrerade via MarkUnicodeGlyphUsed och EnableShapingFeatureForSubset in i FUnicodeExtraUsedGlyphs. Den tredje är sammansatt closure: en glyf vars numberOfContours är -1 i glyf byggs samman av komponent-glyf-ID:n, och att behålla den sammansatta glyfen medan man tappar dess komponenter ger en tom kontur snarare än en .notdef, vilket sannolikt är värre eftersom det uppfattas som en mellanrumsbugg

HotPDF har alltid hanterat den första och den tredje. BuildAndApplyUnicodeFontSubset, subsettingens ingångspunkt som EndDoc anropar innan serialisering, sår den använda-glyf-arrayen med GID 0, vandrar genom BMP-kodpunkterna, vandrar genom SMP-användningslistan, och lämnar arrayen till en subset-byggare som löser sammansatta komponenter internt. Den andra källan skrevs men konsumerades aldrig, och eftersom de tre källorna misslyckas på olika innehåll kan gapet gömma sig i åratal i en kodbas vars regressionskorpus mest är latinsk

Arrayen som skrevs och aldrig lästes

Kontraktet dokumenterades på tre ställen och respekterades på inget av dem. Deklarationen av FUnicodeExtraUsedGlyphs angav att EndDoc-subsettern slår ihop den med den kodpunkts-härledda användningen; huvudkommentaren på ApplyArabicGSUBRefinement utlovade att varje utsänt ersättnings-GID skickas genom MarkUnicodeGlyphUsed så att subsettern drar in glyfen i den inbäddade fonten; samma löfte förekommer ordagrant på ApplyArabicGSUBContextualRefinement för rclt-vägen. Båda anroparna höll sin halva. En grep över varje referens till fältet avgjorde den andra halvan på ungefär nittio sekunder: en deklaration, en SetLength-allokering inuti RegisterUnicodeTTF, och skrivningar i de två markeringsrutinerna. Inte en enda läsning. Det är diagnosen värd att internalisera, eftersom den generaliserar långt bortom fonter. När ett fält skrivs av flera anropsställen och läses av inget, existerar inte funktionen det representerar, hur grundligt den än är kommenterad. Steg 1 i subsettern är litet nog att läsa på en skärm, och gapet är uppenbart så fort man vet att leta efter det

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

Fixen med en enda loop, och att markera glyfer själv

Reparationen är en union, och dess säkerhetsargument kommer från operationens riktning: den bara sätter bitar, aldrig rensar dem, så ingen glyf som brukade överleva subsetet kan börja tappas

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

Tre egenskaper gör detta till en lågrisksändring snarare än en ombyggnad av fontmotorn. Den är monoton, som ovan. Den är en no-op på fonter som aldrig shapat något, eftersom FUnicodeExtraUsedGlyphs förblir all-False och byteutdata för ett rent latinskt dokument är oförändrad. Och den kommer in före steg 2, så båda subset-byggarna ärver den: den glesa byggaren som bevarar den ursprungliga GID-numreringen, och den kompakta byggaren _BuildCompactSubsetTTF som HotPDF väljer under PDF/A för att numrera om behållna glyfer till ett tätt intervall, krympa maxp.numGlyphs, och sända ut den gamla-till-nya mappningen som den /CIDToGIDMap-ström som krävs av ISO 32000-1 §9.7.4.2. Båda anropar _TTFWalkCompositeClosure internt, så en shapad glyf som råkar vara sammansatt drar nu också in sina komponenter. Sammansatt closure var aldrig trasig; den nåddes bara aldrig för dessa glyf-ID:n, eftersom glyf-ID:na inte fanns i mängden den vandrar genom. Om du driver GSUB-motorn direkt i stället för att förlita dig på de inbyggda förfiningspassen blir closure ditt ansvar, och varje ersättnings-glyf-ID du sänder ut måste markeras innan EndDoc fryser den använda-glyf-mängden

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 är batch-motsvarigheten till anropet för ett enda GID, och den är medvetet konservativ. Den vandrar genom GSUB-uppslagslistan efter de uppslagningar som är kopplade till en fyrabokstavs-funktionstagg under den för tillfället valda skript- och språkvägen, och markerar de ersättnings-glyf-ID:n de uppslagningarna kan producera. Den är en defensiv no-op när fonten inte bär någon GSUB-tabell eller när funktionen saknas på den vägen, så att anropa den ovillkorligt är säkert. Den är också en överapproximation by design: den kan behålla glyfer ett givet dokument aldrig ritar. För subsetting kostar överinkludering byte och underinkludering kostar korrekthet, vilket gör den avvägningen enkel. Strukturen hos dessa uppslagningar, och täckningstabellerna som avgör vilka glyfer som deltar, täcks i genomgången av GSUB-stilalternativ i ren Delphi

Hur bevisar man att glyfen faktiskt finns i subsetet?

Genom att läsa den utsända fonten, inte genom att ögonmäta sidan i en läsare som kan ersätta med en systemfont bakom din rygg. Kontrollen som fångar hela den här klassen av buggar är mekanisk: extrahera /FontFile2-strömmen från utdata-PDF:en, tolka loca, och bekräfta att det glyf-ID du förväntar dig bär en icke-tom post, det vill säga att dess start- och slutoffset skiljer sig åt. En tom post är subsettern som beslutat att glyfen är oanvänd. Två vanor gör det sedan mycket svårare att leverera felet igen. Behåll en sida med shapat skript i den automatiserade rökkorpusen snarare än bara i den manuella korrekturmängden, eftersom arabiska, devanagari och khmer utövar closure-vägar som ingen mängd latinsk täckning kommer att röra. Och närhelst en ackumulator finns, försäkra dig om att något konsumerar den, eftersom ett skriv-bara-fält är en funktion som kompilerar, testar grönt på fel korpus och gör ingenting

Var fixen slutar

Subset-closure är nödvändig för att en shapad glyf ska renderas, och den är inte tillräcklig. Glyfen måste också vara adresserbar från innehållsströmmen, vilket är ett separat problem med sin egen gräns. HotPDFs inbyggda arabiska förfiningspass genomför en substitution bara när varje ersättnings-glyf-ID är nåbart genom en Unicode-presentationsforms-kodpunkt via en omvänd cmap-skanning över ungefär 690 kodpunkter i U+FB50 till U+FDFF och U+FE70 till U+FEFF. När en ersättning hamnar på ett glyf-ID utanför det intervallet passerar indatafönstret igenom oförändrat i stället för att sända ut något läsaren inte kan adressera; fontspecifika alternativ på godtyckliga glyf-ID:n behöver en syntetisk privat-bruk-kodpunkt allokerad i U+E000 till U+F8FF för att bäras genom utsändningsvägen. Så den ärliga sammanfattningen är att 2.435.0-fixen tog bort en hård blockerare snarare än att fullborda historien. Innan den kunde en glyf shapas korrekt, sändas ut korrekt, och ändå försvinna vid subset-tillfället, vilket innebar att shaping-motorn inte kunde litas på från ände till ände hur bra dess uppslagningar än var. Det som återstår är adresserbarhet, och den begränsningen misslyckas åtminstone synligt vid utsändningstillfället snarare än tyst i ett byggsteg som körs efter allt du bevakade. För utsändningssidan av samma pipeline, se guiden till arabisk och RTL-textshaping i Delphi-PDF:er

Font-subsetting, GSUB-motorn och shapingen för komplexa skript som beskrivs här levereras i standard-HotPDF Component för Delphi och C++Builder; produktsidan innehåller den fullständiga API-referensen för Unicode-font- och shaping-anropen som nämns ovan