Artykuł techniczny

Domknięcie subsetu czcionki: utracone glify shapingu w Delphi

Kształtowane glify renderują się jako pola .notdef, gdy subsetter czcionki zachowuje tylko glify osiągalne z emitowanych punktów kodowych. HotPDF, natywny komponent VCL PDF dla Delphi i C++Builder, niósł dokładnie ten defekt do wersji 2.435.0: wyjście OpenType GSUB było zapisywane w wewnętrznej mapie bitowej użycia, którą subsetter deklarował, że uwzględni, a potem nigdy faktycznie nie odczytywał

To inna awaria niż ta opisana w błędzie EndDoc, który po cichu wyłączał subsetting czcionek. Tamten błąd dotyczył kiedy subsetting uruchamiał się względem serializacji, i wyłączał subsetting całkowicie. Ten dotyczy czego zawiera subset, gdy subsetting działa idealnie zgodnie z harmonogramem. Pipeline uruchamia się we właściwym momencie, sześcioliterowy prefiks subsetu pojawia się na /BaseFont dokładnie tak, jak wymaga ISO 32000-1 §9.6.4, plik staje się mniejszy, każda łacińska strona sprawdza się czysto, a strona arabska wychodzi jako rząd pustych prostokątów. Błędy kolejności są głośne, gdy tylko spojrzysz. Błędy domknięcia pozostają ciche na zawsze, ponieważ subset jest strukturalnie prawidłowy i błędny tylko co do własnej listy członkostwa

Dlaczego kształtowane glify renderują się jako .notdef?

Ponieważ zbiór punktów kodowych, które emituje dokument, nie jest zbiorem glifów, które dokument rysuje, a subsetter, który myli te dwa, upuszcza każdy glif wyprodukowany przez shaping. Kształtowanie tekstu zamienia logiczną sekwencję znaków w pozycjonowaną sekwencję glifów, a jego całym celem jest wyprodukowanie glifów, do których żaden pojedynczy znak wejściowy nie mapuje: arabski medialny heh, ligatura fi, koniunkt dewanagari, alternatywa kontekstowa wybrana przez cechę rclt. Każdy z nich jest ID glifu, który wyprodukowało wyszukiwanie GSUB, a nie taki, który daje ci cmap dla dowolnego znaku w twoim ciągu. Subsetter kierowany wyłącznie przez cmap przechodzi więc po niewłaściwym indeksie. Wiernie zachowuje każdy glif, którego tekst mógł użyć przed kształtowaniem, i odrzuca dokładnie te glify, których tekst używa po kształtowaniu. Renderer pyta wtedy osadzoną czcionkę o GID 1847, subset wyzerował ten wpis w loca, i wraca indeks glifu 0. Indeks glifu 0 to .notdef z definicji OpenType, dlatego sygnaturą awarii jest puste pole, a nie zła litera czy awaria. Nic w PDF nie jest źle sformułowane; czcionka po prostu nie zawiera glifu, o który zapytał strumień treści

Punkty kodowe to nie glify: trzy źródła subsetu

Poprawne domknięcie subsetu musi sumować trzy niezależne źródła, każde z własnym akumulatorem. Pierwsze to zbiór wyprowadzony z punktów kodowych: HotPDF akumuluje FUnicodeUsedCps w miarę emitowania znaków BMP i FUnicodeSmpUsed dla znaków płaszczyzny uzupełniającej osiąganych przez pary surogatów, a następnie mapuje każdy przez FUnicodeCpToGid na ID glifu. Drugie to zbiór wyprowadzony z shapingu, ID glifów, które wyprodukowała substytucja GSUB, zapisywane przez MarkUnicodeGlyphUsed i EnableShapingFeatureForSubset do FUnicodeExtraUsedGlyphs. Trzecie to domknięcie złożone: glif, którego numberOfContours wynosi -1 w glyf, jest złożony z ID glifów komponentów, a zachowanie glifu złożonego przy odrzuceniu jego komponentów daje pusty kontur zamiast .notdef, co jest chyba gorsze, ponieważ czyta się to jako błąd odstępów

HotPDF zawsze obsługiwał pierwsze i trzecie. BuildAndApplyUnicodeFontSubset, punkt wejścia subsettingu wywoływany przez EndDoc przed serializacją, zasiewa tablicę użytych glifów GID 0, przechodzi punkty kodowe BMP, przechodzi listę użycia SMP i przekazuje tablicę do buildera subsetu, który wewnętrznie rozwiązuje komponenty złożone. Drugie źródło było napisane, ale nigdy nie konsumowane, a ponieważ trzy źródła zawodzą na innej treści, luka może ukrywać się latami w bazie kodu, której korpus regresyjny jest głównie łaciński

Tablica, która została napisana i nigdy odczytana

Umowa była udokumentowana w trzech miejscach i honorowana w żadnym z nich. Deklaracja FUnicodeExtraUsedGlyphs stwierdzała, że subsetter EndDoc sumuje ją z użyciem wyprowadzonym z punktów kodowych; komentarz nagłówkowy na ApplyArabicGSUBRefinement obiecywał, że każdy emitowany zastępczy GID jest przekazywany przez MarkUnicodeGlyphUsed, więc subsetter wciąga glif do osadzonej czcionki; ta sama obietnica pojawia się dosłownie na ApplyArabicGSUBContextualRefinement dla ścieżki rclt. Obaj wywołujący dotrzymali swojej połowy. Grep po każdym odwołaniu do tego pola rozstrzygnął drugą połowę w około dziewięćdziesiąt sekund: jedna deklaracja, jedna alokacja SetLength wewnątrz RegisterUnicodeTTF i zapisy w dwóch procedurach oznaczających. Ani jednego odczytu. To jest diagnostyka warta zinternalizowania, ponieważ dobrze się generalizuje poza czcionki. Gdy pole jest zapisywane przez kilka miejsc wywołania i odczytywane przez żadne, funkcja, którą reprezentuje, nie istnieje, bez względu na to, jak dokładnie jest skomentowana. Krok 1 subsettera jest wystarczająco mały, by przeczytać na jednym ekranie, a luka jest oczywista, gdy już wiesz, czego szukać

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

Naprawa jedną pętlą i samodzielne oznaczanie glifów

Naprawa to suma, a jej argument bezpieczeństwa pochodzi z kierunku operacji: tylko ustawia bity, nigdy ich nie czyści, więc żaden glif, który wcześniej przetrwał subset, nie może zacząć być odrzucany

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

Trzy właściwości czynią to zmianą niskiego ryzyka, a nie przepisaniem silnika czcionek. Jest monotoniczna, jak powyżej. Jest no-opem na czcionkach, które nigdy nic nie kształtowały, ponieważ FUnicodeExtraUsedGlyphs pozostaje całkowicie False, a wyjście bajtowe dla dokumentu tylko łacińskiego jest niezmienione. I ląduje przed krokiem 2, więc oba budery subsetu ją dziedziczą: sparse builder, który zachowuje oryginalną numerację GID, oraz compact builder _BuildCompactSubsetTTF, który HotPDF wybiera pod PDF/A, aby przenumerować zachowane glify na gęsty zakres, zmniejszyć maxp.numGlyphs i wyemitować mapowanie stary-na-nowy jako strumień /CIDToGIDMap wymagany przez ISO 32000-1 §9.7.4.2. Oba wywołują wewnętrznie _TTFWalkCompositeClosure, więc kształtowany glif, który akurat jest złożony, teraz też wciąga swoje komponenty. Domknięcie złożone nigdy nie było zepsute; po prostu nigdy nie było osiągane dla tych ID glifów, ponieważ ID glifów nie były w zbiorze, po którym chodzi. Jeśli kierujesz silnikiem GSUB bezpośrednio zamiast polegać na wbudowanych przejściach dopracowania, domknięcie staje się twoją odpowiedzialnością, i każdy ID glifu zastępczego, który emitujesz, musi być oznaczony przed tym, jak EndDoc zamrozi zbiór użytych glifów

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 jest odpowiednikiem wsadowym wywołania pojedynczego GID i jest celowo konserwatywna. Przechodzi listę wyszukiwań GSUB dla wyszukiwań podpiętych pod jeden czterobajtowy tag cechy na aktualnie wybranej ścieżce skrypt-język i oznacza ID glifów zastępczych, które te wyszukiwania mogą wyprodukować. Jest defensywnym no-opem, gdy czcionka nie niesie tabeli GSUB lub gdy cecha jest nieobecna na tej ścieżce, więc wywoływanie jej bezwarunkowo jest bezpieczne. Jest to też celowe nad-przybliżenie: może zachować glify, których dany dokument nigdy nie rysuje. Dla subsettingu nad-inkluzja kosztuje bajty, a niedo-inkluzja kosztuje poprawność, co czyni ten kompromis łatwym. Struktura tych wyszukiwań i tabele pokrycia decydujące, które glify uczestniczą, są omówione w przeglądzie stylistycznych alternatyw GSUB w czystym Delphi

Jak udowodnić, że glif jest faktycznie w subsecie?

Przez odczytanie wyemitowanej czcionki, nie przez oglądanie strony w przeglądarce, która może po cichu zastępować czcionkę systemową. Sprawdzenie wyłapujące całą tę klasę błędów jest mechaniczne: wyodrębnij strumień /FontFile2 z wynikowego PDF, sparsuj loca i potwierdź, że oczekiwany ID glifu niesie niepusty wpis, czyli że jego przesunięcia początku i końca się różnią. Pusty wpis oznacza, że subsetter zdecydował, że glif jest nieużywany. Dwa nawyki potem znacznie utrudniają ponowne wysłanie tej awarii. Trzymaj stronę w kształtowanym skrypcie w automatycznym korpusie smoke, a nie tylko w ręcznym zestawie sprawdzania, ponieważ arabski, dewanagari i khmerski wywierają nacisk na ścieżki domknięcia, których żadna ilość pokrycia łacińskiego nie dotknie. I zawsze, gdy istnieje akumulator, sprawdź w asercji, że coś go konsumuje, ponieważ pole tylko-do-zapisu to funkcja, która się kompiluje, testuje na zielono na niewłaściwym korpusie i nic nie robi

Gdzie kończy się naprawa

Domknięcie subsetu jest konieczne, aby kształtowany glif się wyrenderował, i nie jest wystarczające. Glif musi też być adresowalny ze strumienia treści, co jest osobnym problemem z własną granicą. Wbudowane przejścia dopracowania arabskiego HotPDF zatwierdzają substytucję tylko wtedy, gdy każdy zastępczy ID glifu jest osiągalny przez punkt kodowy formy prezentacyjnej Unicode przez odwrotny skan cmap po około 690 punktach kodowych od U+FB50 do U+FDFF i od U+FE70 do U+FEFF. Gdy zastępczy ląduje na ID glifu poza tym zakresem, okno wejściowe przechodzi niezmienione, zamiast emitować coś, czego czytnik nie może zaadresować; alternatywy specyficzne dla czcionki pod dowolnymi ID glifów potrzebują syntetycznego punktu kodowego prywatnego użytku przydzielonego w U+E000 do U+F8FF, by przenieść je przez ścieżkę emisji. Więc szczere podsumowanie jest takie, że poprawka 2.435.0 usunęła twardą blokadę, zamiast dokończyć historię. Przed nią glif mógł być poprawnie kształtowany, poprawnie emitowany i wciąż znikać w czasie subsettingu, co oznaczało, że silnikowi kształtowania nie można było ufać na całej długości, niezależnie od tego, jak dobre były jego wyszukiwania. To, co pozostaje, to adresowalność, a to ograniczenie przynajmniej zawodzi widocznie w punkcie emisji, a nie po cichu w kroku budowania uruchamianym po tym wszystkim, co obserwowałeś. Dla strony emisji tego samego pipeline'u zobacz przewodnik po kształtowaniu tekstu arabskiego i RTL w Delphi PDF

Subsetting czcionek, silnik GSUB i kształtowanie skryptów złożonych opisane tutaj są dostarczane w standardowym HotPDF Component dla Delphi i C++Builder; strona produktu zawiera pełne odniesienie API dla wywołań czcionek Unicode i kształtowania wymienionych powyżej