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 Delphi 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

HotPDF: Operand obsahového toku s lomkou Im0 nesedí s kľúčom slovníka zdrojov bez lomky Im0, takže FindValue vráti -1 a päť operátorov Do, gs, cs, CS a sh všetky minú svoje vyhľadania zdrojov
Tokenizer ponecháva solidus v operandu, zatiaľ čo resource parser ho odstrihne od kľúčov slovníka, takže každé vyhľadanie surového operandu vráti -1 a všetkých päť resource operátorov minie potichu
// Prúd obsahu strany, bežný idiom umiestnenia obrázka:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// Token operandu je '/Im0'. Kľúč slovníka zdrojov je 'Im0'

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Každé vyhľadanie zdroja podľa mena operandu prechádza pomocnou funkciou
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// Podslovník /XObject môže byť sám nepriamym odkazom
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

HotPDF: Poradie konkatenácie matice cm PDF ukazujúce ISO kontrakt nová CTM rovná sa M krát CTM, HPDFMatMul aplikujúci svoj argument B prvý a makety strán, kde obrázok pristane na 60 400 pri správnej kaskáde verzus 12000 48000 pri obrátenej
HPDFMatMul aplikuje svoj argument B ako prvý, takže odovzdanie operand matice ako A prevráti kaskádu a dvojstupňové umiestnenie obrázka pristane asi sto tisíc pixelov od strany, kde ho clip test potichu preskočí
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Nesprávne, a dodávané 38 verzií:
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

HotPDF: Indexovanie tokenov NumAt posunuté o jednu, kde OpIndex adresuje samotný token operátora, takže NumAt(0) zlyhá kontrolu druhu operandu a vráti nulu, vynulujúc dvanásť operátorov s jedným operandom od Tc Tw Tz TL Ts Tr po w J j M ri a i
Pretože OpIndex adresuje samotný token operátora, NumAt(0) prečíta operátora, zlyhá na kontrole druhu operandu a vráti predvolenú nulu, čím nechá dvanásť textových a graphics state operátorov sémanticky mŕtvych
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 adresuje token operátora, takže osamelý operand je na pozícii 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;

// Červený obrázok 200x120 umiestnený na 60,400 musí vykresliť približne 24000 červených pixelov
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 Delphi 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