Teknisk artikel

PDF-renderare ritar ingenting: fyra tysta Delphi-buggar

En PDF-renderare som inte ritar någonting har oftast ingen bugg alls i själva ritkoden. I HotPDF Component för Delphi och C++Builder gjorde fyra separata defekter att sidor renderades tomma medan varje loggrad förblev ren: namnoperander med ett inledande snedstreck, en omvänd cm-sammansättning och ett token-index som lästes som noll. Ingen av dem kastade ett undantag. Ingen av dem loggade något. Innehållsströmmen tokeniserades korrekt, operatordispatchern kände igen varje operator, bild-XObjektet avkodades till en giltig bitmapp, och sedan kom sidan ut tom. Den kombinationen — en pipeline som rapporterar framgång i varje steg och ändå producerar ingenting synligt — är kännetecknet för en uppslagning eller ett index som tyst missar snarare än misslyckas. Det här är en obduktion av en sådan felfamilj, och av den testdisciplin som lät den överleva 38 versioner

Varför ritar en PDF-renderare ingenting alls?

Därför att en misslyckad resursuppslagning i en PDF-renderare är omöjlig att skilja från en tom sida. Namnoperander i innehållsströmmen och nycklar i resursordlistan är två olika strängrum, och HotPDF jämförde dem utan att normalisera. Tokeniseraren läser /Im0 och behåller snedstrecket, för det är vad token är; den inlästa /Resources /XObject-ordlistan lagrar nyckeln som Im0, för parsern tar bort avgränsaren när den bygger ordlistenycklar. Varje FindValue mot ett operandnamn returnerade därför -1. Skadeområdet var bredare än bilder. ISO 32000-1 §8.9 täcker Do, §8.4 täcker gs och dess /ExtGState-uppslagning, §8.6 täcker cs och CS, och §8.7.4.3 täcker sh. Alla fem operatorer nycklade sin resursunderordlista med den råa operanden, så alla fem missade. Namngivna färgrymder föll tillbaka till DeviceGray, vilket gör 1 scn till vit bläck på en vit sida. Bild-XObjekt målades aldrig alls — bildpipelinen hade i praktiken aldrig fungerat sedan dagen den landade. Åtgärden är en enhetsnivå-hjälpfunktion som tillämpas vid varje operandnyckelbaserad uppslagning, vilket är enda sättet att hindra konventionen från att glida isär igen

// 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;

Ett andra, relaterat missfall satt ett lager längre ned. Renderaren hade endast typade upplösare för strömmar och ordlistor, så en indirekt referens som pekade på ett array-objekt på toppnivå — det vanliga /CS0 5 0 R med [/Separation ...] i andra änden — löstes till nil genom båda och föll tillbaka till den olösta länken. Att lägga till en generisk objektupplösare fixade namngivna färgrymder och funktionsarrayer i ett drag. Om du kopplar upp skuggningsordlistor gäller samma upplösningsdisciplin för axiell och radiell skuggningsväg, där posten /Function mycket ofta är indirekt

cm-operatorn och en sammansättning skriven baklänges

Den andra defekten placerade bilder ungefär hundra tusen pixlar utanför sidan, vilket ser exakt ut som att inte rita dem alls. ISO 32000-1 §8.3.4 definierar PDF-transformationer med radvektorer, och cm-operatorn sammansätter sin operandmatris M på den aktuella transformationsmatrisen som M × CTM — M träder i kraft först, den befintliga CTM därefter. HotPDF sammansätter matriser genom HPDFMatMul(A, B), som tillämpar B före A. Det korrekta anropet skickar därför den gamla CTM som A. Den levererade koden skickade operandmatrisen som A, vilket gav CTM × M

Omvänd ordning är ofarlig för en enda cm och katastrofal för det vanliga tvåstegsidiomet. Placera en bild med 1 0 0 1 x y cm följt av w 0 0 h 0 0 cm, och den korrekta kaskaden skalar enhetskvadraten med (w, h) och translaterar den sedan med (x, y). Under den omvända kaskaden går translationen in först och skalan multiplicerar den, så en bild nominellt vid (60, 400) skalad till 200 gånger 120 hamnar vid (12000, 48000). Klipptestet högst upp i blit-funktionen avvisar den, blit-anropet hoppas över, och ingenstans rapporteras något 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)));

Det som gör den här läroriken är att samma källfil redan innehöll den korrekta ordningen. Posten /Matrix för ett formulär-XObjekt hade samma omvända sammansättning, men Type 3-glyfvägen och vägen för inbäddade glyfkonturer fick båda det rätt från början, eftersom glyfplacering synligt kollapsar till origo när man inverterar den, och någon hade redan tvingats fixa den. Två konventioner samexisterade i en enhet i tre dussin versioner, var och en korrekt i sin egen funktion, och ingen granskare märkte det eftersom inget anropsställe såg fel ut isolerat

Vad händer när ett token-index är ett steg fel?

Du får tolv operatorer som är syntaktiskt hanterade och semantiskt döda. Operandaccessorn i renderaren är NumAt(Back), som läser Tokens[OpIndex - Back], och OpIndex är indexet för själva operatortoken. En operator med ett enda operand hittar därför sitt tal vid back 1. Tolv av dem var skrivna som NumAt(0), vilket läser operatortoken, misslyckas med ctOperandNumber-typkontrollen och returnerar standardvärdet noll. Listan är Tc, Tw, Tz, TL, Ts och Tr från textlägesoperatorerna i ISO 32000-1 §9.3, plus w, J, j, M, ri och i från grafiklägesoperatorerna i §8.4.3. Tecken- och ordmellanrum blev no-ops, horisontell skalning tillämpades aldrig, ledningen förblev noll så T* avancerade aldrig en rad, texthöjning gjorde ingenting, renderingsläget var alltid fill, och varje kontur i varje dokument kom ut som en 1-pixel hårlinje oavsett den deklarerade linjebredden. Flerodperandoperatorer som m, rg och Tm använde NumAt(1..6) och var alla korrekta, så en granskare som skummade funktionen såg en vägg av rimlig indexaritmetik med tolv felaktiga poster inbäddade i den

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)

Varför förblev testsviten grön i 38 versioner?

Därför att assertionerna var för svaga för att skilja en renderad sida från en delvis renderad. Renderingsröktesterna hävdade saker som att utdatabilden inte är helt svart, eller att sidan inte är tom, eller att bildens hashsumma är noll skild. Vart och ett av dessa håller när text renderas och bilder inte gör det. Text ritades utan problem, så framebuffern var aldrig enhetlig, hashsumman var aldrig noll, och sviten rapporterade framgång medan hela bildpipelinen i praktiken var död kod. Svaga assertioner är förföriska för grafik just för att starka verkar sköra. Ingen vill ha ett test som går sönder när en kant med kantutjämning förskjuts en pixel, så den naturliga reträtten är att hävda något som ingen rimlig ändring skulle kunna bryta — och den reträtten landar dig på predikat som inte heller någon orimlig ändring kan bryta. Ett test för separationsfärgrymd hävdade att utdata gick att skilja från svart; grått på vitt klarade det, och det gjorde vitt på vitt också. Testet mätte inte huruvida rätt färg målades. Det mätte om något alls hade hänt på duken

Hur skriver man en renderingsassertion som faktiskt misslyckas?

Räkna pixlar av den förväntade färgen, i den förväntade mängden, och låt position och storlek falla ut ur räkningen. Ersättningsdisciplinen är en handbyggd minimal PDF, ett visuellt faktum per fil, och en assertion om hur många pixlar hamnar inom en tolerans från en specifik RGB-trippel. En 200 gånger 120 bild i ren röd placerad vid en känd förskjutning måste ge ungefär 24000 röda pixlar. Om resursuppslagningen missar är antalet 0. Om cm-kaskaden är omvänd är antalet 0. Om bilden renderas i fel färgrymd är antalet 0. Ett enda tal fångar alla tre, och toleransbandet absorberar det brus från kantutjämning som fick folk att rygga tillbaka från exakt jämförelse från första början

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');

Fyra röktester skrevs om på det här sättet — en Type 4-tintransform, en bildplacering med Do, ett fall med synlighet för valfritt innehåll och ett Tr-konturläge — och tillsammans avslöjade de hela felfamiljen. Det är den verkliga lärdomen, och den generaliserar bortom den här kodbasen: i en renderingspipeline måste assertionen namnge färgen. Allt mjukare är en kontroll av att renderaren körde, inte en kontroll av att den ritade. Om du bygger din egen sida-till-bitmapp-rigg är genomgången av sidrastrering den naturliga platsen att bulta fast en pixelräknare på din första regressionstest

Ärliga gränser

Två begränsningar är värda att säga rakt ut. Textklippningsrenderingslägen 4 till 7 ritas som sitt bas-fyllnings- eller konturläge, eftersom renderaren inte modellerar ackumulerade klippvägar från glyfkonturer; dokument som förlitar sig på textformad klippning kommer att rendera texten snarare än det klippta konstverket under. Och pixelräkningsdisciplinen som beskrivs här är en röktestteknik, inte en konformitetssvit — den bevisar att ett specifikt visuellt faktum nådde framebuffern, vilket är en mycket lägre ribba än att bevisa att utdata matchar en referensrastrerare. Det är emellertid exakt den ribba som de här fyra buggarna inte klarade av i tre års versioner

Renderaren som diskuteras här levereras som en del av standardkomponenten HotPDF Component för Delphi och C++Builder; produktsidan innehåller den fullständiga API-referensen för sidrendering, inklusive bitmappscachen och entrypunkterna för bakgrundsförhämtning