Tehnički članak

PDF renderer ne crta ništa: četiri tiha Delphi baga

PDF renderer koji ne crta ništa obično uopšte nema bag u kodu za crtanje. U HotPDF Delphi Component za Delphi i C++Builder, četiri odvojena defekta su činila da se stranice renderuju prazne dok je svaka linija loga ostajala čista: operandi imena koji nose vodeću kosu crtu, obrnuta cm konkatenacija, i indeks tokena koji je čitao nulu. Nijedan od njih nije bacio grešku. Nijedan nije logovao. Tok sadržaja se tačno tokenizovao, dispečer operatora je prepoznao svaki operator, XObject slike je dekodiran u validnu bitmapu, a onda je stranica ispala prazna. Ta kombinacija — pipeline koji na svakoj fazi prijavljuje uspeh, a ne proizvodi ništa vidljivo — je potpis pretrage ili indeksa koji tiho promaši umesto da otkaže. Ovo je post-mortem jedne takve porodice, i discipline testiranja koja joj je omogućila da preživi 38 izdanja

Zašto PDF renderer ne crta baš ništa?

Zato što je neuspela pretraga resursa u PDF rendereru neraspoznatljiva od prazne stranice. Operandi imena u toku sadržaja i ključevi rečnika resursa su dva različita prostora stringova, a HotPDF ih je poredio unakrsno bez normalizacije. Tokenizator čita /Im0 i zadržava kosu crtu, jer to je taj token; učitani rečnik /Resources /XObject čuva ključ kao Im0, jer parser uklanja graničnik kada gradi ključeve rečnika. Svaki FindValue protiv imena operanda zato je vraćao -1. Radijus dejstva je bio š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 je ključalo svoj podrečnik resursa sirovim operandom, pa je svih pet promašilo. Imenovani prostori boja su padali nazad na DeviceGray, što pretvara 1 scn u belo mastilo na beloj stranici. XObjecti slika nikad nisu bili nacrtani uopšte — putanja bitmap slike, u praksi, nikad nije radila od dana kad je uvedena. Popravka je helper na nivou unita primenjen na svaku pretragu ključanu operandom, što je jedini način da se spreči da konvencija ponovo skrene

HotPDF: Operand content stream-a sa kosom crtom Im0 koji se ne poklapa sa ključem rečnika resursa bez kose crte Im0 pa FindValue vraća -1 i pet operatora Do, gs, cs, CS i sh svi promaše svoje pretrage resursa
Tokenizator zadržava kosu crtu u operandu dok parser resursa skida s ključeva rečnika, pa svaka pretraga sirovog operanda vraća -1 i svih pet resursnih operatora tiho promašuju
// Tok sadržaja stranice, uobičajeni obrazac postavljanja slike:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// Token operanda je '/Im0'. Ključ rečnika resursa je 'Im0'

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Svako traženje resursa po imenu operanda prolazi kroz pomoćnu funkciju
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// Podrečnik /XObject može i sam biti indirektna referenca
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

Drugi, srodan promašaj sedeo je jedan sloj niže. Renderer je imao tipizovane resolvere samo za tokove i rečnike, pa se indirektna referenca koja pokazuje na objekat niza na najvišem nivou — čest /CS0 5 0 R sa [/Separation ...] na drugom kraju — razrešavala u nil kroz oba, i padala nazad na nerazrešenu vezu. Dodavanje generičkog resolvera objekata popravilo je imenovane prostore boja i nizove funkcija u jednom potezu. Ako povezujete rečnike senčenja, ista disciplina razrešavanja važi za putanju aksijalnog i radijalnog senčenja, gde je stavka /Function vrlo često indirektna

Operator cm i konkatenacija napisana unazad

Drugi defekt je postavljao slike otprilike sto hiljada piksela van stranice, što izgleda potpuno isto kao da ih uopšte nije nacrtao. ISO 32000-1 §8.3.4 definiše PDF transformacije vektorima redova, a operator cm spaja svoju matricu operanda M na trenutnu matricu transformacije kao M × CTM — M deluje prvo, postojeći CTM posle. HotPDF sastavlja matrice kroz HPDFMatMul(A, B), koja primenjuje B pre A. Ispravan poziv zato prosleđuje stari CTM kao A. Isporučeni kod je prosleđivao matricu operanda kao A, proizvodeći CTM × M

Obrnut redosled je bezopasan za jedan pojedinačan cm, a katastrofalan za standardni dvokoračni idiom. Postavite sliku sa 1 0 0 1 x y cm praćenim sa w 0 0 h 0 0 cm, i ispravna kaskada skalira jediničan 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 završava na (12000, 48000). Test isecanja na vrhu blita je odbacuje, blit se preskače, a ništa nigde ne prijavljuje problem

HotPDF: Redosled nadovezivanja PDF cm matrice koji pokazuje ISO ugovor novi CTM jednako M puta CTM, HPDFMatMul koji primenjuje svoj B argument prvi, i makete strana gde slika sleće na 60 400 pod ispravnim kaskadiranjem naspram 12000 48000 pod obrnutim
HPDFMatMul primenjuje svoj B argument prvo, pa prosleđivanje matrice operanda kao A obrće kaskadu i dvostepeno postavljanje slike sleće oko sto hiljada piksela van stranice, gde test isečka tiho preskače
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Pogrešno, a isporučivano je 38 verzija:
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 jeste da je isti izvorni fajl već sadržao ispravan redosled. Stavka /Matrix Form XObjecta imala je istu obrnutu kompoziciju, ali putanja Type 3 glifa i putanja obrisa ugrađenog glifa su obe od početka bile ispravne, jer se postavljanje glifa vidljivo sruši u koordinatni početak kad se invertuje, i neko je već bio primoran da to popravi. Dve konvencije su koegzistirale u jednom unitu tri desetine izdanja, svaka ispravna u svojoj sopstvenoj funkciji, i nijedan recenzent to nije primetio jer nijedno mesto poziva nije izgledalo pogrešno izolovano

Šta se dešava kad je indeks tokena pogrešan za jedan?

Dobijete dvanaest operatora koji su sintaksno 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 sa jednim operandom zato pronalazi svoj broj na back 1. Dvanaest njih je bilo napisano kao NumAt(0), što čita token operatora, ne prolazi proveru vrste ctOperandNumber, i vraća podrazumevanu nulu. Lista 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 karaktera i reči je postao no-op, horizontalno skaliranje se nikad nije primenjivalo, leading je ostajao na nuli pa T* nikad nije pomerao liniju, dizanje teksta nije radilo ništa, mod renderovanja je uvek bio fill, i svaki potez u svakom dokumentu izlazio je kao linija tanka 1 piksel bez obzira na deklarisanu širinu linije. Operatori sa više operanda kao što su m, rg i Tm koristili su NumAt(1..6) i svi su bili ispravni, pa je recenzent koji skenira funkciju video zid verodostojne aritmetike indeksa sa dvanaest pogrešnih stavki ugnežđenih unutra

HotPDF: NumAt indeksiranje tokena pomeren za jedan gde OpIndex adresira sam token operatora pa NumAt(0) pada na proveri vrste operanda i vraća nulu, nulovši dvanaest operatora sa jednim operandom od Tc Tw Tz TL Ts Tr do w J j M ri i i
Pošto OpIndex adresira sam token operatora, NumAt(0) čita operator, omane na proveri vrste operanda i vraća nultu podrazumevanu, ostavljajući dvanaest operatora teksta i grafičkog stanja semantički mrtvim
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 adresira token operatora, pa se usamljeni operand nalazi na poziciji 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 test suite ostao zelen 38 verzija?

Zato što su asercije bile preslabe da razlikuju renderovanu stranicu od delimično renderovane. Rendering smoke testovi su tvrdili stvari poput da izlazna bitmapa nije potpuno crna, ili da stranica nije prazna, ili da digest slike nije nula. Svaka od njih važi kada se tekst renderuje, a slike ne. Tekst se crtao dobro, pa frame buffer nikad nije bio uniforman, digest nikad nije bio nula, a paket je prijavljivao uspeh dok je ceo pipeline slika u praksi bio mrtav kod. Slabe asercije su zavodljive za grafiku upravo zato što jake izgledaju krhko. Niko ne želi test koji puca kad se ivica anti-aliasinga pomeri za jedan piksel, pa je prirodno povlačenje da se tvrdi nešto što nijedna razumna izmena ne bi mogla prekršiti — a to povlačenje vas dovodi do predikata koje ni nerazumna izmena ne može prekršiti. Test prostora boja separation tvrdio je da je izlaz razlučiv od crne; sivo na belom je prošlo, a i belo na belom. Test nije merio da li je nanesena prava boja. Merio je da li se uopšte nešto desilo na platnu

Kako napisati asertaciju renderovanja koja zaista pukne?

Prebrojite piksele očekivane boje, u očekivanoj količini, i pustite da pozicija i veličina proisteknu iz brojanja. Zamenska disciplina je ručno izrađen minimalan PDF, jedna vizuelna činjenica po fajlu, i asercija koliko piksela pada unutar tolerancije specifičnog RGB trojca. Slika 200 sa 120 čiste crvene boje postavljena na poznat ofset mora proizvesti otprilike 24000 crvenih piksela. Ako pretraga resursa promaši, broj je 0. Ako je cm kaskada obrnuta, broj je 0. Ako se slika renderuje u pogrešnom prostoru boja, broj je 0. Jedan broj hvata sva tri, a traka tolerancije apsorbuje šum anti-aliasinga koji je ljude terao da izbegavaju tačno poređenje na prvom mestu

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;

// Crvena slika 200x120 postavljena na 60,400 mora da iscrta oko 24000 crvenih piksela
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

Četiri smoke testa su prepisana na ovaj način — Type 4 tint transformacija, postavljanje slike putem Do, slučaj vidljivosti opcionog sadržaja i mod poteza Tr — i zajedno su otkrili celu porodicu. To je prava lekcija, i generalizuje se izvan ove kodne baze: u rendering pipelineu, asercija mora da imenuje boju. Sve mekše je provera da je renderer izvršen, ne provera da je nešto nacrtao. Ako gradite sopstveni harness za stranica-u-bitmapu, pregled rasterizacije stranice je prirodno mesto da zavrnete helper za brojanje piksela na svoju prvu regresiju

Iskrene granice

Dve granice vredi jasno navesti. Modovi renderovanja isecanja teksta 4 do 7 crtaju se kao njihov osnovni mod ispune ili poteza, jer renderer ne modeluje akumulirane putanje isecanja iz obrisa glifova; dokumenti koji se oslanjaju na isecanje oblikovano tekstom renderovaće tekst umesto isečene ilustracije ispod. A disciplina brojanja piksela opisana ovde je tehnika smoke testa, ne paket usaglašenosti — dokazuje da je specifična vizuelna činjenica stigla do frame buffera, što je mnogo niža granica od dokazivanja da izlaz odgovara referentnom rasterizatoru. Ipak, to je tačno granica koju ova četiri baga nisu uspela da pređu tri godine izdanja

Renderer o kojem je ovde reč isporučuje se kao deo standardne HotPDF Delphi Component za Delphi i C++Builder; proizvodna stranica nosi kompletnu referencu API-ja za renderovanje stranica, uključujući keš bitmapa i ulazne tačke pozadinskog prefetch-a