Artykuł techniczny

Kolorowe fonty emoji w PDF: COLR v1, SVG i bitmapy w Delphi

HotPDF rysuje kolorowe emoji do PDF przez THotPDF.DrawRegisteredColorGlyph, które czyta dane koloru fontu zarejestrowanego przez RegisterUnicodeTTF i emituje je jako natywną grafikę PDF: warstwy COLR v0 jako wypełnione kontury glifów, grafy malowania COLR v1 jako klipy, cieniowania i tryby mieszania, glify SVG jako Form XObjects, a bitmapy CBDT albo sbix jako obrazy. Czokolwiek, czego nie da się zmapować natywnie, idzie do zdarzenia OnColorGlyphRasterize, zamiast po cichu zamieniać się w czarny kształt

Ta ostatnia klauzula to cały powód istnienia tego kodu. Osadź font emoji zwyczajnie, a przeglądarka dostanie kontur z glyf albo CFF, wypełniony tym, czym akurat jest bieżący kolor wypełnienia. Uśmiechnięta buźka przychodzi jako czarna plama, flaga jako prostokąt, a nic w pipeline nie narzeka

Dlaczego kolorowe emoji drukuje się jako czarna sylwetka w PDF?

Program fontu w PDF nie ma pojęcia o kolorowych glifach. ISO 32000-1 traktuje glif jako kształt malowany bieżącym kolorem, a tabele koloru dodane później przez OpenType, czyli COLR/CPAL, SVG , CBDT/CBLC i sbix, nie są częścią modelu obrazowania PDF, więc żadna przeglądarka nie ma obowiązku czytać ich z osadzonego fontu. Kolor musi zostać przetłumaczony na treść strony w czasie generowania, póki producent wciąż trzyma bajty fontu i wie, którego glifa chce. To tłumaczenie różni się per format, a fonty emoji na wolności używają ich wszystkich: warstwowych wektorów, gradientowych grafów malowania, osadzonych dokumentów SVG i strike'ów PNG. HotPDF raportuje wynik jako THPDFOpenTypeColorFormat, z wartościami otcfNone, otcfCOLRv0, otcfCOLRv1, otcfCBDT, otcfSVG i otcfSBIX, i sonduje font w sztywnej kolejności: najpierw COLR, potem SVG, potem CBDT, potem sbix. Dane wektorowe wygrywają z bitmapami, ilekroć font niesie oba, czego chcesz w dokumencie, który może być powiększany albo drukowany

Diagram sondowania kolorowych glifów w HotPDF: program fontu w PDF maluje kontury glifów bieżącym kolorem, więc tabele koloru OpenType COLR, SVG, CBDT i sbix muszą być przetłumaczone na treść strony w czasie generowania, a HotPDF sonduje zarejestrowany font w sztywnej kolejności COLR, potem SVG, potem CBDT, potem sbix, raportując THPDFOpenTypeColorFormat od otcfCOLRv0 po otcfSBIX
Dane wektorowe wygrywają z bitmapami, ilekroć font niesie oba, czego chcesz w dokumencie, który może być powiększany albo drukowany, a glif bez ścieżki koloru zostawiony jest twojemu fallbackowi

Jedno wywołanie, pięć formatów: rozwiązywanie i rysowanie kolorowego glifa

THotPDF.GetRegisteredColorGlyphInfo odpowiada, którą ścieżkę pójdzie punkt kodowy, a DrawRegisteredColorGlyph ją realizuje. Obie szukają punktu kodowego w mapie znaków fontu ostatnio przekazanego do RegisterUnicodeTTF, więc font kolorowy musi być w chwili wywołania zarejestrowanym fontem Unicode. Funkcja rysująca zwraca False, gdy glif nie ma danych koloru albo żadna ścieżka nie umiała go wyrenderować, i zostawia fallback tobie

const
  FormatNames: array[THPDFOpenTypeColorFormat] of string =
    ('none', 'COLR v0', 'COLR v1', 'CBDT', 'SVG', 'sbix');
var
  Pdf: THotPDF;
  Info: THPDFOpenTypeColorGlyphInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'emoji.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\seguiemj.ttf');

    // U+1F600, paleta CPAL 0, strike bitmapowy najbliższy 300 ppem
    if Pdf.GetRegisteredColorGlyphInfo($1F600, 0, 300, Info) then
      Writeln(Format('GID %d via %s',
        [Info.GlyphID, FormatNames[Info.Format]]));

    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F600,
      72, 144, 'Segoe UI Emoji', 36, 0, 300) then
    begin
      // Brak danych koloru: fallback na monochromatyczny kontur
      Pdf.CurrentPage.SetFont('Segoe UI Emoji', [], 36, DEFAULT_CHARSET);
      Pdf.CurrentPage.TextOut(72, 144, 0, WideString(#$D83D#$DE00));
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Dwa parametry zasługują na uwagę. PaletteIndex wybiera paletę CPAL, więc font, który dowozi paletę pod ciemne tło, można przełączyć bez dotykania glifa. TargetPixelsPerEm ma znaczenie tylko dla fontów bitmapowych; zostawione na zerze daje domyślnie Round(FontSize * 96 / 72), rozdzielczość ekranową, dlatego przykład prosi o 300 przy wyjściu do druku. Uczciwy limit siedzi w sygnaturze: wywołanie bierze jeden punkt kodowy i mapuje go wyłącznie przez cmap. Sekwencje ZWJ, modyfikatory odcienia skóry i flagi regional-indicator to ligatury GSUB, więc ich składanie to problem shapingowy rodzaju opisanego w artykule o wariantach GSUB w OpenType, a nie coś, co ten punkt wejścia robi za ciebie

COLR v0: piętrzone warstwy glifów w kolorach z palety

COLR v0 to prosty przypadek i HotPDF renderuje go wprost: każdy glif bazowy wypisuje glify warstw z wpisem koloru CPAL, a każda warstwa staje się jedną zwyczajną operacją pokazywania tekstu z własnym kolorem wypełnienia, piętrowaną w kolejności tabeli. Warstwa z alfą poniżej 255 dostaje słownik parametrów stanu grafiki z pasującymi /ca i /CA (ISO 32000-1 §8.4.5), a każdy glif warstwy jest znacznikowany jako używany, więc subsetter zachowa jego kontur, choć żaden punkt kodowy nie mapuje się na niego wprost. Jeden szczegół zaskakuje: indeks wpisu palety 0xFFFF znaczy w specyfikacji OpenType „użyj koloru pierwszego planu tekstu”, a HotPDF rozwiązuje go na czarny, a nie na bieżący kolor wypełnienia strony. Dla fontów emoji to rzadko gra rolę; dla fontów ikonowych, które polegają na wpisie pierwszego planu, by barwić glifa, sprawdź wyjście, zanim założysz, że pójdzie za kolorem tekstu

Jak HotPDF zamienia graf malowania COLR v1 na operatory PDF?

Przez sparsowanie tabel malowania najpierw na płaski, ograniczony graf i dopiero potem mapowanie każdego węzła na konstrukcję PDF. Glif COLR v1 to nie lista warstw, tylko skierowany acykliczny graf rekordów malowania, w którym węzły bywają współdzielone przez PaintColrLayers i PaintColrGlyph. Parser tnie go na 4096 węzłów malowania, 64 poziomy głębokości i 1024 kolorostopy i prowadzi ewidencję każdego węzła jako aktywnego albo skończonego, więc odwołanie z powrotem do aktywnego węzła, cykl, jaki złośliwy font zbuduje z reużycia warstw, jest odrzucane, a nie rekursowane. Bazy offsetów to miejsce, gdzie pierwsza implementacja się wywraca. Offsety BaseGlyphPaintRecord są względem początku BaseGlyphList, offsety malowania LayerList są względem LayerList, a każdy Offset24 wewnątrz tabeli malowania jest względem tej tabeli malowania samej. Rozwiąż wszystkie trzy względem tej samej bazy, a całkowicie legalne glify padają na sprawdzeniu granic, co wygląda dokładnie jak uszkodzony font. Gdy graf stoi, mapowanie jest wprost:

  • PaintGlyph ustawia kontur glifa jako klip z trybem renderowania tekstu 7 (ISO 32000-1 §9.3.6), po czym maluje swoje dziecko wewnątrz niego
  • Pełne malowania wypełniają przycięty prostokąt; gradienty liniowe stają się osiowymi cieniowaniami wielostopowymi, a gradienty promieniste dwukolorowymi cieniowaniami promienistymi (§8.7.4.5)
  • Gradienty sweep nie mają odpowiednika w PDF, więc HotPDF przybliża je 96 klinami o płaskim kolorze, każdy próbkowany z linii koloru
  • Transformacje są emitowane jako cm, sprzężone wokół początku linii bazowej glifa, z przesunięciami skalowanymi przez FontSize / UnitsPerEm
  • Tryby PaintComposite od 13 do 27 mapują się na rozdzielalne i nierozdzielalne tryby mieszania PDF, takie jak /Multiply, /Screen i /Luminosity (§11.3.5), ustawiane wpisem /BM w ExtGState

Granica jest jawna. Tryby Porter-Duff od 5 do 12 (src_in, xor, plus i reszta) nie mają odpowiednika wśród trybów mieszania PDF, tryby rozszerzania repeat i reflect na gradientach liniowych i promienistych nie są emitowane, a gradienty, których stopy niosą różne wartości alfy, nie są imitowane pojedynczą przezroczystością. Gradienty promieniste z więcej niż dwoma stopami zachowują tylko pierwszy i ostatni kolor. HotPDF sprawdza cały graf względem tego obsługiwanego podzbioru, zanim napisze choćby jeden operator, więc nieobsługiwany glif zostawia stronę nietkniętą i przechodzi do fallbacku rastrowego, zamiast zostawiać połowę rysunku

Diagram konwersji COLR v1 w HotPDF: graf malowania jest parsowany na ograniczony graf z limitem 4096 węzłów, 64 poziomów głębokości i 1024 kolorostopów z odrzucaniem cykli, po czym PaintGlyph staje się klipem trybu 7, gradienty liniowe i promieniste cieniowaniami osiowymi i promienistymi, gradienty sweep 96 klinami, a tryby PaintComposite od 13 do 27 trybami mieszania PDF
Cały graf jest sprawdzany względem obsługiwanego podzbioru, zanim zostanie napisany pierwszy operator, więc nieobsługiwany glif zostawia stronę nietkniętą i przechodzi do fallbacku rastrowego, zamiast zostawiać pół rysunku

Glify SVG i strike'e bitmapowe

Glify SVG idą przez ten sam ograniczony builder, którego HotPDF używa dla importowanych plików SVG, a wynik jest rejestrowany jako Form XObject (§8.10), dokładnie jak opisano w artykule o konwersji SVG na Form XObject. Dokument w tabeli SVG bywa skompresowany gzipem; dekompresja biegnie w kawałkach po 8 KB i zatrzymuje się, jak tylko rozmiar po rozwinięciu miałby przekroczyć 32 MB, zamiast najpierw rozdmuchiwać i sprawdzać potem, a samo skompresowane wejście ma limit 8 MB. Profil jest restrykcyjny z premedytacją: skrypty, osadzone obrazy, zewnętrzne URL-e, URI data: i odwołania nielokalne zawodzą w trybie fail-closed. Formularz jest skalowany tak, by jego dłuższy bok równał się rozmiarowi fontu, i kotwiczony na linii bazowej, co mapuje układ współrzędnych SVG y-w-dół na y-w-górę w PDF. Miej świadomość, że builder dostaje cały dokument SVG dla glifa, bez selekcji elementu glyphNNN, więc fonty pakujące wiele glifów w jeden współdzielony dokument warto przetestować, zanim im zaufasz

Fonty bitmapowe to kwestia wyboru strike'a i umiejscowienia. Dla CBDT HotPDF wybiera rozmiar CBLC, którego pionowe ppem jest najbliższe TargetPixelsPerEm, przyjmuje formaty obrazów 17, 18 i 19, a metryki formatu 19 czyta z podtabeli indeksu CBLC, bo ten format nie przechowuje żadnych własnych. Dla sbix offsety strike'ów są względem tabeli, a offsety glifów względem strike'a, a rekord dupe reużywa grafiki innego glifa, zachowując własne offsety początku; pozwolenie rekursji nadpisać zewnętrzny początek przesuwa obraz. Ładunki PNG i JPEG są dekodowane wewnętrznie, skalowane przez FontSize / PixelsPerEmY zamiast rozciągane do rozmiaru fontu, i zapisywane z miękką maską (§11.6.5.3), gdy tylko jakikolwiek piksel nie jest w pełni kryjący. Ładunki sbix TIFF nie są dekodowane i idą do zdarzenia

Co się dzieje, gdy glifa nie da się narysować natywnie?

HotPDF odpala OnColorGlyphRasterize i umieszcza cokolwiek, co zwróci twój handler jako bitmapę RGBA; jeśli nic nie jest przypisane albo handler zostawia Handled na false, DrawRegisteredColorGlyph zwraca False, a strona zostaje bez zmian. Zdarzenie odpala się dla grafu COLR v1 poza obsługiwanym podzbiorem, dokumentu SVG odrzuconego przez bezpieczny builder i ładunku bitmapowego, którego wewnętrzne dekodery nie czytają. Handler dostaje format, surowe bajty fontu, wyciągnięty zasób (dokument SVG, być może wciąż w gzipie, albo bajty bitmapy; pusto dla COLR v1), identyfikator glifa, paletę i docelowy rozmiar w pikselach

Diagram fallbacku rastrowego w HotPDF: OnColorGlyphRasterize odpala się dla grafu COLR v1 poza obsługiwanym podzbiorem, dokumentu SVG odrzuconego przez bezpieczny builder albo ładunku bitmapowego, którego dekodery nie czytają, podając format, bajty fontu, zasób, GlyphID, PaletteIndex i PixelSize, a zwrócony bufor RGBA jest przyjmowany tylko wtedy, gdy jego długość to dokładnie Width razy Height razy 4
Rozmiary zerowe, zła długość bufora albo wymiary przepełniające są odrzucane, zanim strona zostanie dotknięta, a bez handlera albo przy Handled false wywołanie zwraca False i strona zostaje bez zmian
type
  TEmojiFallback = class
  public
    procedure Rasterize(Sender: TObject;
      Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
      const AssetData: TBytes; GlyphID: Word;
      PaletteIndex, PixelSize: Integer;
      out Width, Height: Integer; out RGBA: TBytes;
      out Handled: Boolean);
  end;

procedure TEmojiFallback.Rasterize(Sender: TObject;
  Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
  const AssetData: TBytes; GlyphID: Word;
  PaletteIndex, PixelSize: Integer;
  out Width, Height: Integer; out RGBA: TBytes;
  out Handled: Boolean);
begin
  Width := 0;
  Height := 0;
  RGBA := nil;
  // RenderWithOwnEngine to twój rasteryzer, a nie API HotPDF.
  // Musi zwrócić dokładnie Width * Height * 4 bajtów RGBA.
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

// Podpięcie
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;

HotPDF waliduje wyjście handlera, zanim dotknie strony: rozmiary zerowe, bufor o długości innej niż dokładnie Width * Height * 4 albo wymiary na tyle duże, by przepełnić się, są odrzucane, a wywołanie zwraca False. Fallback rastrowy to nadal raster, więc emoji wyrenderowane tą drogą traci wektorową ostrość; proś o PixelSize pasujący do rozdzielczości wyjściowej. Sparuj ścieżkę koloru z kontrolami pokrycia w czasie rysowania z artykułu o śledzeniu brakujących glifów, a pipeline obsługujący dowolny tekst użytkownika będzie umiał zgłosić i brakujące glify, i glify, które straciły kolor

Rasteryzer kolorowych glifów, stack shapingu OpenType i bezpieczny builder SVG trafiają razem do komponentu PDF HotPDF dla Delphi, dostępnego dla Delphi i C++Buildera