Un renderer PDF che non disegna nulla di solito non ha alcun bug nel proprio codice di disegno. Nel componente HotPDF per Delphi e C++Builder, quattro difetti separati facevano renderizzare pagine vuote mentre ogni riga di log restava pulita: operandi nome che portavano uno slash iniziale, una concatenazione cm invertita, e un indice di token che leggeva zero. Nessuno di essi sollevava un'eccezione. Nessuno di essi registrava nulla nel log. Il content stream veniva tokenizzato correttamente, il dispatcher degli operatori riconosceva ogni operatore, l'XObject immagine veniva decodificato in un bitmap valido, e poi la pagina usciva vuota. Quella combinazione — una pipeline che riporta successo a ogni stadio e non produce nulla di visibile — è la firma di una ricerca o di un indice che manca silenziosamente anziché fallire. Questo è un post-mortem di una tale famiglia di bug, e della disciplina di test che le ha permesso di sopravvivere per 38 release
Perché un renderer PDF non disegna proprio nulla?
Perché una ricerca di risorsa fallita in un renderer PDF è indistinguibile da una pagina vuota. Gli operandi nome del content stream e le chiavi del dizionario risorse sono due spazi di stringhe diversi, e HotPDF li confrontava tra loro senza normalizzarli. Il tokenizer legge /Im0 e mantiene il solidus, perché quello è il token; il dizionario /Resources /XObject caricato memorizza la chiave come Im0, perché il parser rimuove il delimitatore quando costruisce le chiavi del dizionario. Ogni FindValue contro un nome operando restituiva quindi -1. Il raggio dell'esplosione era più ampio delle sole immagini. ISO 32000-1 §8.9 copre Do, §8.4 copre gs e la sua ricerca /ExtGState, §8.6 copre cs e CS, e §8.7.4.3 copre sh. Tutti e cinque gli operatori indicizzavano il proprio sotto-dizionario di risorse con l'operando grezzo, quindi tutti e cinque fallivano. Gli spazi colore nominati ricadevano su DeviceGray, il che trasforma 1 scn in inchiostro bianco su una pagina bianca. Gli XObject immagine non venivano mai dipinti affatto — il percorso dell'immagine bitmap non aveva, in pratica, mai funzionato dal giorno in cui era arrivato. La correzione è un helper a livello di unit applicato a ogni ricerca indicizzata da operando, l'unico modo per impedire che la convenzione derivi di nuovo
// 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;
Un secondo miss correlato si trovava un livello più sotto. Il renderer aveva risolutori tipizzati solo per stream e dizionari, quindi un riferimento indiretto che puntava a un oggetto array di primo livello — il comune /CS0 5 0 R con [/Separation ...] dall'altra parte — si risolveva a nil attraverso entrambi e ricadeva sul link non risolto. Aggiungere un risolutore di oggetti generico ha corretto in un colpo solo gli spazi colore nominati e gli array di funzione. Se stai collegando dizionari di shading, la stessa disciplina di risoluzione si applica al percorso di shading assiale e radiale, dove la voce /Function è molto spesso indiretta
L'operatore cm e una concatenazione scritta al contrario
Il secondo difetto posizionava le immagini a circa centomila pixel fuori dalla pagina, il che sembra esattamente come non disegnarle affatto. ISO 32000-1 §8.3.4 definisce le trasformazioni PDF con vettori riga, e l'operatore cm concatena la sua matrice operando M sulla matrice di trasformazione corrente come M × CTM — M ha effetto per primo, la CTM esistente dopo. HotPDF compone le matrici tramite HPDFMatMul(A, B), che applica B prima di A. La chiamata corretta quindi passa la vecchia CTM come A. Il codice spedito passava la matrice operando come A, producendo CTM × M
L'ordine invertito è innocuo per un singolo cm e catastrofico per il tipico idioma a due passi. Posiziona un'immagine con 1 0 0 1 x y cm seguito da w 0 0 h 0 0 cm e la cascata corretta scala il quadrato unitario di (w, h) e poi lo trasla di (x, y). Sotto la cascata invertita la traslazione entra per prima e la scala la moltiplica, così un'immagine nominalmente a (60, 400) scalata a 200 per 120 finisce a (12000, 48000). Il test di clip in cima al blit la rifiuta, il blit viene saltato, e nulla da nessuna parte segnala un problema
// 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)));
Ciò che rende questo caso istruttivo è che lo stesso file sorgente conteneva già l'ordine corretto. La voce /Matrix di un Form XObject aveva la stessa composizione invertita, ma il percorso dei glifi Type 3 e il percorso dei contorni glifo incorporati avevano entrambi l'ordine giusto fin dall'inizio, perché il posizionamento dei glifi collassa visibilmente all'origine quando lo inverti e qualcuno era già stato costretto a correggerlo. Due convenzioni sono coesistite in una sola unit per tre dozzine di release, ciascuna corretta nella propria funzione, e nessun revisore se n'è accorto perché nessuno dei due punti di chiamata sembrava sbagliato in isolamento
Cosa succede quando un indice di token è sfasato di uno?
Ottieni dodici operatori gestiti sintatticamente ma semanticamente morti. L'accessore di operando nel renderer è NumAt(Back), che legge Tokens[OpIndex - Back], e OpIndex è l'indice del token operatore stesso. Un operatore a operando singolo trova quindi il suo numero a back 1. Dodici di essi erano scritti come NumAt(0), che legge il token operatore, fallisce il controllo del tipo ctOperandNumber, e restituisce il default zero. L'elenco è Tc, Tw, Tz, TL, Ts e Tr dagli operatori di stato del testo di ISO 32000-1 §9.3, più w, J, j, M, ri e i dagli operatori di stato grafico di §8.4.3. La spaziatura tra caratteri e parole diventava un no-op, il ridimensionamento orizzontale non si applicava mai, il leading restava a zero così T* non avanzava mai una riga, il rise del testo non faceva nulla, la modalità di rendering era sempre fill, e ogni tratto in ogni documento usciva come una linea sottile di 1 pixel indipendentemente dalla larghezza di linea dichiarata. Gli operatori multi-operando come m, rg e Tm usavano NumAt(1..6) ed erano tutti corretti, quindi un revisore che scansionava la funzione vedeva un muro di aritmetica di indice plausibile con dodici voci sbagliate incorporate al suo interno
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)
Perché la suite di test è rimasta verde per 38 versioni?
Perché le asserzioni erano troppo deboli per distinguere una pagina renderizzata da una renderizzata solo in parte. Gli smoke test di rendering asserivano cose come il bitmap di output non è interamente nero, oppure la pagina non è vuota, oppure il digest dell'immagine non è zero. Ognuna di queste vale quando il testo si renderizza e le immagini no. Il testo disegnava bene, quindi il frame buffer non era mai uniforme, il digest non era mai zero, e la suite riportava successo mentre l'intera pipeline immagini era in pratica codice morto. Le asserzioni deboli sono seducenti per la grafica proprio perché quelle forti sembrano fragili. Nessuno vuole un test che si rompe quando un bordo di anti-aliasing si sposta di un pixel, quindi la ritirata naturale è asserire qualcosa che nessuna modifica ragionevole potrebbe violare — e quella ritirata ti porta su predicati che nemmeno una modifica irragionevole può violare. Un test dello spazio colore separation asseriva che l'output era distinguibile dal nero; grigio su bianco lo superava, e così anche bianco su bianco. Il test non misurava se il colore giusto fosse stato dipinto. Misurava se fosse successo qualcosa in assoluto sulla canvas
Come scrivi un'asserzione di rendering che fallisce davvero?
Conta i pixel del colore atteso, nella quantità attesa, e lascia che posizione e dimensione emergano dal conteggio. La disciplina sostitutiva è un PDF minimo costruito a mano, un fatto visivo per file, e un'asserzione su quanti pixel cadono entro una tolleranza da una specifica terna RGB. Un'immagine di 200 per 120 di rosso puro posizionata a un offset noto deve produrre circa 24000 pixel rossi. Se la ricerca di risorsa fallisce, il conteggio è 0. Se la cascata cm è invertita, il conteggio è 0. Se l'immagine si renderizza nello spazio colore sbagliato, il conteggio è 0. Un solo numero cattura tutti e tre i casi, e la banda di tolleranza assorbe il rumore di anti-aliasing che faceva esitare le persone dal confronto esatto fin dall'inizio
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');
Quattro smoke test sono stati riscritti in questo modo — una trasformazione tint Type 4, un posizionamento immagine Do, un caso di visibilità del contenuto opzionale e una modalità di tratto Tr — e tra loro hanno esposto l'intera famiglia. Questa è la vera lezione, e si generalizza oltre questa codebase: in una pipeline di rendering, l'asserzione deve nominare il colore. Qualunque cosa più morbida è un controllo che il renderer sia stato eseguito, non un controllo che abbia disegnato. Se stai costruendo il tuo harness pagina-a-bitmap, la trattazione della rasterizzazione delle pagine è il posto naturale dove agganciare un helper di conteggio pixel alla tua prima regressione
Limiti onesti
Due limiti vale la pena dichiarare apertamente. Le modalità di rendering per il clipping di testo da 4 a 7 vengono disegnate come la loro modalità base di fill o stroke, perché il renderer non modella percorsi di clip accumulati dai contorni glifo; i documenti che si affidano al clipping a forma di testo renderizzeranno il testo anziché la grafica ritagliata sottostante. E la disciplina di conteggio pixel descritta qui è una tecnica di smoke test, non una suite di conformità — dimostra che uno specifico fatto visivo ha raggiunto il frame buffer, il che è una soglia molto più bassa che dimostrare che l'output corrisponde a un rasterizzatore di riferimento. È, tuttavia, esattamente la soglia che questi quattro bug non sono riusciti a superare per tre anni di release
Il renderer trattato qui viene fornito come parte del componente standard HotPDF Component per Delphi e C++Builder; la pagina prodotto riporta il riferimento API completo per il rendering delle pagine, inclusi i punti di ingresso per la cache bitmap e il prefetch in background