Odborný článok

PDF renderer nekreslí nič: štyri tiché Delphi bugy

PDF renderer, ktorý nekreslí nič, väčšinou nemá vôbec žiadny bug vo svojom kresliacom kóde. V HotPDF Component pre Delphi a C++Builder spôsobili štyri samostatné chyby, že sa stránky renderovali prázdne, kým každý log riadok ostal čistý: name operandy nesúce vedúcu lomku, obrátená konkatenácia cm a token index, ktorý čítal nulu. Žiadna z nich nevyvolala výnimku. Žiadna z nich nič nelogovala. Content stream sa tokenizoval správne, operator dispatcher rozpoznal každý operátor, image XObject sa dekódoval do platnej bitmapy, a potom stránka vyšla prázdna. Táto kombinácia — pipeline, ktorá na každom kroku hlási úspech a nevyprodukuje nič viditeľné — je podpis vyhľadávania alebo indexu, ktorý potichu minie namiesto toho, aby zlyhal. Toto je pitva jednej takej rodiny a disciplíny testovania, ktorá jej umožnila prežiť 38 vydaní

Prečo PDF renderer nekreslí vôbec nič?

Pretože zlyhané vyhľadávanie resource v PDF rendereri je nerozoznateľné od prázdnej stránky. Name operandy content streamu a kľúče resource slovníka sú dva rôzne reťazcové priestory, a HotPDF ich porovnával naprieč bez normalizácie. Tokenizer prečíta /Im0 a ponechá solidus, pretože to je to, čo token je; načítaný slovník /Resources /XObject ukladá kľúč ako Im0, pretože parser oddeľovač odstráni, keď zostavuje kľúče slovníka. Každý FindValue voči operand name teda vrátil -1. Rozsah zásahu bol širší než obrázky. ISO 32000-1 §8.9 pokrýva Do, §8.4 pokrýva gs a jeho vyhľadávanie /ExtGState, §8.6 pokrýva cs a CS, a §8.7.4.3 pokrýva sh. Všetkých päť operátorov kľúčovalo svoj resource pod-slovník podľa surového operandu, takže všetkých päť minulo. Pomenované farebné priestory padli späť na DeviceGray, čo zmení 1 scn na bielu farbu na bielej stránke. Image XObjekty sa nikdy nevykreslili vôbec — cesta bitmapového obrázka v praxi nikdy nefungovala odo dňa, keď pristála. Oprava je pomocník na úrovni unitu aplikovaný pri každom vyhľadávaní kľúčovanom operandom, čo je jediný spôsob, ako zabrániť tomu, aby konvencia znovu unikla

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

Druhý, súvisiaci nedostatok sedel o vrstvu nižšie. Renderer mal typované resolvery len pre streamy a slovníky, takže indirect reference ukazujúca na top-level pole objekt — bežné /CS0 5 0 R s [/Separation ...] na druhom konci — sa vyriešila na nil cez oboch a padla späť na nevyriešený odkaz. Pridanie generického resolvera objektov opravilo pomenované farebné priestory a function polia jedným krokom. Ak zapájate shading slovníky, rovnaká disciplína riešenia platí pre cestu axiálnych a radiálnych shadingov, kde je záznam /Function veľmi často indirect

Operátor cm a konkatenácia napísaná pozadu

Druhá chyba umiestňovala obrázky zhruba sto tisíc pixelov mimo stránku, čo vyzerá presne ako ich nekreslenie. ISO 32000-1 §8.3.4 definuje PDF transformácie s riadkovými vektormi, a operátor cm spája svoju operand maticu M na aktuálnu transformačnú maticu ako M × CTM — M sa uplatní najprv, existujúca CTM potom. HotPDF skladá matice cez HPDFMatMul(A, B), ktorá aplikuje B pred A. Správne volanie teda odovzdá starú CTM ako A. Dodaný kód odovzdal operand maticu ako A, čo vyprodukovalo CTM × M

Obrátené poradie je neškodné pre jeden cm a katastrofálne pre štandardný dvojkrokový idióm. Umiestnite obrázok pomocou 1 0 0 1 x y cm nasledovaného w 0 0 h 0 0 cm, a správna kaskáda škáluje jednotkový štvorec o (w, h) a potom ho posunie o (x, y). Pod obrátenou kaskádou ide posun dnu ako prvý a škála ho násobí, takže obrázok nominálne na (60, 400) škálovaný na 200 na 120 pristane na (12000, 48000). Clip test na vrchu blit ho odmietne, blit sa preskočí, a nič nikde nič nenahlási

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

Čo robí toto poučným, je to, že ten istý zdrojový súbor už obsahoval správne poradie. Záznam /Matrix vo Form XObjekte mal rovnakú obrátenú kompozíciu, ale cesta Type 3 glyfu a cesta obrysu embedovaného glyfu obe mali od začiatku správne, pretože umiestnenie glyfu sa viditeľne zrúti do počiatku, keď to invertujete, a niekto to už bol nútený opraviť. Dve konvencie koexistovali v jednom unite tri desiatky vydaní, každá správna vo svojej vlastnej funkcii, a žiadny reviewer si to nevšimol, pretože žiadne volacie miesto v izolácii nevyzeralo zle

Čo sa stane, keď je index tokenu o jedna mimo?

Dostanete dvanásť operátorov, ktoré sú syntakticky spracované a sémanticky mŕtve. Prístupový bod k operandom v rendereri je NumAt(Back), ktorý číta Tokens[OpIndex - Back], a OpIndex je index samotného operátorového tokenu. Jednooperandový operátor teda nájde svoje číslo na back 1. Dvanásť z nich bolo napísaných ako NumAt(0), čo prečíta operátorový token, zlyhá na kontrole druhu ctOperandNumber, a vráti predvolenú nulu. Zoznam je Tc, Tw, Tz, TL, Ts a Tr z operátorov textového stavu ISO 32000-1 §9.3, plus w, J, j, M, ri a i z operátorov stavu grafiky §8.4.3. Medzery medzi znakmi a slovami sa stali no-op, horizontálne škálovanie sa nikdy neuplatnilo, leading ostal na nule, takže T* nikdy neposunul riadok, text rise nerobil nič, render mode bol vždy fill, a každý ťah v každom dokumente vyšiel ako 1-pixelová vlasová čiara bez ohľadu na deklarovanú šírku čiary. Viacoperandové operátory ako m, rg a Tm používali NumAt(1..6) a boli všetky správne, takže reviewer prezerajúci funkciu videl stenu vierohodnej indexovej aritmetiky s dvanástimi nesprávnymi záznamami vloženými v nej

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)

Prečo testovacia sada ostala zelená 38 vydaní?

Pretože assercie boli príliš slabé na to, aby rozoznali vykreslenú stránku od čiastočne vykreslenej. Rendering smoke testy tvrdili veci ako výstupná bitmapa nie je celá čierna, alebo stránka nie je prázdna, alebo digest obrázka je nenulový. Každé z toho platí, keď sa text renderuje a obrázky nie. Text sa kreslil v poriadku, takže frame buffer nikdy nebol uniformný, digest nikdy nebol nulový, a sada hlásila úspech, kým celá pipeline obrázkov bola v praxi mŕtvy kód. Slabé assercie sú pri grafike zvodné práve preto, že silné vyzerajú krehko. Nikto nechce test, ktorý sa pokazí, keď sa anti-aliasing hrana posunie o jeden pixel, takže prirodzeným ústupom je tvrdiť niečo, čo žiadna rozumná zmena nemôže porušiť — a tento ústup vás dostane na predikáty, ktoré nedokáže porušiť ani žiadna nerozumná zmena. Test farebného priestoru separation tvrdil, že výstup je rozoznateľný od čiernej; sivá na bielej ho prešla, a rovnako aj biela na bielej. Test nemeral, či bola nakreslená správna farba. Meral, či sa na plátne vôbec niečo stalo

Ako napísať rendering asserciu, ktorá naozaj zlyhá?

Počítajte pixely očakávanej farby, v očakávanom množstve, a nechajte pozíciu a veľkosť vyplynúť z počtu. Náhradná disciplína je ručne zostavené minimálne PDF, jeden vizuálny fakt na súbor, a assercia na to, koľko pixelov pristane v tolerancii konkrétnej RGB trojice. Obrázok 200 na 120 čistej červenej umiestnený na známom offsete musí vyprodukovať zhruba 24000 červených pixelov. Ak resource lookup minie, počet je 0. Ak je kaskáda cm obrátená, počet je 0. Ak sa obrázok renderuje v nesprávnom farebnom priestore, počet je 0. Jedno číslo zachytí všetky tri, a pásmo tolerancie absorbuje šum anti-aliasingu, ktorý ľudí odrádzal od presného porovnávania na začiatku

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

Štyri smoke testy boli takto prepísané — Type 4 tint transform, umiestnenie obrázka Do, prípad viditeľnosti optional content a stroke mode Tr — a medzi sebou odhalili celú rodinu. To je skutočné poučenie, a zovšeobecňuje sa ďaleko za túto kódovú bázu: v rendering pipeline musí assercia pomenovať farbu. Čokoľvek mäkšie je kontrola, že renderer bežal, nie kontrola, že kreslil. Ak si staviate vlastný harness page-to-bitmap, prehľad rasterizácie stránky je prirodzeným miestom, kam pripojiť pomocníka na počítanie pixelov k vašej prvej regresii

Poctivé hranice

Za zmienku stoja dva limity. Text clipping render mody 4 až 7 sa kreslia ako ich základný fill alebo stroke mode, pretože renderer nemodeluje akumulované clip cesty z obrysov glyfov; dokumenty spoliehajúce sa na text-tvarované clippovanie vykreslia text namiesto orezanej grafiky pod ním. A disciplína počítania pixelov tu popísaná je technika smoke testu, nie konformitná sada — dokazuje, že konkrétny vizuálny fakt dosiahol frame buffer, čo je oveľa nižšia latka, než dokazovať, že výstup sa zhoduje s referenčným rasterizérom. Je to však presne tá latka, ktorú tieto štyri bugy nedokázali prekonať tri roky vydaní

Renderer tu diskutovaný sa dodáva ako súčasť štandardnej HotPDF Component pre Delphi a C++Builder; produktová stránka nesie kompletnú API referenciu renderovania stránok, vrátane bitmap cache a background prefetch vstupných bodov