Un renderer PDF care nu desenează nimic de obicei nu are niciun bug în codul lui de desenare. În HotPDF Component pentru Delphi și C++Builder, patru defecte separate făceau paginile să se randeze goale, în timp ce fiecare linie de log rămânea curată: operanzi de nume cu slash la început, o concatenare cm inversată și un index de token care citea zero. Niciunul dintre ele nu arunca o excepție. Niciunul dintre ele nu loga nimic. Content stream-ul se tokeniza corect, dispatcher-ul de operatori recunoștea fiecare operator, XObject-ul de imagine era decodat într-un bitmap valid, iar apoi pagina ieșea goală. Această combinație — un pipeline care raportează succes la fiecare etapă și nu produce nimic vizibil — este semnătura unei căutări sau a unui index care ratează silențios, nu eșuează. Acesta este un post-mortem al unei asemenea familii de bug-uri, și al disciplinei de testare care i-a permis să supraviețuiască timp de 38 de release-uri
De ce un renderer PDF nu desenează absolut nimic?
Pentru că o căutare de resursă eșuată într-un renderer PDF e imposibil de deosebit de o pagină goală. Operanzii de nume din content stream și cheile din dicționarul de resurse sunt două spații de șiruri diferite, iar HotPDF le compara între ele fără normalizare. Tokenizer-ul citește /Im0 și păstrează solidus-ul, pentru că exact asta e token-ul; dicționarul /Resources /XObject încărcat stochează cheia ca Im0, pentru că parser-ul elimină delimitatorul atunci când construiește cheile dicționarului. Fiecare FindValue pentru un nume de operand returna deci -1. Raza defecțiunii era mai largă decât imaginile. ISO 32000-1 §8.9 acoperă Do, §8.4 acoperă gs și căutarea lui /ExtGState, §8.6 acoperă cs și CS, iar §8.7.4.3 acoperă sh. Toți cei cinci operatori indexau sub-dicționarul lor de resurse după operandul brut, așa că toți cinci ratau. Spațiile de culoare numite cădeau înapoi pe DeviceGray, ceea ce transformă 1 scn în cerneală albă pe o pagină albă. XObject-urile de imagine nu erau pictate deloc — calea de imagine bitmap, în practică, nu funcționase niciodată de la data la care a fost livrată. Fix-ul e un helper la nivel de unit, aplicat la fiecare căutare indexată după operand, care e singurul mod de a împiedica din nou convenția să alunece
// 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;
O a doua ratare, înrudită, stătea un nivel mai jos. Renderer-ul avea resolver-e tipizate doar pentru stream-uri și dicționare, așa că o referință indirectă care indica spre un obiect array de nivel superior — frecventul /CS0 5 0 R cu [/Separation ...] la celălalt capăt — se rezolva la nil prin ambele și cădea înapoi pe link-ul nerezolvat. Adăugarea unui resolver generic de obiecte a reparat dintr-o mișcare spațiile de culoare numite și array-urile de funcții. Dacă cablezi dicționare de shading, aceeași disciplină de rezolvare se aplică și la calea de shading axial și radial, unde intrarea /Function este foarte adesea indirectă
Operatorul cm și o concatenare scrisă invers
Al doilea defect plasa imaginile la aproximativ o sută de mii de pixeli în afara paginii, ceea ce arată exact ca și cum nu ar fi fost desenate deloc. ISO 32000-1 §8.3.4 definește transformările PDF cu vectori-linie, iar operatorul cm concatenează matricea sa operand M pe matricea de transformare curentă ca M × CTM — M ia efect primul, CTM-ul existent după. HotPDF compune matricile prin HPDFMatMul(A, B), care aplică B înainte de A. Apelul corect trece deci vechiul CTM ca A. Codul livrat trecea matricea operand ca A, producând CTM × M
Ordinea inversată e inofensivă pentru un singur cm și catastrofală pentru idiomul standard în doi pași. Plasează o imagine cu 1 0 0 1 x y cm urmat de w 0 0 h 0 0 cm, iar cascada corectă scalează pătratul unitate cu (w, h) și apoi îl translatează cu (x, y). Sub cascada inversată, translația intră prima, iar scalarea o multiplică, așa că o imagine nominal la (60, 400), scalată la 200 pe 120, ajunge la (12000, 48000). Testul de clip din vârful blit-ului o respinge, blit-ul e sărit, iar nimic, nicăieri, nu raportează o 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)));
Ce face acest caz instructiv e că același fișier sursă conținea deja ordinea corectă. Intrarea /Matrix a unui Form XObject avea aceeași compoziție inversată, dar calea glifelor Type 3 și calea conturului de glife încorporate au fost ambele corecte de la început, pentru că plasarea glifelor colapsează vizibil în origine atunci când o inversezi, iar cineva fusese deja forțat să repare asta. Două convenții au coexistat într-un singur unit timp de trei duzini de release-uri, fiecare corectă în funcția ei, iar niciun reviewer nu a observat, pentru că niciun call site nu arăta greșit izolat
Ce se întâmplă când un index de token e greșit cu unu?
Obții doisprezece operatori care sunt tratați sintactic corect, dar mort din punct de vedere semantic. Accesorul de operand din renderer e NumAt(Back), care citește Tokens[OpIndex - Back], iar OpIndex e indexul token-ului operatorului însuși. Un operator cu un singur operand își găsește deci numărul la back 1. Doisprezece dintre ei erau scriși ca NumAt(0), care citește token-ul operatorului, eșuează verificarea de tip ctOperandNumber și returnează valoarea implicită zero. Lista este Tc, Tw, Tz, TL, Ts și Tr din operatorii de stare a textului din ISO 32000-1 §9.3, plus w, J, j, M, ri și i din operatorii de stare grafică din §8.4.3. Spațierea între caractere și cuvinte a devenit no-op, scalarea orizontală nu se aplica niciodată, leading-ul rămânea la zero, așa că T* nu avansa niciodată o linie, ridicarea textului nu făcea nimic, modul de randare era mereu fill, iar fiecare stroke din fiecare document ieșea ca o linie subțire de 1 pixel, indiferent de lățimea de linie declarată. Operatorii cu mai mulți operanzi, cum ar fi m, rg și Tm, foloseau NumAt(1..6) și erau toți corecți, așa că un reviewer care scana funcția vedea un zid de aritmetică de index plauzibilă, cu douăsprezece intrări greșite îngropate în el
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)
De ce a rămas suita de teste verde timp de 38 de versiuni?
Pentru că assertion-urile erau prea slabe ca să deosebească o pagină randată de una parțial randată. Smoke-urile de randare verificau lucruri de genul bitmap-ul de ieșire nu e complet negru, sau pagina nu e goală, sau digest-ul imaginii e diferit de zero. Fiecare dintre astea se verifică atunci când textul se randează, iar imaginile nu. Textul se desena bine, așa că frame buffer-ul nu era niciodată uniform, digest-ul nu era niciodată zero, iar suita raporta succes în timp ce întregul pipeline de imagini era, în practică, cod mort. Assertion-urile slabe sunt seducătoare pentru grafică tocmai pentru că cele puternice par fragile. Nimeni nu vrea un test care se rupe când o margine de anti-aliasing se deplasează cu un pixel, așa că retragerea naturală e să verifici ceva ce nicio schimbare rezonabilă n-ar putea încălca — iar retragerea asta te duce la predicate pe care nici o schimbare nerezonabilă nu le poate încălca. Un test pentru spațiul de culoare separation verifica dacă output-ul era distinct de negru; gri pe alb îl trecea, și la fel alb pe alb. Testul nu măsura dacă se picta culoarea corectă. Măsura dacă se întâmplase absolut ceva pe canvas
Cum scrii o assertion de randare care chiar eșuează?
Numără pixelii de culoarea așteptată, în cantitatea așteptată, și lasă poziția și dimensiunea să reiasă din numărătoare. Disciplina de înlocuire e un PDF minimal construit manual, un fapt vizual pe fișier, și o assertion asupra câți pixeli cad într-o toleranță a unui triplet RGB specific. O imagine de 200 pe 120 de roșu pur, plasată la un offset cunoscut, trebuie să producă aproximativ 24000 de pixeli roșii. Dacă lookup-ul de resursă ratează, numărul e 0. Dacă cascada cm e inversată, numărul e 0. Dacă imaginea se randează în spațiul de culoare greșit, numărul e 0. Un singur număr le prinde pe toate trei, iar banda de toleranță absoarbe zgomotul de anti-aliasing care îi făcea pe oameni să evite comparația exactă, de la bun început
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');
Patru smoke-uri au fost rescrise în felul acesta — o transformare de tentă Type 4, o plasare de imagine Do, un caz de vizibilitate a conținutului opțional și un mod de stroke Tr — și, împreună, au expus întreaga familie de bug-uri. Aceasta e adevărata lecție, și se generalizează dincolo de această bază de cod: într-un pipeline de randare, assertion-ul trebuie să numească explicit culoarea. Orice e mai moale de atât e o verificare că renderer-ul a rulat, nu o verificare că a desenat ceva. Dacă îți construiești propriul harness de la pagină la bitmap, ghidul de rasterizare a paginii e locul firesc unde să prinzi un helper de numărare a pixelilor pe prima ta regresie
Granițe oneste
Merită menționate clar două limite. Modurile de randare cu clipping de text, de la 4 la 7, sunt desenate ca modul lor de bază de fill sau stroke, pentru că renderer-ul nu modelează căile de clip acumulate din conturul glifelor; documentele care se bazează pe clipping în formă de text vor randa textul, în loc de artwork-ul decupat de dedesubt. Iar disciplina de numărare a pixelilor descrisă aici e o tehnică de smoke test, nu o suită de conformitate — dovedește că un fapt vizual specific a ajuns în frame buffer, ceea ce e o ștachetă mult mai joasă decât a dovedi că output-ul se potrivește cu un rasterizator de referință. Este, totuși, exact ștacheta pe care aceste patru bug-uri au eșuat s-o treacă timp de trei ani de release-uri
Renderer-ul discutat aici este livrat ca parte a HotPDF Component standard, pentru Delphi și C++Builder; pagina produsului conține referința API completă pentru randarea paginilor, inclusiv punctele de intrare pentru cache-ul de bitmap-uri și prefetch-ul în fundal