PDF renderer koji ne crta ništa obično nema nikakav bug u vlastitom kôdu crtanja. U HotPDF Component za Delphi i C++Builder, četiri odvojena kvara učinila su da se stranice renderiraju prazne dok je svaki redak zapisnika ostajao čist: operandi imena koji nose vodeću kosu crtu, obrnuta cm konkatenacija i indeks tokena koji je čitao nulu. Nijedan nije bacio iznimku. Nijedan nije zapisao u log. Tok sadržaja se tokenizirao ispravno, raspoređivač operatora prepoznao je svaki operator, XObjekt slike dekodiran je u valjanu bitmapu, a onda je stranica izašla prazna. Ta kombinacija — cjevovod koji prijavljuje uspjeh na svakoj fazi, a ne proizvodi ništa vidljivo — potpis je pretrage ili indeksa koji tiho promaši umjesto da propadne. Ovo je post-mortem jedne takve obitelji, i discipline testiranja koja joj je omogućila da preživi 38 izdanja
Zašto PDF renderer ne crta ništa?
Zato što je neuspjela pretraga resursa u PDF rendereru nerazlučiva od prazne stranice. Operandi imena u toku sadržaja i ključevi rječnika resursa dva su različita prostora nizova, a HotPDF ih je uspoređivao unakrsno bez normalizacije. Tokenizator čita /Im0 i zadržava kosu crtu, jer je to token. Učitani rječnik /Resources /XObject pohranjuje ključ kao Im0, jer parser uklanja graničnik kad gradi ključeve rječnika. Svaki FindValue prema imenu operanda stoga je vraćao -1. Radijus eksplozije bio je širi od slika. ISO 32000-1 §8.9 pokriva Do, §8.4 pokriva gs i njegovu pretragu /ExtGState, §8.6 pokriva cs i CS, a §8.7.4.3 pokriva sh. Svih pet operatora ključalo je svoj podrječnik resursa sirovim operandom, pa je svih pet promašilo. Imenovani prostori boja padali su natrag na DeviceGray, što 1 scn pretvara u bijelu tintu na bijeloj stranici. XObjekti slika nikad nisu bili nacrtani — put bitmapnih slika, u praksi, nikad nije radio od dana kad je isporučen. Popravak je pomoćna funkcija na razini jedinice primijenjena na svakoj pretrazi ključanoj operandom, što je jedini način da se konvencija zadrži da opet ne odluta
// 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;
Drugi, povezani promašaj sjedio je jedan sloj niže. Renderer je imao tipizirane razrješavače samo za tokove i rječnike, pa se indirektna referenca koja pokazuje na objekt polja najviše razine — česti /CS0 5 0 R s [/Separation ...] na drugom kraju — razrješavala u nil kroz oba, i padala natrag na nerazriješenu vezu. Dodavanje generičkog razrješavača objekata popravilo je imenovane prostore boja i polja funkcija u jednom potezu. Ako povezujete rječnike sjenčanja, ista disciplina razrješavanja vrijedi za put aksijalnog i radijalnog sjenčanja, gdje je unos /Function vrlo često indirektan
Operator cm i konkatenacija zapisana unatraške
Drugi kvar postavljao je slike otprilike sto tisuća piksela izvan stranice, što izgleda potpuno kao da ih se uopće ne crta. ISO 32000-1 §8.3.4 definira PDF transformacije s vektorima redaka, a operator cm konkatenira svoju operandnu matricu M na trenutnu transformacijsku matricu kao M × CTM — M djeluje prvi, postojeći CTM zatim. HotPDF sastavlja matrice kroz HPDFMatMul(A, B), koji primjenjuje B prije A. Ispravan poziv stoga prosljeđuje stari CTM kao A. Isporučeni kôd prosljeđivao je operandnu matricu kao A, proizvodeći CTM × M
Obrnut redoslijed je bezopasan za jedan pojedinačni cm, a katastrofalan za standardni dvokoračni idiom. Postavite sliku s 1 0 0 1 x y cm praćeno s w 0 0 h 0 0 cm, i ispravna kaskada skalira jediničnu kvadrat po (w, h), a zatim ga translira po (x, y). Pod obrnutom kaskadom translacija ide prva, a skala je množi, pa slika nominalno na (60, 400) skalirana na 200 sa 120 slijeće na (12000, 48000). Test isjecanja na vrhu blita ju odbacuje, blit se preskače, i ništa nigdje ne prijavljuje 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)));
Ono što ovo čini poučnim je da je ista izvorna datoteka već sadržavala ispravan redoslijed. Unos /Matrix Form XObjekta imao je istu obrnutu kompoziciju, ali put Type 3 glifa i put ugrađene konture glifa oba su od početka bila ispravna, jer se postavljanje glifa vidljivo urušava u ishodište kad ga invertirate, i netko je već bio prisiljen to popraviti. Dvije konvencije koegzistirale su u jednoj jedinici tri desetljeća izdanja, svaka ispravna u vlastitoj funkciji, a nijedan recenzent to nije primijetio jer nijedno mjesto poziva nije izgledalo pogrešno izolirano
Što se događa kad je indeks tokena pomaknut za jedan?
Dobijete dvanaest operatora koji su sintaktički obrađeni, a semantički mrtvi. Pristupnik operanda u rendereru je NumAt(Back), koji čita Tokens[OpIndex - Back], a OpIndex je indeks samog tokena operatora. Operator s jednim operandom stoga pronalazi svoj broj na back 1. Dvanaest ih je bilo napisano kao NumAt(0), što čita token operatora, ne prolazi provjeru vrste ctOperandNumber, i vraća nulu prema zadanom. Popis je Tc, Tw, Tz, TL, Ts i Tr iz operatora stanja teksta ISO 32000-1 §9.3, plus w, J, j, M, ri i i iz operatora stanja grafike §8.4.3. Razmak znakova i riječi postali su no-op, horizontalno skaliranje nikad se nije primjenjivalo, vodeći razmak ostao je na nuli pa T* nikad nije pomicao redak, dizanje teksta nije radilo ništa, način renderiranja bio je uvijek ispuna, i svaki potez u svakom dokumentu izlazio je kao linija širine 1 piksel bez obzira na deklariranu širinu linije. Operatori s više operanada poput m, rg i Tm koristili su NumAt(1..6) i bili su svi ispravni, pa je recenzent koji je skenirao funkciju vidio zid vjerodostojne indeksne aritmetike s dvanaest pogrešnih unosa ugrađenih unutra
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)
Zašto je testni paket ostao zelen 38 izdanja?
Zato što su tvrdnje bile preslabe da razlikuju renderiranu stranicu od djelomično renderirane. Smoke testovi renderiranja tvrdili su stvari poput izlazna bitmapa nije potpuno crna, ili stranica nije prazna, ili sažetak slike nije nula. Svaka od njih vrijedi kad se tekst renderira, a slike ne. Tekst se crtao dobro, pa spremnik okvira nikad nije bio jednolik, sažetak nikad nije bio nula, a paket je prijavljivao uspjeh dok je cijeli cjevovod slika bio u praksi mrtav kôd. Slabe tvrdnje su primamljive za grafiku upravo zato što jake tvrdnje izgledaju krhko. Nitko ne želi test koji se pokvari kad se rub antialiasinga pomakne za jedan piksel, pa je prirodno povlačenje tvrditi nešto što nijedna razumna promjena ne bi mogla prekršiti — a to povlačenje vas smjesti na predikate koje ni nerazumna promjena ne može prekršiti. Test prostora boja separacije tvrdio je da je izlaz razlučiv od crne; siva na bijeloj je prošla, kao i bijela na bijeloj. Test nije mjerio je li nacrtana ispravna boja. Mjerio je je li se uopće nešto dogodilo na platnu
Kako napisati tvrdnju renderiranja koja stvarno propadne?
Brojanjem piksela očekivane boje, u očekivanoj količini, i dopuštanjem da položaj i veličina proizlaze iz broja. Zamjenska disciplina je ručno izrađen minimalan PDF, jedna vizualna činjenica po datoteci, i tvrdnja o tome koliko piksela slijeće unutar tolerancije specifičnog RGB trojca. Slika crvene boje 200 sa 120 postavljena na poznatom pomaku mora proizvesti otprilike 24000 crvenih piksela. Ako pretraga resursa promaši, broj je 0. Ako je kaskada cm obrnuta, broj je 0. Ako se slika renderira u pogrešnom prostoru boja, broj je 0. Jedan broj hvata sva tri, a traka tolerancije apsorbira šum antialiasinga koji je ljude odvraćao od točne usporedbe u prvom redu
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');
Četiri smoke testa prepisana su na ovaj način — transformacija tinte tipa 4, postavljanje slike putem Do, slučaj opcionalne vidljivosti sadržaja i način poteza Tr — a zajedno su otkrila cijelu obitelj. To je prava pouka, i generalizira se izvan ove baze kôda: u cjevovodu renderiranja, tvrdnja mora imenovati boju. Bilo što blaže je provjera da je renderer radio, a ne provjera da je crtao. Ako gradite vlastiti okvir za stranicu-u-bitmapu, pregled rasterizacije stranice prirodno je mjesto da pričvrstite pomoćnu funkciju brojanja piksela na svoju prvu regresiju
Iskrene granice
Dvije granice vrijedi jasno navesti. Načini renderiranja isjecanja teksta 4 do 7 crtaju se kao njihov osnovni način ispune ili poteza, jer renderer ne modelira akumulirane putanje isjecanja iz kontura glifova; dokumenti koji se oslanjaju na isjecanje oblikovano tekstom renderirat će tekst umjesto isjeljene umjetnine ispod. I disciplina brojanja piksela opisana ovdje je tehnika smoke testa, a ne paket usklađenosti — dokazuje da je specifična vizualna činjenica dosegla spremnik okvira, što je mnogo niža letvica od dokazivanja da se izlaz podudara s referentnim rasterizatorom. Ipak, upravo je to letvica koju ova četiri buga nisu prošla tri godine izdanja
Renderer o kojem je ovdje riječ isporučuje se kao dio standardnog HotPDF Component za Delphi i C++Builder; produktna stranica sadrži potpunu API referencu renderiranja stranica, uključujući ulazne točke predmemorije bitmapa i pozadinskog dohvaćanja