En PDF-renderer, der ikke tegner noget, har som regel slet ingen fejl i sin tegnekode. I HotPDF Component til Delphi og C++Builder gjorde fire separate defekter, at sider blev rendret tomme, mens hver logline forblev ren: navneoperander med en indledende skråstreg, en omvendt cm-sammenkædning, og et tokenindeks der læste nul. Ingen af dem kastede en fejl. Ingen af dem loggede noget. Indholdsstrømmen blev tokeniseret korrekt, operator-dispatcheren genkendte hver operator, billed-XObjektet blev afkodet til et gyldigt bitmap, og så kom siden ud tom. Den kombination — en pipeline der rapporterer succes på hvert trin og ikke producerer noget synligt — er signaturen på et opslag eller et indeks, der stille fejler frem for at kaste en fejl. Dette er en obduktion af netop en sådan familie, og af den testdisciplin der lod den overleve i 38 releases
Hvorfor tegner en PDF-renderer slet ingenting?
Fordi et fejlet ressourceopslag i en PDF-renderer er umuligt at skelne fra en tom side. Navneoperander i indholdsstrømmen og nøgler i ressource-dictionaryet er to forskellige strengrum, og HotPDF sammenlignede på tværs af dem uden at normalisere. Tokenizeren læser /Im0 og beholder skråstregen, fordi det er hvad tokenet er; den indlæste /Resources /XObject-dictionary gemmer nøglen som Im0, fordi parseren strips afgrænseren, når den bygger dictionary-nøgler. Hvert FindValue mod et operandnavn returnerede derfor -1. Skadesradius var bredere end billeder. ISO 32000-1 §8.9 dækker Do, §8.4 dækker gs og dets /ExtGState-opslag, §8.6 dækker cs og CS, og §8.7.4.3 dækker sh. Alle fem operatorer nøglede deres ressource-underdictionary med den rå operand, så alle fem missede. Navngivne farverum faldt tilbage til DeviceGray, hvilket gør 1 scn til hvid blæk på en hvid side. Billed-XObjekter blev slet aldrig malet — bitmap-billedstien havde i praksis aldrig virket siden den dag den landede. Rettelsen er en enhedsniveau-hjælpefunktion anvendt på hvert operand-nøglet opslag, hvilket er den eneste måde at forhindre konventionen i at drifte igen
// 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;
Et andet, beslægtet miss sad ét lag længere nede. Rendereren havde kun typede resolvere for streams og dictionaries, så en indirekte reference, der pegede på et top-niveau array-objekt — det almindelige /CS0 5 0 R med [/Separation ...] i den anden ende — opløstes til nil gennem begge og faldt tilbage til det uopløste link. At tilføje en generisk objektresolver rettede navngivne farverum og funktionsarrays i ét hug. Hvis du kobler shading-dictionaries sammen, gælder den samme opløsningsdisciplin for den aksiale og radiale shading-sti, hvor /Function-posten meget ofte er indirekte
cm-operatoren og en sammenkædning skrevet baglæns
Den anden defekt placerede billeder omkring hundrede tusinde pixels uden for siden, hvilket ser nøjagtigt ud som ikke at tegne dem. ISO 32000-1 §8.3.4 definerer PDF-transformationer med rækkevektorer, og cm-operatoren sammenkæder sin operandmatrix M på den aktuelle transformationsmatrix som M × CTM — M træder i kraft først, den eksisterende CTM bagefter. HotPDF sammensætter matricer gennem HPDFMatMul(A, B), som anvender B før A. Det korrekte kald overgiver derfor den gamle CTM som A. Den leverede kode overgav operandmatricen som A, hvilket producerede CTM × M
Omvendt rækkefølge er harmløs for en enkelt cm og katastrofal for det almindelige to-trins-idiom. Placér et billede med 1 0 0 1 x y cm efterfulgt af w 0 0 h 0 0 cm, og den korrekte kaskade skalerer enhedskvadratet med (w, h) og oversætter det derefter med (x, y). Under den omvendte kaskade kommer oversættelsen ind først, og skaleringen multiplicerer den, så et billede nominelt ved (60, 400) skaleret til 200 gange 120 lander på (12000, 48000). Klip-testen øverst i blit'en afviser det, blit'en springes over, og intet noget sted rapporterer et problem
// 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)));
Det, der gør denne lærerig, er at den samme kildefil allerede indeholdt den korrekte rækkefølge. /Matrix-posten på et Form-XObjekt havde den samme omvendte sammensætning, men Type 3-glyf-stien og den indlejrede glyf-konturs sti fik begge det rigtigt fra starten, fordi glyfplacering synligt kollapser til origo, hvis man vender den om, og nogen var allerede blevet tvunget til at rette den. To konventioner sameksisterede i én enhed i tre dusin releases, hver korrekt i sin egen funktion, og ingen reviewer bemærkede det, fordi intet af kaldestederne så forkert ud isoleret set
Hvad sker der, når et tokenindeks er forskudt med én?
Man får tolv operatorer, der er syntaktisk håndteret og semantisk døde. Operand-accessoren i rendereren er NumAt(Back), som læser Tokens[OpIndex - Back], og OpIndex er indekset for selve operator-tokenet. En enkelt-operand-operator finder derfor sit tal ved back 1. Tolv af dem var skrevet som NumAt(0), som læser operator-tokenet, fejler ctOperandNumber-type-tjekket, og returnerer nulstandarden. Listen er Tc, Tw, Tz, TL, Ts og Tr fra tekst-tilstandsoperatorerne i ISO 32000-1 §9.3, plus w, J, j, M, ri og i fra grafik-tilstandsoperatorerne i §8.4.3. Tegn- og ordafstand blev no-ops, horisontal skalering blev aldrig anvendt, leading forblev nul så T* aldrig rykkede en linje frem, tekst-rise gjorde intet, rendertilstand var altid fill, og hvert stroke i hvert dokument kom ud som en 1-pixel hårlinje uanset den deklarerede linjebredde. Fleroperand-operatorer som m, rg og Tm brugte NumAt(1..6) og var alle korrekte, så en reviewer, der skimmede funktionen, så en mur af plausibel indeksaritmetik med tolv forkerte poster indlejret i den
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)
Hvorfor forblev testsuiten grøn i 38 versioner?
Fordi assertions var for svage til at skelne en rendret side fra en delvist rendret en. Rendering-smokene hævdede ting som at output-bitmappet ikke er helt sort, eller at siden ikke er tom, eller at billed-digestet er ikke-nul. Hvert af dem holder, når tekst rendres og billeder ikke gør. Tekst tegnede fint, så framebufferen var aldrig uniform, digestet var aldrig nul, og suiten rapporterede succes, mens hele billedpipelinen i praksis var død kode. Svage assertions er forførende for grafik netop fordi stærke ser skrøbelige ud. Ingen vil have en test, der brækker, når en anti-aliasing-kant flytter sig én pixel, så den naturlige tilbagetrækning er at hævde noget, som ingen rimelig ændring kunne krænke — og den tilbagetrækning lander en på prædikater, som heller ingen urimelig ændring kan krænke. En separation-farverum-test hævdede, at outputtet var kendeligt fra sort; gråt på hvidt bestod den, og det gjorde hvidt på hvidt også. Testen målte ikke, om den rigtige farve blev malet. Den målte, om overhovedet noget var sket på lærredet
Hvordan skriver man en rendering-assertion, der faktisk fejler?
Tæl pixels af den forventede farve, i den forventede mængde, og lad position og størrelse falde ud af tællingen. Erstatningsdisciplinen er en håndbygget minimal PDF, ét visuelt faktum per fil, og en assertion på hvor mange pixels lander inden for en tolerance af en specifik RGB-triple. Et 200 gange 120-billede af ren rød placeret ved en kendt offset skal producere omkring 24000 røde pixels. Hvis ressourceopslaget misser, er tællingen 0. Hvis cm-kaskaden er omvendt, er tællingen 0. Hvis billedet rendres i det forkerte farverum, er tællingen 0. Ét tal fanger alle tre, og tolerancebåndet absorberer den anti-aliasing-støj, der fik folk til at vige fra eksakt sammenligning i første omgang
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');
Fire smokes blev omskrevet på denne måde — en Type 4 tint-transform, en billed-Do-placering, et optional-content-synlighedstilfælde og en Tr-stroke-tilstand — og imellem dem afdækkede de hele familien. Det er den egentlige lektie, og den generaliserer ud over denne kodebase: i en rendering-pipeline skal assertionen navngive farven. Alt blødere er et tjek på, at rendereren kørte, ikke et tjek på, at den tegnede. Hvis du bygger din egen side-til-bitmap-harness, er gennemgangen af siderasterisering det naturlige sted at bolte en pixel-tællings-hjælper på din første regression
Ærlige grænser
To grænser er værd at nævne ligeud. Tekst-klip-rendertilstande 4 til 7 tegnes som deres grundlæggende fill- eller stroke-tilstand, fordi rendereren ikke modellerer akkumulerede klip-stier fra glyf-konturer; dokumenter, der er afhængige af tekst-formet klipning, vil rendre teksten frem for det klippede artwork nedenunder. Og pixel-tællings-disciplinen beskrevet her er en smoke-test-teknik, ikke en overensstemmelsessuite — den beviser, at et specifikt visuelt faktum nåede framebufferen, hvilket er en meget lavere bar end at bevise, at output matcher en referencerasterisering. Det er dog netop den bar, disse fire fejl ikke kunne klare i tre års releases
Rendereren omtalt her leveres som del af standard-HotPDF Component til Delphi og C++Builder; produktsiden bærer den fulde API-reference for siderendering, inklusive bitmap-cachen og entry-punkterne for baggrundsprefetch