Artykuł techniczny

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

Emoji uśmiechniętej twarzy zapisane do PDF zwykłym wywołaniem tekstowym, z Segoe UI Emoji jako czcionką, wychodzi jako płaska czarna sylwetka: właściwy kształt, bez żółci, bez cieniowania. Podmień na font emoji CBDT i to samo wywołanie może nie narysować niczego, bo ten font trzyma swoje obrazki jako pliki PNG i ma mało albo wcale danych obrysów. Skopiuj czarny kształt z przeglądarki, a schowek wciąż trzyma U+1F600: znak jest właściwy, po prostu tabel koloru nigdy nie przeczytano

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

Ponieważ program czcionki PDF nie ma pojęcia o kolorowym glifie. ISO 32000-1 traktuje każdy glif jako kształt malowany bieżącym kolorem wypełnienia, a tabele koloru dodane później do OpenType (COLR/CPAL, SVG , CBDT/CBLC i sbix) nie są częścią modelu obrazowania PDF, więc żadna przeglądarka nie ma powodu czytać ich z osadzonego fontu. Jakikolwiek kolor ma emoji, musi zostać zamieniony w treść strony w trakcie pisania PDF, w punkcie, w którym producent wciąż trzyma bajty fontu i wie dokładnie, którego glifa chce. To tłumaczenie jest różne dla każdego formatu, a prawdziwe fonty emoji używają wszystkich czterech: piętrowych warstw wektorowych, grafów malowania gradientami, całych dokumentów SVG i strike'ów PNG

HotPDF robi to tłumaczenie w THotPDF.DrawRegisteredColorGlyph. Raportuje, do którego formatu rozwiązał się glif, jako THPDFOpenTypeColorFormat, z wartościami otcfNone, otcfCOLRv0, otcfCOLRv1, otcfCBDT, otcfSVG i otcfSBIX, i sonduje font w stałej kolejności: COLR, potem SVG, potem CBDT, potem sbix. Font niosący i wektory, i bitmapy dostaje więc ścieżkę wektorową. Dla czegokolwiek, co będzie powiększane albo drukowane, to właściwa kolejność

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

Rozwiązywanie i rysowanie pojedynczego punktu kodowego

THotPDF.GetRegisteredColorGlyphInfo mówi ci, którą ścieżkę pójdzie punkt kodowy; DrawRegisteredColorGlyph nią idzie. Obie szukają punktu kodowego w mapie znaków fontu najdawniej przekazanego do RegisterUnicodeTTF, więc font emoji musi być zarejestrowanym fontem Unicode w chwili wywołania. Funkcja rysująca zwraca False, gdy glif nie ma danych koloru albo niczego nie umiało go wyrenderować, i strona zostaje wtedy nietknięta

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';
    // Wpis palety 0xFFFF ("kolor tekstu") rozwiązuje się do tego koloru
    Pdf.ColorGlyphForegroundColor := clNavy;
    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 ścieżki koloru: spadnij na jednokolorowy obrys
      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;

PaletteIndex wybiera paletę CPAL, więc font wysyłający paletę na ciemne tło można przełączyć bez dotykania glifa. Wpis palety 0xFFFF jest specjalny w specyfikacji OpenType: znaczy "cokolwiek jest kolorem tekstu", a HotPDF wypełnia go z ColorGlyphForegroundColor i ColorGlyphForegroundAlpha (domyślnie czarny i 255). Fonty emoji rzadko używają tego wpisu. Fonty ikon tak, i jeśli twój tintuje glify przez niego, ustaw właściwość na kolor tekstu przed rysowaniem

TargetPixelsPerEm ma znaczenie tylko dla fontów bitmapowych, a jego domyślna to parametr najbardziej wart nadpisania. Pozostawione zero staje się Round(FontSize * 96 / 72), rozdzielczością ekranową: 48 pikseli dla emoji 36-punktowego. Dla fontu deklarującego strikey sbix przy 20, 32, 40, 48, 64, 96 i 160 ppem ta domyślna wybiera strike 48-pikselowy, a drukarka 600 dpi musi potem powiększyć go około sześciokrotnie. Poproś o 300, czyli tyle, ile 36 punktów potrzebuje przy 600 dpi, i to samo wywołanie bierze strike 160, największy, jaki font ma

Sygnatura przy okazji zamyka zakres. Jeden punkt kodowy wchodzi do środka i jest mapowany przez cmap. Sekwencje rodzinne ZWJ, modyfikatory odcienia skóry i flagi z regionalnych wskaźników to ligatury GSUB, więc najpierw trzeba je ukształtować do glifa — to ta robota, którą omawia artykuł o alternatach GSUB OpenType

Warstwy COLR i grafy malowania jako wektory PDF

COLR v0 to prosty format. Każdy glif bazowy wylicza stos warstw glifów, każda z kolorem CPAL, a HotPDF wypełnia obrys każdej warstwy w kolejności z tabeli. Warstwa, której kolor CPAL ma alfę poniżej 255, dostaje własne /ca, więc półprzezroczyste warstwy mieszają się z warstwami pod nimi dokładnie tak, jak zamierzał projektant fontu. W zwykłym dokumencie cały stos jest potem przechwytywany w jedną izolowaną grupę przezroczystości (ISO 32000-1 §11.4). Grupa dotyczy tego, co dzieje się na zewnątrz glifa: krycie, tryb mieszania albo miękka maska ustawione wokół emoji są nakładane raz na skończony glif, zamiast powtarzać się na każdej warstwie i podwajać się wszędzie tam, gdzie warstwy na siebie nachodzą

COLR v1 to inne zwierzę: skierowany acykliczny graf rekordów malowania z gradientami, transformacjami, zagnieżdżonymi klipami glifów i trybami składania, gdzie węzły mogą być współdzielone przez PaintColrLayers i PaintColrGlyph. HotPDF parsuje go najpierw na ograniczony graf, z limitem 4096 węzłów malowania, 96 poziomów głębokości i 1024 stopni koloru, i śledzi każdy węzeł podczas odwiedzania, tak żeby odwołanie z powrotem do bieżącej ścieżki (cykl, który wrogi font potrafi zbudować z ponownego użycia warstw) zostało odrzucone, zamiast wchodzić w rekurencję

Offsety to miejsce, gdzie ręcznie pisane parsery COLR v1 zwykle się łamią. Offsety BaseGlyphPaintRecord są względne do początku BaseGlyphList, offsety malowania LayerList do LayerList, a każdy Offset24 wewnątrz tabeli malowania do samej tej tabeli. Rozwiążesz wszystkie trzy względem jednej podstawy i idealnie legalne glify wypadają kontroli zakresów, co wygląda dokładnie jak uszkodzony font. Gdy graf jest już zbudowany, mapowanie na PDF jest w większości bezpośrednie:

  • PaintGlyph zamienia obrys glifa w ścieżkę przycinającą, a jego dziecko maluje przycięty obszar
  • Gradienty liniowe stają się wielostopniowymi cieniowaniami osiowymi, a gradienty promieniste cieniowaniami promienistymi (§8.7.4.5)
  • Gradienty sweep nie mają typu cieniowania PDF, więc rysują się jako 96 jednokolorowych klinów wokół środka, każdy próbkowany z linii koloru
  • Transformacje stają się operatorami cm, z przesunięciami skalowanymi przez FontSize / UnitsPerEm
  • Tryby PaintComposite od 13 do 27 mapują się na tryby mieszania PDF o tych samych nazwach, /Multiply, /Screen, /Luminosity i resztę (§11.3.5), przez wpis /BM w ExtGState

Gradienty z półprzezroczystymi stopniami kryją jeszcze jedną pułapkę. COLR v1 interpoluje kolory premultiplied przez alfę, podczas gdy PDF oczekuje koloru prostego i sam nakłada alfę, więc konwerter, który wręczy PDF wartości premultiplied, przyciemnia każde półprzezroczyste pasmo podwójnie. Kolory trzeba odwrócić z premultiplication po interpolacji, a przed ich zapisaniem

Diagram konwersji COLR v1 w HotPDF: graf malowania jest parsowany na ograniczony graf z limitem 4096 węzłów, 96 poziomów głębokości i 1024 stopni koloru z odrzucaniem cykli, po czym PaintGlyph staje się ścieżką przycinającą obrys, 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

Co się dzieje z trybami składania COLR v1 pod PDF/A-1 albo PDF/X-1a?

Tryby Porter-Duff w PaintComposite (od 5 do 12: src_in, dest_out, src_atop, xor, plus i rodzeństwo) nie mają trybu mieszania PDF o tej samej nazwie, a oczywista lektura specyfikacji PDF mówi, że nie da się ich wyrazić. Ta lektura jest zbyt mocna. Tryby in/out to miękka maska alfy nad jednym operandem; atop, xor i plus da się złożyć z form izolowanych, alfy grupy, masek luminancji i arytmetyki mieszania. HotPDF składa je właśnie tak, więc te glify zostają wektorami

Koszt jest taki, że większość tych konstrukcji potrzebuje przezroczystości PDF, a PDF/A-1, PDF/X-1a i PDF/X-3 jej zakazują. Gdy PDFACompliance ma wartość PDF/A-1 albo PDFXCompliance jest 'X-1a' albo 'X-3', HotPDF sprawdza glifa przed rysowaniem i kieruje przypadki, których nie da się narysować bez przezroczystości, do zdarzenia rastrowego: złożenia atop, xor i plus, złożenia in/out, których maskowany operand to więcej niż proste nieprzezroczyste malowanie, pełne malowania z częściową alfą, gradienty z półprzezroczystymi stopniami i warstwy COLR v0 z alfą poniżej 255. Tryby in i out zostają wektorami, gdy maskowana strona to zwykły pełny kolor albo gradient, opcjonalnie owinięty w klip glifa albo transformację, bo ten przypadek potrzebuje wyłącznie nieprzezroczystej maski w skali szarości. To samo dotyczy COLR v0, gdy StrictVersionLock przypina plik poniżej PDF 1.4

Pdf.PDFACompliance := 'B';   // PDF/A-1b: przezroczystość zabroniona
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF(EmojiFontPath);

if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F308,
  72, 200, EmojiFontName, 36, 0, 300) then
  // Bez handlera OnColorGlyphRasterize półprzezroczysty
  // albo złożony glif kończy tutaj pod PDF/A-1
  LogMissingColorGlyph($1F308);   // twoja własna procedura logowania

Jeśli archiwum ma pokazywać emoji w kolorze, celuj w PDF/A-2b zamiast PDF/A-1b. Część 2 pozwala na przezroczystość, każda konstrukcja COLR zostaje wektorem, a handler zastępczy staje się siatką bezpieczeństwa, a nie głównym rendererem każdego półprzezroczystego emoji. Zostaw PDF/A-1 dla przepływów, które wymagają go z nazwy

Jedno ustawienie po stronie czytelnika też należy do tego miejsca. HotPDF składa grupy COLR v1 w przestrzeni mieszania światła liniowego; część rendererów gubi bardzo ciemne kanały w takich grupach, a część ignoruje przestrzenie mieszania grup całkiem, więc COLRv1DeviceSpaceGroupBlend (domyślnie False) każe grupom składać się w zwykłej przestrzeni urządzenia. Kompromis jest wyjaśniony w artykule o gradientach repeat i reflect COLR v1; włącz, jeśli ciemne półprzezroczyste tinty znikają w przeglądarce, którą twoi użytkownicy faktycznie mają

Glify SVG i strike'e bitmapowe

Glify SVG idą przez ten sam bezpieczny builder, którego HotPDF używa dla importowanych plików SVG, a wynik jest rejestrowany jako Form XObject (§8.10), jak opisano w artykule o SVG do Form XObject. Dokument w tabeli SVG może być skompresowany gzipem. Skompresowane wejście ma limit 8 MB, dekompresja biegnie w kawałkach po 8 KB i staje, jak tylko rozmiar po rozpakowaniu miałby przejść 32 MB. Skrypty, foreignObject, URL-e javascript: i arkusze stylów @import każą builderowi odrzucić dokument od razu. Forma jest skalowana tak, że jej dłuższy bok równa się rozmiarowi czcionki. Jedna rzecz do sprawdzenia przed produkcją: część fontów pakuje wiele glifów w jeden współdzielony dokument SVG i builder dostaje cały ten dokument dla każdego glifa

Fonty bitmapowe sprowadzają się do 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 i czyta metryki formatu 19 z podtabeli indeksu CBLC, bo ten format nie składowuje własnych. Dla sbix wygrywa najbliższy strike, który faktycznie zawiera glifa; offsety strike'ów są względne do tabeli, a offsety glifów do strike'a. Rekord dupe używa ponownie obrazu innego glifa, ale trzyma własne offsety początku, i parser, który pozwoli wyszukiwaniu nadpisać zewnętrzny początek, przesunie obraz w bok. Ładunki PNG i JPEG są dekodowane wewnętrznie, skalowane przez FontSize / PixelsPerEmY zamiast rozciągane do rozmiaru czcionki i zapisywane z miękką maską (§11.6.5.3), ilekroć jakikolwiek piksel jest mniej niż w pełni kryjący. Ładunki TIFF w sbix idą do zdarzenia

Pisanie handlera zastępczego rastrowego

Gdy żadna natywna ścieżka nie działa, HotPDF podnosi OnColorGlyphRasterize i umieszcza bitmapę RGBA, którą zwróci twój handler. Bez handlera albo z Handled pozostawionym False DrawRegisteredColorGlyph zwraca False i nic nie jest rysowane. Handler dostaje format, bajty fontu, wyekstrahowany zasób (dokument SVG, być może wciąż w gzipie, albo bajty bitmapy; puste dla COLR), identyfikator glifa, paletę i docelowy rozmiar pikselowy

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, nie API HotPDF.
  // Musi zwrócić dokładnie Width * Height * 4 bajtów RGBA
  // z prostą (nie premultiplied) alfą
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

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

HotPDF waliduje wynik, zanim strona go zobaczy: 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. Dla zmiennego fontu kolorów handler dostaje statyczną instancję przy zarejestrowanych wartościach osi; jeśli kiedykolwiek dostanie oryginalny font zmienny zamiast niej, wołanie GetRegisteredVariableFontAxes na Sender (rzutowanym na THotPDF) zwróci współrzędne do renderowania. PixelSize to ta sama wartość TargetPixelsPerEm, więc porada o rozdzielczości drukarskiej z góry stosuje się i tutaj

Potok drukujący dowolny tekst od użytkownika musi wiedzieć o dwóch rodzajach strat: glifach, których font nigdy nie miał, i glifach, które straciły kolor. Śledzenie pokrycia w czasie rysowania z artykułu o śledzeniu brakujących glifów raportuje pierwsze; False z DrawRegisteredColorGlyph raportuje drugie

Renderowanie emoji COLR, SVG i bitmapowego, kształtowanie OpenType i bezpieczny builder SVG są częścią HotPDF component for Delphi and C++Builder