Een PDF-renderer die niets tekent, heeft meestal helemaal geen bug in zijn tekencode. In het HotPDF Component voor Delphi en C++Builder zorgden vier afzonderlijke defecten ervoor dat pagina's blanco renderden terwijl elke logregel schoon bleef: naamoperanden met een leidende slash, een omgekeerde cm-concatenatie, en een tokenindex die nul las. Geen daarvan gooide een exceptie. Geen daarvan logde iets. De content stream tokeniseerde correct, de operator-dispatcher herkende elke operator, de image XObject werd gedecodeerd tot een geldige bitmap, en dan kwam de pagina leeg tevoorschijn. Die combinatie — een pipeline die bij elke stap succes rapporteert en niets zichtbaars produceert — is de signatuur van een lookup of een index die stilzwijgend mist in plaats van te falen. Dit is een post-mortem van zo'n familie, en van de testdiscipline die hem 38 releases lang liet overleven
Waarom tekent een PDF-renderer helemaal niets?
Omdat een mislukte resource-lookup in een PDF-renderer niet te onderscheiden is van een lege pagina. Content-stream-naamoperanden en resource-dictionary-sleutels zijn twee verschillende string-ruimtes, en HotPDF vergeleek ze zonder te normaliseren. De tokenizer leest /Im0 en behoudt de schuine streep, omdat dat is wat het token is; het geladen /Resources /XObject-dictionary slaat de sleutel op als Im0, omdat de parser het scheidingsteken strippt bij het bouwen van dictionary-sleutels. Elke FindValue tegen een operandnaam retourneerde daarom -1. De schade reikte verder dan afbeeldingen. ISO 32000-1 §8.9 behandelt Do, §8.4 behandelt gs en zijn /ExtGState-lookup, §8.6 behandelt cs en CS, en §8.7.4.3 behandelt sh. Alle vijf operatoren sleutelden hun resource-subdictionary op de ruwe operand, dus alle vijf misten. Genoemde kleurruimtes vielen terug op DeviceGray, wat 1 scn verandert in witte inkt op een witte pagina. Image XObjects werden helemaal nooit getekend — het bitmap-afbeeldingspad had in de praktijk sinds de dag dat het gelanceerd werd nooit gewerkt. De fix is een unit-niveau helper toegepast op elke operand-gesleutelde lookup, wat de enige manier is om te voorkomen dat de conventie opnieuw afdrijft
// 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;
Een tweede, verwante misser zat één laag lager. De renderer had alleen getypeerde resolvers voor streams en dictionaries, dus loste een indirecte referentie die naar een top-level array-object wees — de gebruikelijke /CS0 5 0 R met [/Separation ...] aan de andere kant — via beide op tot nil en viel terug op de niet-opgeloste link. Het toevoegen van een generieke object-resolver repareerde genoemde kleurruimtes en functie-arrays in één beweging. Als je shading-dictionaries aan het bekabelen bent, geldt dezelfde resolutiediscipline voor het axiale en radiale shading-pad, waar de /Function-entry heel vaak indirect is
De cm-operator en een concatenatie die achterstevoren geschreven werd
Het tweede defect plaatste afbeeldingen ongeveer honderdduizend pixels van de pagina af, wat er precies uitziet als ze niet tekenen. ISO 32000-1 §8.3.4 definieert PDF-transformaties met rijvectoren, en de cm-operator concateneert zijn operand-matrix M op de huidige transformatiematrix als M × CTM — M treedt eerst in werking, de bestaande CTM daarna. HotPDF stelt matrices samen via HPDFMatMul(A, B), die B toepast vóór A. De correcte aanroep geeft dus de oude CTM door als A. De uitgeleverde code gaf de operand-matrix door als A, wat CTM × M produceerde
Omgekeerde volgorde is onschadelijk voor een enkele cm en catastrofaal voor het standaard tweestaps-idioom. Plaats een afbeelding met 1 0 0 1 x y cm gevolgd door w 0 0 h 0 0 cm en de correcte cascade schaalt het eenheidsvierkant met (w, h) en verplaatst het dan met (x, y). Onder de omgekeerde cascade komt de verplaatsing eerst binnen en vermenigvuldigt de schaal die, dus een afbeelding nominaal op (60, 400) geschaald naar 200 bij 120 landt op (12000, 48000). De clip-test bovenaan de blit wijst het af, de blit wordt overgeslagen, en niets meldt ergens een probleem
// 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)));
Wat dit leerzaam maakt, is dat hetzelfde bronbestand al de correcte volgorde bevatte. De /Matrix-entry van een Form XObject had dezelfde omgekeerde samenstelling, maar het Type 3-glyph-pad en het embedded-glyph-outline-pad hadden het beide vanaf het begin goed, omdat glyphplaatsing zichtbaar naar de oorsprong instort wanneer je het omdraait en iemand daar al toe gedwongen was het te repareren. Twee conventies bestonden drie dozijn releases lang naast elkaar in één unit, elk correct in zijn eigen functie, en geen reviewer merkte het op omdat geen van beide aanroeppunten geïsoleerd fout leek
Wat gebeurt er wanneer een tokenindex één afwijkt?
Je krijgt twaalf operatoren die syntactisch afgehandeld worden en semantisch dood zijn. De operand-accessor in de renderer is NumAt(Back), die Tokens[OpIndex - Back] leest, en OpIndex is de index van het operatortoken zelf. Een operator met één operand vindt zijn getal dus op back 1. Twaalf ervan waren geschreven als NumAt(0), wat het operatortoken leest, faalt bij de ctOperandNumber-soortcontrole, en de standaardwaarde nul retourneert. De lijst is Tc, Tw, Tz, TL, Ts en Tr uit de tekststatus-operatoren van ISO 32000-1 §9.3, plus w, J, j, M, ri en i uit de grafische-statusoperatoren van §8.4.3. Teken- en woordspatiëring werden no-ops, horizontale schaling werd nooit toegepast, leading bleef op nul dus T* avanceerde nooit een regel, tekstverheffing deed niets, rendermodus was altijd fill, en elke streek in elk document kwam eruit als een haarlijn van 1 pixel ongeacht de opgegeven lijnbreedte. Multi-operand-operatoren zoals m, rg en Tm gebruikten NumAt(1..6) en waren allemaal correct, dus een reviewer die de functie doorliep zag een muur van plausibele indexrekenkunde met twaalf verkeerde entries erin verweven
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)
Waarom bleef de testsuite 38 versies lang groen?
Omdat de asserties te zwak waren om een gerenderde pagina te onderscheiden van een gedeeltelijk gerenderde. De render-smokes asserteerden dingen zoals de output-bitmap is niet volledig zwart, of de pagina is niet blanco, of de image-digest is niet nul. Elk daarvan houdt stand wanneer tekst rendert en afbeeldingen niet. Tekst tekende prima, dus was de framebuffer nooit uniform, was de digest nooit nul, en rapporteerde de suite succes terwijl de hele afbeeldingspipeline in de praktijk dode code was. Zwakke asserties zijn verleidelijk voor grafiek juist omdat sterke er broos uitzien. Niemand wil een test die breekt wanneer een anti-aliasing-rand één pixel verschuift, dus de natuurlijke terugtrekking is om iets te asserten dat geen redelijke verandering zou kunnen schenden — en die terugtrekking brengt je bij predikaten die ook geen onredelijke verandering kan schenden. Een test voor separation-kleurruimte asserteerde dat de output onderscheidbaar was van zwart; grijs op wit slaagde daarvoor, en wit op wit ook. De test mat niet of de juiste kleur geschilderd was. Hij mat of er überhaupt iets op het canvas gebeurd was
Hoe schrijf je een render-assertie die daadwerkelijk faalt?
Tel pixels van de verwachte kleur, in de verwachte hoeveelheid, en laat positie en grootte uit de telling voortvloeien. De vervangende discipline is een handgebouwde minimale PDF, één visueel feit per bestand, en een assertie over hoeveel pixels binnen een tolerantie van een specifieke RGB-drietal vallen. Een afbeelding van 200 bij 120 pixels puur rood geplaatst op een bekende offset moet ongeveer 24000 rode pixels produceren. Als de resource-lookup mist, is de telling 0. Als de cm-cascade omgekeerd is, is de telling 0. Als de afbeelding in de verkeerde kleurruimte rendert, is de telling 0. Eén getal vangt alle drie, en de tolerantieband absorbeert de anti-aliasing-ruis die mensen in de eerste plaats deed terugdeinzen voor exacte vergelijking
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');
Vier smoke-tests werden op deze manier herschreven — een Type 4 tint-transform, een image-Do-plaatsing, een optional-content-zichtbaarheidsgeval en een Tr-streekmodus — en samen legden ze de hele familie bloot. Dat is de echte les, en ze generaliseert voorbij deze codebase: in een renderpipeline moet de assertie de kleur benoemen. Alles zachter is een controle dat de renderer draaide, geen controle dat hij getekend heeft. Als je je eigen page-to-bitmap-harnas bouwt, is de doorloop van pagina-rasterisatie de natuurlijke plek om een pixel-telling-helper op je eerste regressie te bouten
Eerlijke grenzen
Twee limieten zijn het waard om ronduit te noemen. Text-clipping-rendermodi 4 tot en met 7 worden getekend als hun basis-fill- of streekmodus, omdat de renderer geen geaccumuleerde clip-paden van glyph-omtrekken modelleert; documenten die vertrouwen op tekstvormige clipping renderen de tekst in plaats van de geclipte kunst eronder. En de pixel-telling-discipline die hier beschreven wordt, is een smoke-test-techniek, geen conformiteitssuite — ze bewijst dat een specifiek visueel feit de framebuffer bereikte, wat een veel lagere lat is dan bewijzen dat de output overeenkomt met een referentie-rasterizer. Het is echter precies de lat die deze vier bugs drie jaar lang releases niet haalden
De hier besproken renderer wordt geleverd als onderdeel van het standaard HotPDF Component voor Delphi en C++Builder; de productpagina bevat de volledige API-referentie voor paginarendering, inclusief de bitmap-cache en de background-prefetch-entrypunten