Tabela lookup /Indexed przechowuje komponenty swojej bazowej przestrzeni kolorów, nie trójki RGB. Baza DeviceGray pakuje jeden bajt na wpis, DeviceCMYK cztery, Lab trzy wymagające skalowania zakresu, a ICCBased tyle, ile deklaruje /N profilu. HotPDF normalizuje je wszystkie w czasie parsowania do publicznej palety RGB, którą wystawia THPDFColorSpace
To zdanie to cała klasa bugów. Prawie każdy renderer palet zaczyna życie z założeniem trzybajtowego kroku, bo przytłaczająca większość prawdziwych przestrzeni /Indexed siedzi na /DeviceRGB i założenie trzyma się latami. Potem przychodzi zeskanowana faktura z [/Indexed /DeviceCMYK 15 <...>], czytelnik przechodzi po stringu lookup trzy bajty naraz, a każdy indeks powyżej zera ląduje w środku czyjegoś cudzego koloru. Wyjście nie jest puste i nie crashuje. To wiarygodnie wyglądające śmieci, co jest znacznie gorsze, bo nikt nie zgłasza buga przeciw kolorom, które po prostu wyglądają nie tak
Dlaczego indeksowana paleta wychodzi zła?
Krok jest zły, bo krok nie jest stałą. ISO 32000-1 §8.6.6.3 definiuje tabelę lookup jako m × (hival + 1) bajtów, gdzie m to liczba komponentów w przestrzeni bazowej, i nie mówi nic o tym, że baza ma być RGB. Ta sama sekcja wprost zakazuje tylko dwóch baz: samego /Indexed i /Pattern. Wszystko inne wchodzi w grę, łącznie ze wszystkimi czterema rodzinami CIE-based z §8.6.5.2 do §8.6.5.5
Więc prawdziwa robota to nie czytanie bajtów, tylko najpierw rozwiązanie bazy i zapytanie jej, jak szeroki jest wpis. ResolveIndexedPalette w HotPDF robi dokładnie to: rozwiązuje bazę do THPDFColorSpace, bierze ComponentCount z rozwiązania rekordu i odrzuca tablicę wprost, jeśli baza wróciła jako csfIndexed, csfPattern albo csfUnsupported. Dopiero wtedy wymiaruje budżet bajtów jako PaletteCount * Count, co przy legalnym maksimum hival 255 i bazie czterokomponentowej wychodzi najwyżej 1024 bajty. Dwa szczegóły tego wymiarowania to miejsca, gdzie wrogi plik będzie cię sondował. HotPDF zaciska hival do 0..255 przed użyciem, zgodnie z limitem z §8.6.6.3, więc tablica lookup twierdząca hival 100000 nie zamienia się w gigabajtową alokację; a budżet ucina do tego, co dane lookup faktycznie zawierają, po czym przestaje wypełniać paletę przy pierwszym wpisie, którego bajty wykraczają poza koniec. Krótka tabela daje krótką paletę, nie odczyt poza buforem
Normalizacja czterech różnych przestrzeni bazowych do jednej tabeli RGB
HotPDF konwertuje palety do RGB podczas parsowania, a nie w czasie rysowania, i ta decyzja napędza resztę projektu. Publiczne pole THPDFColorSpace.Palette to TBytes trójek RGB o długości 3 * PaletteCount, i ma ten kształt od dawna, zanim wsparcie dla baz CIE-based w ogóle powstało. Przepisanie układu rekordu na natywne komponenty bazowe złamałoby każdego konsumenta tego pola. Konwersja w czasie parsowania trzyma układ nietknięty i, jako bonus, kieruje wpisy Gray, CMYK, Lab, CalGray, CalRGB i ICCBased przez tę samą ścieżkę HPDFResolveColor, której renderer już używa dla bezpośrednich operatorów koloru
Krok skalowania to miejsce, w którym przestrzenie bazowe przestają być wymienne. Bajt lookup to zawsze 0..255, ale to, co ten bajt znaczy, zależy od zakresu komponentów deklarowanego przez bazę. Komponenty Device i skalibrowane mapują się na 0..1. Lab nie: komponent L* biegnie 0..100, podczas gdy a* i b* biegną po /Range deklarowanym przez przestrzeń. HotPDF wyciąga te granice z pól AMin, AMax, BMin i BMax rekordu THPDFCIEParams, które parser Lab wypełnia z wpisu /Range słownika i domyślnie ustawia na −108..101, gdy wpisu brak, więc nie zahardkodowuj symetrycznego zakresu z pamięci. Bazy ICCBased dodają trzecie źródło prawdy: liczba komponentów pochodzi ze strumienia profilu /N, wystawiona jako ICCN, a strumień ICCBased może sam nieść tablicę /Range, którą HotPDF czyta przez HPDFReadNumericArrayUpTo do tej samej tabeli granic per-komponent. Trójkomponentowa paleta ICC i trójkomponentowa paleta Lab przechodzą więc po stringu lookup z identycznymi krokami i całkowicie inną arytmetyką, co jest dokładnie wynikiem, którego stały krok RGB nie może wyprodukować
var
CS: THPDFColorSpace;
Entry: Integer;
R, G, B: Byte;
begin
// ColorSpaceObj to wpis /ColorSpace z XObjecta obrazu albo słownika
// zasobów; może być nazwą, tablicą albo odwołaniem pośrednim
CS := HPDFResolveColorSpace(ColorSpaceObj);
if CS.Family <> csfIndexed then
Exit;
// Palette jest już w RGB niezależnie od tego, jaka była przestrzeń bazowa
for Entry := 0 to CS.PaletteCount - 1 do
begin
if (Entry * 3 + 2) >= Length(CS.Palette) then
Break; // krótka tabela lookup, zatrzymaj się czysto
R := CS.Palette[Entry * 3];
G := CS.Palette[Entry * 3 + 1];
B := CS.Palette[Entry * 3 + 2];
UseColor(Entry, R, G, B);
end;
end;
Gdy tabela lookup żyje w strumieniu
ISO 32000-1 §8.6.6.3 pozwala, by tabela lookup była stringiem albo strumieniem, i to forma strumieniowa gryzie. Strumień może nieść /FlateDecode, może być obiektem pośrednim współdzielonym z niepowiązanymi częściami pliku, a jego TStream może być ustawiony w dowolnym miejscu w chwili, gdy twój parser do niego dociera. HotPDF obsługuje przypadek skompresowany, delegując do callbacku zdekodowanych bajtów zarejestrowanego przez HPDFSetColorStreamReader — tego samego callbacku, którego używa silnik funkcji próbkowanych Type 0
Gdy żaden czytelnik nie jest zarejestrowany, fallback jest celowo wąski, a nie best-effort. HotPDF sprawdza słownik strumienia pod kątem /Filter i odrzuca paletę w całości, jeśli jakiś jest, zamiast karmić lookup skompresowanymi bajtami i produkować pewną siebie, złą tabelę. Bez filtra czyta surowe bajty, ucięte do budżetu bajtów policzonego z hival i liczby komponentów bazy. Cała gałąź strumienia siedzi wewnątrz try..finally, który przywraca oryginalny Stream.Position na każdej ścieżce wyjścia, włącznie z odmowami, bo obiekt jest współdzielony, a następny konsument nie podziękuje ci za przesunięty kursor. LookupStreamUsesDecodedReaderAndRestoresPosition w suite regresji przypina obie połówki tego zachowania
// Zarejestruj oba callbacki, zanim rozwiążesz jakąkolwiek przestrzeń kolorów, bo
// inaczej bazy pośrednie i skompresowane strumienie lookup po prostu zostaną odrzucone
HPDFSetColorSpaceResolver(MyResolveIndirectObject);
HPDFSetColorStreamReader(MyDecodedStreamReader);
try
CS := HPDFResolveColorSpace(ColorSpaceObj);
if (CS.Family = csfIndexed) and (Length(CS.Palette) = 0) then
LogWarning('lookup table unreadable: filtered stream or no reader');
finally
HPDFSetColorStreamReader(nil);
HPDFSetColorSpaceResolver(nil);
end;
Co powstrzymuje indeksowaną przestrzeń kolorów przed rekurencją w nieskończoność?
Odrzucenie /Indexed jako bezpośredniej bazy jest konieczne i ani trochę niewystarczające. Ciekawy atak idzie bokiem. Weź poprawne [/Indexed /Separation ...] i przypomnij sobie, że §8.6.6.4 pozwala Separation nazwać przestrzeń alternatywną, a §8.6.6.5 pozwala DeviceN zrobić to samo. Zrób z tej alternatywy odwołanie pośrednie wskazujące z powrotem na oryginalną tablicę Indexed. Nic w łańcuchu nie łamie reguły bezpośredniej bazy na żadnym pojedynczym skoku: bazą jest Separation, alternatywa Separation to tablica, a tablica akurat jest tą, od której zacząłeś. Rozwiąż to naiwnie, a parser chodzi po pętli, aż stos odmówi posłuszeństwa. Maszyneria transformacji tint, na której polegają te przestrzenie, to osobny temat, pokryty w artykule o renderowaniu kolorów spot Separation i DeviceN; tutaj liczą się tylko jako ogniwo w cyklu
HotPDF zamyka to na pojedynczym punkcie wejścia, przez który już i tak przechodzą wszystkie ścieżki. HPDFResolveColorSpace opakowuje wewnętrzny resolver w licznik głębokości ograniczony przez HPDFColorSpaceMaxDepth, czyli 64. Przekrocz tę granicę, a strażnik ustawia flagę, i to flaga się liczy: przy rozwinięciu stosu każdy poziom zagnieżdżenia, który ją widział ustawioną, odrzuca swój własny częściowo rozwiązany rekord i zwraca csfUnsupported, więc cały graf jest odrzucany zamiast zatrzymywać jego połowę, a oba liczniki resetują się, gdy wróci najbardziej zewnętrzne wywołanie. Te liczniki są zadeklarowane jako threadvar, nie globalne jednostki, i to nie przypadkowa schludność: GResolveObj i GReadStream żyją w pamięci lokalnej wątku z tego samego powodu, bo strony renderują się równolegle. Współdzielony global pozwoliłby jednemu wątkowi głęboko w zagnieżdżonym łańcuchu DeviceN wyzwolić limit dla innego wątku parsującego całkiem zwykłe [/Indexed /DeviceRGB 255 <...>], produkując niedeterministyczną, zależną od obciążenia porażkę — najmniej debuggowalny rodzaj, jaki istnieje. Sześćdziesiąt cztery to wygodny zapas dla legalnej zawartości, która rzadko zagnieżdża się głębiej niż cztery-pięć poziomów, więc traktuj csfUnsupported z tablicy wyglądającej strukturalnie poprawnie jako coś wartego logowania: w praktyce oznacza cykl albo głębokość, której nie emituje żaden prawdziwy producent. IndirectAlternateCycleIsUnsupported, parametryzowane po Separation i DeviceN, oraz ExcessivePatternDepthIsUnsupported pokrywają oba przypadki
function TryResolveImageSpace(Obj: THPDFObject;
out CS: THPDFColorSpace): Boolean;
begin
CS := HPDFResolveColorSpace(Obj);
// csfUnsupported tutaj pokrywa naprawdę nieznaną rodzinę, cykl przez
// pośrednią alternatywę oraz głębokość powyżej 64 poziomów
Result := CS.Family <> csfUnsupported;
if not Result then
CS := HPDFDefaultColorSpace; // DeviceGray, początkowa przestrzeń PDF
end;
Jedna granica API warta udokumentowania dla twoich wywołujących
HotPDF stosuje konfigurację konwersji ICC obowiązującą w czasie parsowania, gdy buduje paletę Indexed, a późniejsze wywołania HPDFSetColorSpaceRenderingIntent nie regenerują już rozwiązanej palety. Ta funkcja przechodzi po transformacjach ICCBased i alternatyw rekordu, który jej podajesz, i przebudowuje te pipeline'y; spłaszczone bajty RGB w Palette to artefakt czasu parsowania i zostają, jakie były. To konsekwencja wczesnej normalizacji, nie przeoczenie, i warto wpisać to do własnej dokumentacji API, zamiast zostawiać klientowi do odkrycia. Jeśli twoja aplikacja pozwala użytkownikowi przełączać rendering intent w trakcie sesji, rozwiąż przestrzenie kolorów ponownie po przełączeniu; jeśli ustawia intent raz podczas konfiguracji pipeline'u, co jest przytłaczająco częstym przypadkiem, nie ma nic do roboty. Ta sama kolejność dotyczy profili proofing instalowanych przez HPDFSetICCColorPipeline: skonfiguruj je, zanim zaczniesz rozwiązywać, nie po
Lustrzany bug po stronie zapisu
Wszystko powyżej dotyczy czytania. Symetryczny defekt po stronie zapisu łatwiej wysłać i znacznie łatwiej przegapić. Gdy HotPDF buduje tablicę /Indexed z bitmapy Windows 8-bitowej, woła GetPaletteEntries, a to API wypełnia tylko tyle wpisów, ile paleta faktycznie trzyma, i zwraca tę liczbę. Oryginalny kod testował wynik na = 0, a potem iterował po stałych 256 wpisach tablicy na stosie. Dla 16-kolorowej bitmapy zapisało to 240 wpisów niezainicjalizowanej pamięci stosu wprost do stringu lookup PDF-a, który potem oddajesz klientowi, z hival zahardkodowanym na 255, więc każdy czytelnik posłusznie traktował pozostałość jako prawdziwe dane koloru. To bug poprawności i bug ujawniania informacji ubrane w te same ciuchy. Poprawka jest nieefektowna i kompletna: wyzeruj tablicę przed wywołaniem, a potem respektuj zwróconą liczbę zarówno dla granicy pętli, jak i dla hival. Jeśli generujesz obrazy /Indexed gdziekolwiek we własnym kodzie, idź sprawdzić ten hival już teraz
Dwa sąsiednie tematy domykają obraz: palety Indexed i funkcje próbkowane Type 0 współdzielą czytelnika zdekodowanych strumieni i tę samą dyscyplinę zaciskania, omówione w artykule o tabelach lookup kolorów funkcji Type 0, a reguły filtrów rządzące strumieniami lookup to te same, które rządzą danymi obrazów w wyciąganiu załadowanych obrazów przez filtry dekodujące. Opisany tu resolver przestrzeni kolorów jest częścią HotPDF Delphi Component dla Delphi i C++Builder, z pełną referencją THPDFColorSpace na stronie produktu