HotPDF Component maluje gradienty COLRv1 repeat i reflect jako wektorowe shadingi PDF zamiast rastrówać glif. Geometria jest rozciągana, by pokryć całkowitą liczbę cykli linii koloru, każdy cykl dostaje własny shading osiowy albo radialny z wyłączonym Extend, kolory są interpolowane w CalRGB w świetle liniowym, a tryby kompozycji Porter-Duff in/out są wyrażane soft maskami luminosności
Kiedy pisaliśmy o tym, jak HotPDF rysuje fonty COLR v1 i kolorowe emoji SVG w Delphi, tryby extend repeat i reflect były na liście rzeczy, których ścieżka natywna nie emitowała, i takie glify szły na rasterowy fallback. Nowsze wersje HotPDF rysują je wektorowo. Drogę tam wyznaczyło kilka fałszywych startów, większość spowodowana przez rozsądnie wyglądające założenie o PDF, które okazało się złe w co najmniej jednej szeroko używanej przeglądarce. Ten post przechodzi przez te założenia, bo każdy, kto mapuje gradienty CSS, SVG albo OpenType na PDF, spotka te same
Dlaczego przeglądarka PDF nie powtórzy gradientu za ciebie?
Shading PDF nie ma trybu repeat ani reflect, więc producent musi rozłożyć każdy cykl sam. ISO 32000-1 §8.7.4.5.3 i §8.7.4.5.4 definiują /Extend dla shadingów osiowych i radialnych, a on robi wyłącznie to, że kontynuuje kolor przy t0 albo t1 poza końcem osi. To jest COLRv1 EXTEND_PAD i nic poza tym. OpenType COLR v1 definiuje też EXTEND_REPEAT, gdzie linia koloru zaczyna od nowa za każdym razem, gdy parametr przechodzi liczbę całkowitą, i EXTEND_REFLECT, gdzie każdy drugi cykl biegnie w tył
Pierwsza próba HotPDF próbowała zmusić funkcję do pracy okresowej. Rejestrowała jedną próbkowaną funkcję Type 0 (§7.10.2) z szeroką domeną, w przybliżeniu [-12.73, 12.73] dla rozpiętości 25 cykli, wypełniała ją złożonymi próbkami i włączała Extend. Wewnątrz pierwszego cyklu minimalny plik testowy renderował się poprawnie; na zewnątrz MuPDF malował jeden uśredniony kolor, a Poppler nie był lepszy. Obie przeglądarki liczą funkcję wzdłuż parametru shadingu i dopełniają poza osią, i żadna nie wraca, by odpytać funkcję okresową w regionie rozszerzonym. Szeroka domena była też błędnym odczytaniem specyfikacji: parametr shadingu t jest przekazywany prosto do funkcji, a według §7.10.1 wejście poza domeną funkcji jest przycinane, więc funkcja, której domena nie zgadza się z /Domain shadingu (domyślnie [0 1]), ma czytaną tylko część swoich próbek
Działający projekt ma trzy części, wszystkie zweryfikowane względem renderów MuPDF i Popplera. Geometria shadingu fizycznie pokrywa całkowitą liczbę N cykli, więc żaden piksel nie zależy od Extend. Próbkowana funkcja trzyma domyślną domenę [0 1], zgadzającą się z tym, co przeglądarka faktycznie podaje. A sama sekwencja próbek jest składana per cykl, z 256 interwałami na cykl, więc funkcja trzyma dane koloru repeat albo reflect, zamiast polegać na jakiejkolwiek funkcji przeglądarki
Składanie linii koloru w próbki Type 0
Składanie znaczy próbkowanie linii koloru raz na cykl i zwierciadlenie sekwencji na cyklach nieparzystych dla trybu reflect. Pomocnik niżej to zwykłe Delphi, nie API HotPDF, i robi dwie rzeczy, które wersja naiwna psuje. Interpoluje w świetle liniowym zamiast po przechowanych bajtach sRGB i rozstrzyga twarde stopu (dwa stopu na tym samym offsecie) na kolor dalszej strony, co jest semantyką OpenType i CSS
uses
System.SysUtils, System.Math;
type
TColorStop = record
Offset: Single; // 0..1 wewnątrz jednego cyklu, posortowane rosnąco
R, G, B: Byte; // bajty sRGB w stylu CPAL
end;
function SRGBToLinear(V: Byte): Double;
var
C: Double;
begin
C := V / 255;
if C <= 0.04045 then
Result := C / 12.92
else
Result := Power((C + 0.055) / 1.055, 2.4);
end;
function LinearToSRGB(L: Double): Byte;
var
C: Double;
begin
L := EnsureRange(L, 0, 1);
if L <= 0.0031308 then
C := L * 12.92
else
C := 1.055 * Power(L, 1 / 2.4) - 0.055;
Result := Round(C * 255);
end;
// Jeden cykl linii koloru jako trójki RGB 8-bit. Reverse = True
// zwiercadli cykl, co robi tryb reflect na cyklach nieparzystych
function SampleCycle(const Stops: array of TColorStop; Count: Integer;
Reverse: Boolean): TBytes;
var
I, S: Integer;
T, F, Span: Double;
begin
SetLength(Result, Count * 3);
for I := 0 to Count - 1 do
begin
T := I / (Count - 1);
if Reverse then
T := 1 - T;
S := 0;
// >= wybiera tylną stronę twardego stopu
while (S < High(Stops) - 1) and (T >= Stops[S + 1].Offset) do
Inc(S);
Span := Stops[S + 1].Offset - Stops[S].Offset;
if Span > 0 then
F := EnsureRange((T - Stops[S].Offset) / Span, 0, 1)
else
F := 1;
Result[I * 3] := LinearToSRGB(SRGBToLinear(Stops[S].R) +
(SRGBToLinear(Stops[S + 1].R) - SRGBToLinear(Stops[S].R)) * F);
Result[I * 3 + 1] := LinearToSRGB(SRGBToLinear(Stops[S].G) +
(SRGBToLinear(Stops[S + 1].G) - SRGBToLinear(Stops[S].G)) * F);
Result[I * 3 + 2] := LinearToSRGB(SRGBToLinear(Stops[S].B) +
(SRGBToLinear(Stops[S + 1].B) - SRGBToLinear(Stops[S].B)) * F);
end;
end;
Renderer COLRv1 wewnątrz HotPDF idzie dalej niż ten szkic. Zapisuje big-endian 16-bitowe liniowe próbki do funkcji CalRGB i przechowuje alfa jako osobną 16-bitową szarą próbkę, podczas gdy publiczne pomocniki pokazane niżej przyjmują trójki 8-bitowe DeviceRGB, dlatego szkic przelicza z powrotem na bajty sRGB. Teorię tego, co czytnik robi z tymi próbkami, opisuje szczegółowo artykuł o próbkowanych funkcjach Type 0 jako 3D LUT-ach koloru
Dlaczego każdy cykl potrzebuje własnego shadingu?
Każdy cykl potrzebuje własnego shadingu, bo czytniki nie liczą próbkowanej funkcji dokładnie; prekomputują tabelę kolorów, a tabela interpoluje w poprzek zawinięcia od ostatniego koloru jednego cyklu do pierwszego koloru następnego. MuPDF przechowuje jednowymiarową funkcję shadingu jako 256-elementową tabelę kolorów. Zapakuj 11 cykli w jedną funkcję 2816 próbek i każda zapisana próbka może być dokładna, podczas gdy MuPDF wciąż pokazuje 12-14 poziomów błędu kanału, bo 256 wpisów tabeli jest rozsmarowanych na całą rozpiętość, a twardy szew repeat jest wygładzany w rampę. Poppler i analityczna interpolacja tego samego pliku były oba poprawne, więc to limit precyzji czytnika, nie błąd pliku, i dokładanie próbek nie pomaga
Poprawka polega na trzymaniu każdej tabeli czytnika wewnątrz jednego cyklu. HotPDF emituje okresowe gradienty liniowe jako jeden niezależny shading osiowy na pierwotny cykl, z obiema flagami Extend fałszywymi, więc skok koloru od końca do startu ląduje na granicy między dwoma shadingami zamiast wewnątrz jednej tabeli. Dla reflect test nieparzystości używa pierwotnego numeru cyklu ze znakiem, łącznie z cyklami ujemnymi przed startem linii koloru, a kierunek shadingu jest zamieniany zamiast przeliczać próbki. Wszystkie cykle są rysowane do jednej izolowanej powierzchni w świetle liniowym, a alfa farby jest nakładana raz na końcu, więc półprzezroczysty gradient nie podwaja swojej krycia wzdłuż szwów
Publiczne metody THotPDF.RegisterSampledAxialGradient i THotPDF.RegisterSampledRadialGradient wystawiają klocek próbkowanego shadingu do własnego rysowania. Obie przyjmują (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) po geometrii, wymagają PDF 1.3, podnoszą wyjątek, chyba że Samples trzyma dokładnie SampleCount * 3 bajtów, i zwracają nazwę wzorca dla THPDFPage.SetFillPattern. Ustawiają Extend na obu końcach i zapisują Cycles jako niestandardowy klucz wzorca /Cycles, który przeglądarki ignorują. Żeby przestrzegać zasady jeden-cykl-na-shading z ich pomocą, rejestruj jeden wzorzec na cykl i pozwól prostokątowi pasa go przyciąć
// Poziomy gradient repeat albo reflect złożony z jednego shadingu na cykl.
// Współrzędne wzorca są w domyślnej przestrzeni PDF; pozioma oś trzyma
// ten przykład niezależnym od konwencji Y strony z góry na dół
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
SamplesPerCycle = 257; // 256 interwałów na cykl
var
Cycle, CycleCount: Integer;
X0, X1: Single;
Samples: TBytes;
PatternName: AnsiString;
begin
CycleCount := Ceil(Width / CycleLength);
for Cycle := 0 to CycleCount - 1 do
begin
X0 := X + Cycle * CycleLength;
X1 := X0 + CycleLength;
Samples := SampleCycle(Stops, SamplesPerCycle, Reflect and Odd(Cycle));
// Domena [0, 1]: parametr shadingu dociera do funkcji bez zmian
PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
Samples, SamplesPerCycle, 1);
if PatternName = '' then
Exit; // wersja dokumentu zablokowana poniżej PDF 1.3
Pdf.CurrentPage.SetFillPattern(PatternName);
// Pas przycina shading do dokładnie jednego cyklu, więc Extend nigdy się nie pokazuje
Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
Pdf.CurrentPage.Fill;
end;
end;
Bardzo długie rozpiętości potrzebują innej sztuczki, bo jeden shading na cykl znaczy jeden zasób na cykl. Dla okresowych gradientów liniowych COLRv1 HotPDF przełącza się na schemat podwajania: formularz liścia trzyma prymityw repeat jednego cyklu albo reflect dwóch cykli, a każdy poziom Form XObject rysuje poprzedni poziom dwa razy, przesunięty o jego szerokość. Liczba formularzy rośnie logarytmicznie z rozpiętością, a przeglądarka wciąż maluje każdy widoczny cykl, więc pokrycie po wyrównaniu fazy jest ograniczone do 65536 jednostek cyklu. Wcześniejsza wersja, która kłaść prymityw do kafelkowego wzorca Type 1, została wyrzucona, bo czytniki dostosowują odstęp kafelków do siatki urządzenia i Poppler wypadał z fazy na cyklu, który nie był całkowitą liczbą pikseli
Rozwiązywanie zakresu parametru dla rodzin okręgów radialnych
Gradient radialny pokrywa glif tylko wtedy, gdy zakres cykli jest policzony dla właściwej rodziny okręgów, a farby radialne COLRv1 wpadają w trzy rodziny z różną matematyką. Przy D = c1 - c0 i dr = r1 - r0 ściśle zagnieżdżona rodzina ma |dr| > |D|, niezagnieżdżony stożek ma |D|² > dr², a rodzina styczna ma |D| = |dr|. HotPDF mierzy parametr od punktu o zerowym promieniu pierwotnej rodziny i trzyma środek i promień każdego emitowanego okręgu przywiązane do tego samego pierwotnego parametru, więc przemappowane ujemne promienie nie są nigdy zaciskane niezależnie
- Rodziny zagnieżdżone przecinają pudełko wypełnienia monotonicznie, więc zakres parametru pochodzi z czterech rogów plus bisekcja dla pierwszego okręgu, który dotyka pudełka. Do 64 cykli jest próbkowanych do jednego shadingu po 256 próbek na cykl, z limitem 4096 próbek; głębsze rozpiętości przełączają na plastry per cykl
- Stożki i pasy o równych promieniach używają osobnych krótkich radialnych shadingów na pierwotnych granicach cykli, każdy z 257 próbkami i wyłączonym
Extend, komponowanych w pierwotnej kolejności parametrów wewnątrz izolowanej, nie-wyłączającej grupy, więc największy ważny parametr wygrywa tam, gdzie okręgi nachodzą - Rodziny styczne redukują równanie okręgu do liniowego. Względem wspólnego punktu styczności pierwiastek to
q = |W|² / (2·(W·V)), a pokryty region to otwarta półpłaszczyzna przed tym punktem
Przypadek styczny ma prawdziwą osobliwość. Gdy linia styczna przecina pudełko wypełnienia, liczba cykli potrzebnych, by dosięgnąć pikseli przy tej linii, jest nieograniczona, więc żadna skończona liczba shadingów jej nie pokryje. HotPDF wydaje budżet 64 cykli od punktu styczności i zostawia pozostały cienki pas przezroczystym. Gdy wszystkie cztery rogi pudełka siedzą za punktem styczności, farba jest przezroczysta jako całość, a reszta glifu dalej się rysuje. Ścieżka skończonego stożka przyjmuje do 4096 wymaganych cykli, z pokryciem ograniczonym do 1024 plastrów i wszystkim ponad limitem zostawionym niewymalonym; glif o 1024 plastrach to około 1,8 MB
Kolor w świetle liniowym i pułapka dwóch miejsc po przecinku
COLRv1 interpoluje i komponuje w świetle liniowym, więc HotPDF dekoduje każdy bajt CPAL odwrotną funkcją transferu sRGB i maluje wypełnienia pełne, shadingi i grupy wewnętrzne w jednej przestrzeni CalRGB z białym D65, barwami podstawowymi sRGB i /Gamma [1 1 1] (§8.6.5.3). Tylko skończona powierzchnia korzenia przelicza się do przestrzeni mieszania wołającego. Interpolowanie przechowanych bajtów sRGB daje zamiast tego widocznie ciemniejsze punkty środkowe, czyli klasyczny błąd gammy, który każdy silnik gradientów popełnia raz
Przejście na świat liniowy obnażyło stary błąd formatera. Ciemna wartość kanału CPAL równa 12 staje się w świetle liniowym około 0,0036765, a wspólny formater liczb za scn trzymał tylko dwa miejsca po przecinku, więc ta składowa była zapisywana jako 0, zanim jakakolwiek przeglądarka dotknęła pliku. Objaw wyglądał dokładnie jak kwantyzacja czytnika, a pierwszy odruch był obwiniać 8-bitowe bufory grup MuPDF. Składowe wypełnienia i kreski przechodzą teraz przez formater dziesiętny bez wykładnika, który trzyma małe wartości i ignoruje locale procesu. Wniosek uogólnia się: gdy ciemny kolor znika, zrzuć strumień treści i sprawdź zapisany operand, zanim zaczniesz badać przeglądarkę
Przeglądarka ma wciąż znaczenie po tej poprawce. MuPDF 1.28.2 gubi niemal zerowe liniowe kanały, gdy komponuje izolowaną grupę przezroczystości niosącą jawną przestrzeń barw mieszania, a Poppler 26.07.0 Splash ignoruje grupowe przestrzenie mieszania całkowicie. Opt-in własność THotPDF.COLRv1DeviceSpaceGroupBlend (domyślnie False) emituje grupy COLRv1 bez przestrzeni barw mieszania, więc komponują się one w odziedziczonej przestrzeni urządzenia, podczas gdy kolory treści, shadingi i funkcje zostają w liniowym CalRGB. Kosztem jest to, że nachodzące półprzezroczyste warstwy mieszają się wtedy w przestrzeni urządzenia w przeglądarkach, które honorują grupowe przestrzenie barw
Które tryby kompozycji COLRv1 PDF wyrazi dokładnie?
HotPDF renderuje tryby kompozycji COLRv1 od 5 do 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) natywnie, a tryby od 9 do 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) idą do OnColorGlyphRasterize. Tryby in i out to iloczyny jednej powierzchni z alfą drugiej, czyli dokładnie to, co liczy soft mask (§11.6.5). Przeżywający operand jest przechwytywany jako zwykły form; operand maskowany jest przechwytywany w przebiegu alfy jako płaska treść wewnątrz jednej izolowanej grupy DeviceGray i używany jako maska /Luminosity z /BC [0]. Tryby out zapisują 1 − α dla każdej alfy i kładą kryjącą białą osłonę pod treścią maski, więc ocalały zostaje widoczny tam, gdzie drugi operand nigdy nie malował
Pozostałe cztery wymagają premnożonego dodawania dwóch powierzchni. W modelu source-over PDF człon tła jest zawsze tłumiony przez (1 − α) źródła, a nie ma prymitywu, który dodaje dwie powierzchnie albo skaluje tło wyłącznie pokryciem źródła, więc atop, xor i plus nie dadzą się zapisać wektorowo. Jeszcze jedno ograniczenie przyszło z gołych testowych PDF-ów: obie przeglądarki błędnie liczą grupy soft mask, których treść zagnieżdża form Do, więc operand maskowany musi być płaski. Kwalifikują się kolory pełne, gradienty pad i opakowanie PaintGlyph wokół nich; warstwy, transformacje i gradienty okresowe, które wszystkie produkują wewnętrzne formy, posyłają glif na fallback
var
Pdf: THotPDF;
Fallback: TEmojiFallback; // klasa handlera z sygnaturą zdarzenia niżej
begin
Pdf := THotPDF.Create(nil);
Fallback := TEmojiFallback.Create;
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'periodic-icons.pdf';
// Opt-in: kompozycja grup w przestrzeni urządzenia dla MuPDF i Popplera
Pdf.COLRv1DeviceSpaceGroupBlend := True;
// Przyjmuje glify atop/xor/plus i wszystko inne poza podzbiorem:
// (Sender; Format; const FontBytes, AssetData: TBytes; GlyphID: Word;
// PaletteIndex, PixelSize: Integer; out Width, Height: Integer;
// out RGBA: TBytes; out Handled: Boolean)
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('Fonts\GradientIcons.ttf');
if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $E001,
72, 144, 'Gradient Icons', 48) then
Writeln('U+E001 was neither drawn natively nor rasterized');
Pdf.EndDoc;
finally
Fallback.Free;
Pdf.Free;
end;
end;
Gdzie ścieżka wektorowa wciąż nie dorasta
Wyjście wektorowe nie znaczy identycznych pikseli w każdej przeglądarce, i zapisy odbioru HotPDF mówią to wprost. Próbki w jednym lub dwóch krokach od granicy cyklu są wygładzane przez interpolację czytnika, więc testy pikselowe próbkują z dala od szwów, a strumienie funkcji są weryfikowane przez ich dekodowanie. Interpolowanie osobno przechowanych prostych próbek RGB i alfy jest przybliżeniem przy końcach o niskiej alfie i twardych stopach; zmierzony błąd jest drugiego rzędu przy 256 próbkach na cykl, ale nie jest dokładny. Poppler Splash próbkuje na lewej krawędzi piksela zamiast w jego środku, co wychodzi jako małe różnice na najstromniejszych rampach
Budżety to też prawdziwe limity. Zagnieżdżone rodziny radialne odmawiają powyżej rozpiętości 4096 cykli, stożki i rodziny styczne powyżej 4096 wymaganych cykli, a styczny pas przy osobliwej linii zostaje pusty. Zmienna alfa stopów potrzebuje przezroczystości PDF 1.4, więc nie jest włączana pod PDF/A-1, PDF/X-1a ani PDF/X-3. Gdy glif wypada poza którykolwiek z tych limitów, DrawRegisteredColorGlyph odmawia całego grafu przed zapisaniem operatora, czyli to samo zachowanie wszystko-albo-nic co wcześniej
FAQ
Czy HotPDF rastruje gradienty COLRv1 repeat i reflect?
Nie. Nowsze wersje HotPDF emitują je jako wektorowe shadingi osiowe i radialne pokrywające całe cykle. Tylko grafy poza wspieranym podzbiorem, jak kompozycje atop, xor i plus, idą do OnColorGlyphRasterize
Dlaczego nie użyć jednej próbkowanej funkcji dla wszystkich cykli?
Czytniki prekomputują tabelę kolorów z funkcji, a tabela MuPDF ma 256 wpisów. Rozsmarowana na wiele cykli interpoluje przez każdy szew repeat. Jeden shading na cykl trzyma każdą tabelę wewnątrz pojedynczego cyklu
Kiedy włączyć COLRv1DeviceSpaceGroupBlend?
Włącz ją, gdy niemal czarne kanały w glifach COLRv1 spadają do zera w MuPDF. Zostaw wyłączoną, gdy nachodzące półprzezroczyste warstwy muszą mieszać się w świetle liniowym w przeglądarkach honorujących grupowe przestrzenie barw
Natywne gradienty COLRv1, kolorowe emoji, próbkowane shadingi i grupy przezroczystości to wszystko części HotPDF Component for Delphi and C++Builder, która generuje dokumenty PDF prosto z kodu Delphi i C++Builder