Renderer PDF, który nic nie rysuje, zwykle nie ma żadnego błędu w swoim kodzie rysującym. W komponencie HotPDF dla Delphi i C++Builder cztery osobne defekty sprawiały, że strony renderowały się jako puste, podczas gdy każda linia dziennika pozostawała czysta: operandy nazw niosące wiodący ukośnik, odwrócona konkatenacja cm i indeks tokenu, który odczytywał zero. Żaden z nich nie zgłaszał wyjątku. Żaden nie logował. Strumień treści był poprawnie tokenizowany, dyspozytor operatorów rozpoznawał każdy operator, obraz XObject był dekodowany do prawidłowej bitmapy, a potem strona wychodziła pusta. Ta kombinacja — pipeline, który raportuje sukces na każdym etapie i nic nie produkuje widocznego — jest sygnaturą wyszukiwania lub indeksu, który po cichu chybia, zamiast zawodzić. To jest post-mortem jednej takiej rodziny i dyscypliny testowania, która pozwoliła jej przetrwać 38 wydań
Dlaczego renderer PDF nic w ogóle nie rysuje?
Ponieważ nieudane wyszukiwanie zasobu w rendererze PDF jest nieodróżnialne od pustej strony. Operandy nazw strumienia treści i klucze słownika zasobów to dwie różne przestrzenie ciągów, a HotPDF porównywał je bez normalizacji. Tokenizer czyta /Im0 i zachowuje ukośnik, ponieważ to jest token; wczytany słownik /Resources /XObject przechowuje klucz jako Im0, ponieważ parser usuwa ogranicznik, budując klucze słownika. Każde FindValue względem nazwy operandu zwracało więc -1. Promień rażenia był szerszy niż same obrazy. ISO 32000-1 §8.9 obejmuje Do, §8.4 obejmuje gs i jego wyszukiwanie /ExtGState, §8.6 obejmuje cs i CS, a §8.7.4.3 obejmuje sh. Wszystkie pięć operatorów kluczowało swój podsłownik zasobów surowym operandem, więc wszystkie pięć chybiało. Nazwane przestrzenie kolorów wpadały w DeviceGray, co zamienia 1 scn w biały atrament na białej stronie. Obrazy XObject nigdy nie były w ogóle malowane — ścieżka obrazu bitmapowego w praktyce nigdy nie działała od dnia, gdy wylądowała. Naprawą jest pomocnik na poziomie jednostki stosowany przy każdym wyszukiwaniu kluczowanym operandem, co jest jedynym sposobem, by utrzymać konwencję od dryfowania ponownie
// Page content stream, the ordinary image-placement idiom:
// q
// /GS0 gs
// 200 0 0 120 60 400 cm
// /Im0 Do
// Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.
function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
Result := N;
if (Result <> '') and (Result[1] = '/') then
Delete(Result, 1, 1);
end;
// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
Exit;
Drugie, powiązane chybienie siedziało warstwę niżej. Renderer miał typowane resolwery tylko dla strumieni i słowników, więc odwołanie pośrednie wskazujące na obiekt tablicy na najwyższym poziomie — powszechne /CS0 5 0 R z [/Separation ...] po drugiej stronie — rozwiązywało się do nil przez oba i wpadało z powrotem do nierozwiązanego linku. Dodanie ogólnego resolwera obiektu naprawiło nazwane przestrzenie kolorów i tablice funkcji jednym ruchem. Jeśli podpinasz słowniki cieniowania, ta sama dyscyplina rozwiązywania stosuje się do ścieżki cieniowania osiowego i promienistego, gdzie wpis /Function bardzo często jest pośredni
Operator cm i konkatenacja napisana od tyłu
Drugi defekt umieszczał obrazy około sto tysięcy pikseli poza stroną, co wygląda dokładnie jak brak ich rysowania. ISO 32000-1 §8.3.4 definiuje transformacje PDF wektorami wierszowymi, a operator cm konkatenuje swoją macierz operandu na bieżącą macierz transformacji jako M × CTM — M działa jako pierwsza, istniejące CTM potem. HotPDF komponuje macierze przez HPDFMatMul(A, B), które stosuje B przed A. Poprawne wywołanie więc przekazuje starą CTM jako A. Wysłany kod przekazywał macierz operandu jako A, produkując CTM × M
Odwrócona kolejność jest nieszkodliwa dla pojedynczego cm i katastrofalna dla standardowego dwuetapowego idiomu. Umieść obraz za pomocą 1 0 0 1 x y cm, po którym następuje w 0 0 h 0 0 cm, a poprawna kaskada skaluje kwadrat jednostkowy przez (w, h), a potem przesuwa go o (x, y). Pod odwróconą kaskadą przesunięcie wchodzi najpierw, a skala je mnoży, więc obraz nominalnie w (60, 400), przeskalowany do 200 na 120, ląduje w (12000, 48000). Test przycinania na górze blitu odrzuca go, blit jest pomijany, a nic nigdzie nie raportuje problemu
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.
// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)), GS.CTM);
// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)));
Co czyni to pouczającym, to fakt, że ten sam plik źródłowy już zawierał poprawną kolejność. Wpis /Matrix Form XObject miał tę samą odwróconą kompozycję, ale ścieżka glifów Type 3 i ścieżka konturu osadzonego glifu obie zrobiły to dobrze od początku, ponieważ umiejscowienie glifu widocznie zapada się do początku układu, gdy się to odwróci, i ktoś już był zmuszony to naprawić. Dwie konwencje współistniały w jednej jednostce przez trzy tuziny wydań, każda poprawna we własnej funkcji, i żaden recenzent tego nie zauważył, ponieważ żadne miejsce wywołania nie wyglądało źle w izolacji
Co się dzieje, gdy indeks tokenu jest przesunięty o jeden?
Dostajesz dwanaście operatorów, które są składniowo obsłużone i semantycznie martwe. Akcesor operandu w rendererze to NumAt(Back), który czyta Tokens[OpIndex - Back], a OpIndex to indeks samego tokenu operatora. Operator jednooperandowy znajduje więc swoją liczbę na back 1. Dwanaście z nich było napisanych jako NumAt(0), co czyta token operatora, nie przechodzi sprawdzenia rodzaju ctOperandNumber i zwraca domyślne zero. Lista to Tc, Tw, Tz, TL, Ts i Tr z operatorów stanu tekstu ISO 32000-1 §9.3, plus w, J, j, M, ri i i z operatorów stanu grafiki §8.4.3. Odstępy między znakami i słowami stały się no-opami, skalowanie poziome nigdy się nie stosowało, prowadzenie pozostawało na zerze, więc T* nigdy nie przesuwał linii, uniesienie tekstu nic nie robiło, tryb renderowania był zawsze wypełnieniem, a każde pociągnięcie w każdym dokumencie wychodziło jako jednopikselowa cienka linia niezależnie od zadeklarowanej szerokości linii. Operatory wieloperandowe, takie jak m, rg i Tm, używały NumAt(1..6) i były wszystkie poprawne, więc recenzent skanujący funkcję widział ścianę wiarygodnej arytmetyki indeksów z dwunastoma błędnymi wpisami osadzonymi w niej
function NumAt(Back: Integer): Double;
begin
Result := 0;
if (OpIndex - Back >= 0)
and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
Result := Tokens[OpIndex - Back].NumValue;
end;
// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1) // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading := NumAt(1) // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w' then GS.LineWidth := NumAt(1) // previously NumAt(0)
Dlaczego zestaw testów pozostawał zielony przez 38 wersji?
Ponieważ asercje były zbyt słabe, by odróżnić zrenderowaną stronę od częściowo zrenderowanej. Testy smoke renderowania sprawdzały rzeczy w rodzaju: wynikowa bitmapa nie jest całkowicie czarna, lub strona nie jest pusta, lub skrót obrazu jest niezerowy. Każde z nich zachodzi, gdy tekst się renderuje, a obrazy nie. Tekst rysował się dobrze, więc bufor ramki nigdy nie był jednolity, skrót nigdy nie był zerowy, a zestaw testów raportował sukces, podczas gdy cały pipeline obrazów był w praktyce martwym kodem. Słabe asercje są kuszące w grafice właśnie dlatego, że silne wyglądają na kruche. Nikt nie chce testu, który psuje się, gdy krawędź antyaliasingu przesuwa się o jeden piksel, więc naturalnym odwrotem jest sprawdzenie czegoś, czego żadna rozsądna zmiana nie mogłaby naruszyć — i ten odwrót ląduje cię na predykatach, których nie może naruszyć nawet żadna nierozsądna zmiana. Test przestrzeni kolorów separation sprawdzał, że wyjście jest odróżnialne od czerni; szary na białym go przechodził, podobnie jak biały na białym. Test nie mierzył, czy pomalowano właściwy kolor. Mierzył, czy w ogóle cokolwiek się stało na płótnie
Jak napisać asercję renderowania, która faktycznie zawodzi?
Licz piksele oczekiwanego koloru, w oczekiwanej ilości, i pozwól pozycji i rozmiarowi wynikać z liczby. Zastępcza dyscyplina to ręcznie zbudowany minimalny PDF, jeden fakt wizualny na plik i asercja na tym, ile pikseli ląduje w tolerancji od konkretnej triady RGB. Obraz czystej czerwieni 200 na 120 umieszczony pod znanym przesunięciem musi wyprodukować mniej więcej 24000 czerwonych pikseli. Jeśli wyszukiwanie zasobu chybia, liczba wynosi 0. Jeśli kaskada cm jest odwrócona, liczba wynosi 0. Jeśli obraz renderuje się w niewłaściwej przestrzeni kolorów, liczba wynosi 0. Jedna liczba łapie wszystkie trzy, a pasmo tolerancji pochłania szum antyaliasingu, który sprawiał, że ludzie unikali dokładnego porównania w pierwszej kolejności
function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
X, Y: Integer;
C: TColor;
begin
Result := 0;
for Y := 0 to Bmp.Height - 1 do
for X := 0 to Bmp.Width - 1 do
begin
C := Bmp.Canvas.Pixels[X, Y];
if (Abs(GetRValue(C) - R) <= Tol)
and (Abs(GetGValue(C) - G) <= Tol)
and (Abs(GetBValue(C) - B) <= Tol) then
Inc(Result);
end;
end;
// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
'image XObject was never drawn');
Cztery testy smoke zostały przepisane w ten sposób — transformacja tint Type 4, umieszczenie obrazu Do, przypadek widoczności treści opcjonalnej i tryb pociągnięcia Tr — a razem odsłoniły całą rodzinę. To jest prawdziwa lekcja i uogólnia się poza tę bazę kodu: w pipeline renderowania asercja musi nazwać kolor. Cokolwiek miększego jest sprawdzeniem, że renderer działał, nie że coś narysował. Jeśli budujesz własny harness strona-na-bitmapę, przegląd rasteryzacji strony jest naturalnym miejscem, by przypiąć pomocnik zliczania pikseli do pierwszego testu regresyjnego
Uczciwe granice
Dwa ograniczenia warto wyraźnie stwierdzić. Tryby renderowania przycinania tekstu od 4 do 7 są rysowane jako ich bazowy tryb wypełnienia lub pociągnięcia, ponieważ renderer nie modeluje skumulowanych ścieżek przycinania z konturów glifów; dokumenty polegające na przycinaniu kształtowanym tekstem wyrenderują tekst zamiast przyciętej grafiki poniżej. A dyscyplina zliczania pikseli opisana tutaj to technika testu smoke, nie zestaw zgodności — dowodzi, że konkretny fakt wizualny dotarł do bufora ramki, co jest znacznie niższą poprzeczką niż dowodzenie, że wyjście pasuje do referencyjnego rasteryzera. Jest to jednak dokładnie poprzeczka, której te cztery błędy nie zdołały przekroczyć przez trzy lata wydań
Omawiany tutaj renderer jest dostarczany jako część standardowego HotPDF Component dla Delphi i C++Builder; strona produktu zawiera pełne odniesienie API renderowania stron, w tym punkty wejścia cache bitmap i tło prefetch