Artykuł techniczny

Wykrywanie brakujących glifów PDF w czasie rysowania

Brakujący glif w PDF nie jest błędem. Producent prosi o znak, którego wybrany font nie potrafi zmapować, font zwraca indeks glifa zero, a plik, który wychodzi, jest strukturalnie poprawny, otwiera się wszędzie i pokazuje pusty prostokąt tam, gdzie powinna być nazwa albo kwota. Nikt w generującym potoku się nie dowiaduje. Odbiorca tak. HotPDF zamyka tę pętlę przez TrackUnresolvedGlyphs: włącz to, a ścieżka rysowania tekstu zapisuje każdy punkt kodowy, którego wyszukiwanie glifa rozwiązuje się do indeksu zero, odpalając OnUnresolvedGlyph raz na unikalne znalezisko z punktem kodowym, fontem, na którym zawiodło, skryptem, do którego należy, i sugestią fontów, które by to pokryły

Detekcja to połowa odpowiedzi. Drugą połową jest SetFontFallbackChain, która rejestruje uporządkowaną listę fontów per skrypt, więc typowe przypadki rozwiązują się same i tylko prawdziwe luki docierają do twojego handlera. Razem zamieniają klasę defektów, o których kiedyś raportowali klienci, w kontrolę czasu budowania

Czemu brakujący glif niczego nie zgłasza?

Bo ISO 32000 nie nakłada na producenta obowiązku weryfikacji pokrycia, a indeks glifa zero to legalny glif. To .notdef, którego kontur wybiera projektant fontu: zwykle prostokąt pusty lub pusty w środku, czasem nic w ogóle. Przeglądarka, która go rysuje, zachowuje się poprawnie. Ekstrakcja tekstu może nawet zwrócić właściwe znaki, bo mapowanie /ToUnicode jest pisane z tekstu źródłowego, a nie z konturów, więc automatyczna kontrola w obie strony chętnie przepuści dokument, którego widoczny tekst ma dziury

Diagram czemu brakujący glif PDF milczy: glif zero rysuje pusty prostokąt, a ekstrakcja ToUnicode przechodzi kontrole w obie strony
Glif zero to legalna odpowiedź .notdef, a /ToUnicode jest pisane z tekstu źródłowego, więc nic w potoku nie zostaje poinformowane o luce

Praktyczną konsekwencją jest to, że pokrycie musi być sprawdzane w chwili rysowania, gdy biblioteka wciąż wie, który punkt kodowy był żądany i który glif font faktycznie zaoferował. Potem tej informacji już nie ma

Detektor ma obserwować stan subsetu, nie kontekst urządzenia

Tu pierwsza implementacja poszła źle, a powód jest wart zrozumienia, bo dotyczy każdej kontroli pokrycia przykręconej do potoku tekstu. HotPDF ma dwie ścieżki tekstu. Jedna emituje przez zarejestrowany font TrueType Unicode z mapą znaków w pamięci budowaną w chwili rejestracji. Druga to starsza ścieżka GDI, która tworzy świeży kontekst urządzenia i uchwyt fontu per przebieg znaków

Ocenianie pokrycia ze ścieżki GDI jest beznadziejne. Jej mapowanie nie jest tym, które trafia do emitowanego strumienia zawartości, a oba nie są zsynchronizowane, więc detektor czytający wyniki GDI zgłasza cały drukowalny zakres ASCII jako nierozwiązany. Autorytatywna odpowiedź mieszka w zarejestrowanym foncie: mapa znaków, którą parsuje RegisterUnicodeTTF, odpytywana przez GetUnicodeGlyphForCodepoint. Detektor jest więc bramkowany na stanie gotowości subsetu, nie na żadnym warunku GDI, i po prostu nie działa na dokumentach, które nigdy nie zarejestrowały fontu Unicode, co jest poprawne, bo te dokumenty zresztą są ograniczone do standardowych kodowań

Druga pułapka siedzi obok. Rodzinna nazwa GDI fontu i nazwa PostScript wydobyta z binarium fontu przy rejestracji to różne łańcuchy, i to nie w sposób dający się znormalizować: rodzina zwana Arial Unicode MS niesie nazwę PostScript ArialMT. Każda bramka napisana jako „czy aktualnie wybrany font to ten, który zarejestrowaliśmy”, porównywana po nazwie, to martwy kod, który nigdy nie odpala. Bramkuj na stanie, nigdy na nazwach fontów

Przepływ detekcji nierozwiązanych glifów HotPDF pokazujący bramkę stanu subsetu, odpytanie GetUnicodeGlyphForCodepoint i podpięcie zdarzenia OnUnresolvedGlyph
Pokrycie jest oceniane z zarejestrowanej mapy fontu Unicode, a nie z GDI, a każdy unikalny punkt kodowy odpala jedno zdarzenie z sugestią fontu

Nie testuj detektora glifów emoji

Oczywistym przypadkiem testowym jest uśmiechnięta buźka i przekona cię, że detektor jest zepsuty. Typowe punkty kodowe emoji w płaszczyznach astralnych rozwiązują się przez ścieżkę syntezy private-use, która mapuje je bezpośrednio na indeks glifa, więc nigdy nie docierają do ogólnej gałęzi pokrycia. Detektor zachowuje się poprawnie, a test mierzy złą ścieżkę

Użyj zamiast tego nieprzypisanego punktu kodowego. U+0378 jest na stałe nieprzydzielony w Unicode, więc żaden font nie może go legalnie zmapować, i ćwiczy dokładnie gałąź, którą chcesz zweryfikować. To rozróżnienie między „funkcja jest zepsuta” a „test wybrał wejście omijające funkcję” kosztuje realne godziny, a nieprzypisane punkty kodowe to najtańszy sposób, by go uniknąć

type
  TCoverageAudit = class
  private
    FFindings: TStringList;
  public
    procedure Handle(Sender: TObject;
      const Info: THPDFUnresolvedGlyphInfo);
    property Findings: TStringList read FFindings;
  end;

procedure TCoverageAudit.Handle(Sender: TObject;
  const Info: THPDFUnresolvedGlyphInfo);
begin
  // Odpala raz na unikalny punkt kodowy, nie raz na wystąpienie
  FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
    [Info.CodePoint, String(Info.FontName), Ord(Info.Script),
     String(Info.SuggestedFonts)]));
end;

// Podpięcie do generującego zadania
Pdf := THotPDF.Create(nil);
try
  Pdf.TrackUnresolvedGlyphs := True;
  Pdf.OnUnresolvedGlyph := Audit.Handle;
  Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\arial.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(50, 720, 0, CustomerName);
  Pdf.EndDoc;
  if Audit.Findings.Count > 0 then
    // Zawal zadanie, zamiast wysyłać stronę z prostokątami
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

Łańcuchy fallbacku są per skrypt, nie per font

Powodem, dla którego fallback jest zakreślony skryptem, a nie fontem źródłowym, jest to, że luki pokrycia klastrowane są po systemie pisma. Łaciński font tekstowy nie ma dewanagari, tajskiego, Han i emoji, wszystko naraz, a zamiennik każdego z nich to inny font. Deklarowanie jednego łańcucha per skrypt opisuje więc realne wdrożenie: jeden font łaciński do tekstu, jeden font CJK, jeden font emoji, jeden awaryjny

Diagram fallbacku fontów per skrypt mapujący skrypty hfsCJK, hfsArabic, hfsEmoji i hfsOther na uporządkowane łańcuchy fontów zamiennych w HotPDF
Każdy skrypt dostaje własny uporządkowany łańcuch, więc łaciński font tekstu bez Han, arabskiego albo emoji spada do fontu, który to pokrywa
// THPDFFontScript covers hfsCommon, hfsLatin, hfsGreek, hfsCyrillic,
// hfsHebrew, hfsArabic, hfsIndic, hfsSoutheastAsian, hfsCJK, hfsKana,
// hfsHangul, hfsEmoji and hfsOther
Pdf.SetFontFallbackChain(hfsCJK,
  ['Microsoft YaHei', 'SimSun', 'Yu Gothic']);
Pdf.SetFontFallbackChain(hfsArabic, ['Segoe UI', 'Arial']);
Pdf.SetFontFallbackChain(hfsEmoji, ['Segoe UI Emoji']);
Pdf.SetFontFallbackChain(hfsOther, ['Arial Unicode MS']);

Fallback i detekcja są komplementarne, a nie alternatywne. Łańcuchy obsługują przewidziane pokrycie; detektor zgłasza to, którego nie przewidziano, co w systemie przetwarzającym dowolne dane klientów jest tą interesującą połową. Zauważ, że podstawienie fontu zmienia metryki, więc akapit, który spadł do fallbacku, może się przemieścić; jeśli układ się liczy, zachowanie domknięcia i wydzielania podzbiorów podstawionego fontu warto poczytać w artykule o domykaniu podzbioru fontu, a skrypty wymagające przestawiania albo łączenia są obsługiwane przez etap kształtowania opisany w kształtowaniu tekstu skryptów złożonych

Jak dorobić zachowanie bez ryzykowania istniejącej ścieżki

To samo wydanie dodało fallback starszej tabeli kern dla odstępów par, a sposób jego zakreślenia to wzorzec warty skopiowania. Zamiast dodawać nowy punkt decyzyjny do logiki kerningu, fallback zawiesił się na gałęzi wczesnego wyjścia, która już istniała dla fontów bez tabeli GPOS. Nowoczesny font z GPOS nigdy do niej nie dociera, więc jego zachowanie jest niezmienione konstrucją, a nie testowaniem. Ścieżki nie rejestrujące fontu Unicode produkują dwa zera offsetów, więc one też są niezmienione

To ogólny kształt niskoryzykowego dorobienia w dojrzałej bibliotece renderującej: znajdź gałąź, która obecnie niczego nie produkuje, i umieść tam nowe zachowanie. Zamienia to „wierzymy, że to niczego nie zregresowało” w „to nie mogło niczego zregresować”, co jest znacznie lepszą rzeczą do powiedzenia o silniku tekstu, przez który przechodzą cudze faktury

Zrób z tego bramkę, nie log

Znaleziska pokrycia są użyteczne tylko, gdy coś na nich zawodzi. W usłudze generującej dokumenty produktywne ustawienie to trzymanie śledzenia włączonego w nocnym zadaniu regresji na korpusie prawdziwych nazwisk klientów, adresów i opisów produktów oraz zawalenie zadania przy każdym znalezisku. Ponieważ zdarzenie odpala raz na unikalny punkt kodowy, a nie raz na wystąpienie, wyjście pozostaje małe na tyle, by dało się czytać, nawet gdy cały skrypt jest nieobecny

Na produkcji ten sam handler lepiej działa jako telemetria: zapisuj punkt kodowy i font, serwuj dokument dalej i pozwól agregatowi powiedzieć ci, który skrypt dodać następny do zestawu fontów wdrożenia. Zachowanie renderowania dla fontów osadzonych i podstawionych jest omówione dalej w renderowaniu glifów fontów osadzonych, a pełna lista własności łącznie z TrackUnresolvedGlyphs jest udokumentowana na stronie produktu HotPDF Delphi PDF component