Renderer PDF, který nekreslí nic, obvykle nemá vůbec žádnou chybu ve svém vykreslovacím kódu. V HotPDF Component pro Delphi a C++Builder způsobily čtyři samostatné vady, že se stránky vykreslovaly prázdné, přestože každý řádek logu zůstával čistý: operandy jmen nesoucí vedoucí lomítko, obrácené skládání cm a index tokenu, který četl nulu. Žádná z nich nevyhodila výjimku. Žádná z nich nic nezalogovala. Content stream se správně tokenizoval, dispatcher operátorů rozpoznal každý operátor, obrázkový XObject se dekódoval do platné bitmapy, a pak stránka vyšla prázdná. Tato kombinace – pipeline, která na každém stupni hlásí úspěch a nevyprodukuje nic viditelného – je signaturou vyhledávání nebo indexu, který potichu mine, místo aby selhal. Toto je posmrtný rozbor jedné takové rodiny chyb a testovací disciplíny, která jí umožnila přežít 38 vydání
Proč renderer PDF nekreslí vůbec nic?
Protože neúspěšné vyhledání zdroje v rendereru PDF je k nerozeznání od prázdné stránky. Operandy jmen v content streamu a klíče slovníku zdrojů jsou dva odlišné prostory řetězců a HotPDF je porovnával napříč nimi bez normalizace. Tokenizér přečte /Im0 a ponechá lomítko, protože to je přesně tím tokenem; načtený slovník /Resources /XObject ale ukládá klíč jako Im0, protože parser při sestavování klíčů slovníku oddělovač odstraní. Každé FindValue proti názvu operandu tedy vrátilo -1. Dosah škody byl širší než jen obrázky. ISO 32000-1 §8.9 pokrývá Do, §8.4 pokrývá gs a jeho vyhledávání /ExtGState, §8.6 pokrývá cs a CS a §8.7.4.3 pokrývá sh. Všech pět operátorů klíčovalo svůj podslovník zdrojů podle syrového operandu, takže všech pět minulo. Pojmenované barevné prostory spadly zpět na DeviceGray, což mění 1 scn na bílý inkoust na bílé stránce. Obrázkové XObjecty se nevykreslovaly vůbec – cesta bitmapového obrázku v praxi nikdy nefungovala od svého prvního dne. Oprava je pomocná funkce na úrovni unitu použitá při každém vyhledávání klíčovaném operandem, což je jediný způsob, jak zabránit tomu, aby konvence znovu neujela
// 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é, související minutí sedělo o vrstvu níž. Renderer měl typované resolvery jen pro streamy a slovníky, takže nepřímá reference ukazující na objekt typu pole na nejvyšší úrovni – běžné /CS0 5 0 R s [/Separation ...] na druhém konci – se přes oba resolvery rozřešila na nil a spadla zpět na nerozřešený odkaz. Přidání obecného resolveru objektů opravilo pojmenované barevné prostory i pole funkcí jedním tahem. Pokud zapojujete slovníky stínování, stejná disciplína rozřešení platí i pro cestu axiálního a radiálního stínování, kde je položka /Function velmi často nepřímá
Operátor cm a skládání napsané pozpátku
Druhá vada umísťovala obrázky zhruba sto tisíc pixelů mimo stránku, což vypadá přesně jako jejich nekreslení. ISO 32000-1 §8.3.4 definuje transformace PDF pomocí řádkových vektorů a operátor cm skládá svou operandovou matici M s aktuální transformační maticí jako M × CTM – M se uplatní jako první, existující CTM až poté. HotPDF skládá matice přes HPDFMatMul(A, B), které aplikuje B před A. Správné volání tedy předává starou CTM jako A. Dodaný kód předával operandovou matici jako A, což produkovalo CTM × M
Obrácené pořadí je neškodné u jediného cm a katastrofální u standardního dvoukrokového idiomu. Umístíte-li obrázek pomocí 1 0 0 1 x y cm následovaným w 0 0 h 0 0 cm, správná kaskáda nejprve zvětší jednotkový čtverec o (w, h) a pak jej posune o (x, y). Při obrácené kaskádě jde posun první a měřítko jej znásobí, takže obrázek nominálně na (60, 400) zvětšený na 200 krát 120 přistane na (12000, 48000). Test ořezu na začátku blitu jej odmítne, blit se přeskočí a nikde nic nehlásí problém
// 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)));
Poučné na tomto případu je, že stejný zdrojový soubor už správné pořadí obsahoval. Položka /Matrix u Form XObjectu měla stejné obrácené skládání, ale cesta glyfů Type 3 a cesta vestavěných obrysů glyfů to obě měly správně od začátku, protože umístění glyfu viditelně zkolabuje do počátku, když je obrátíte, a někdo už byl dřív donucen to opravit. Dvě konvence koexistovaly v jednom unitu po tři tucty vydání, každá správná ve své vlastní funkci, a žádný recenzent si toho nevšiml, protože žádné z volání nevypadalo špatně samostatně
Co se stane, když je index tokenu posunutý o jedna?
Dostanete dvanáct operátorů, které jsou syntakticky obsloužené a sémanticky mrtvé. Přístupová funkce operandu v rendereru je NumAt(Back), která čte Tokens[OpIndex - Back], a OpIndex je index samotného tokenu operátoru. Operátor s jedním operandem tedy najde své číslo na back 1. Dvanáct z nich bylo napsáno jako NumAt(0), což čte token operátoru, neprojde kontrolou typu ctOperandNumber a vrátí výchozí nulu. Seznam tvoří Tc, Tw, Tz, TL, Ts a Tr z operátorů stavu textu dle ISO 32000-1 §9.3, plus w, J, j, M, ri a i z operátorů stavu grafiky dle §8.4.3. Rozestupy znaků a slov se staly no-opy, horizontální škálování se nikdy neuplatnilo, řádkování zůstávalo na nule, takže T* nikdy neposunulo řádek, zdvih textu nedělal nic, režim vykreslování byl vždy výplň a každý tah v každém dokumentu vyšel jako 1pixelová vlásková čára bez ohledu na deklarovanou šířku čáry. Víceoperandové operátory jako m, rg a Tm používaly NumAt(1..6) a byly všechny správně, takže recenzent procházející funkci viděl zeď věrohodné indexové aritmetiky s dvanácti špatnými položkami vloženými uvnitř
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)
Proč zůstala testovací sada zelená po 38 verzí?
Protože asserty byly příliš slabé na to, aby rozlišily vykreslenou stránku od částečně vykreslené. Kouřové (smoke) testy vykreslování ověřovaly věci jako to, že výstupní bitmapa není celá černá, nebo že stránka není prázdná, nebo že digest obrázku je nenulový. Každá z těchto podmínek platí, když se vykreslí text a obrázky ne. Text se vykreslil v pořádku, takže framebuffer nikdy nebyl jednolitý, digest nikdy nebyl nula a sada hlásila úspěch, zatímco celá obrázková pipeline byla v praxi mrtvý kód. Slabé asserty jsou pro grafiku svůdné právě proto, že silné vypadají křehce. Nikdo nechce test, který se rozbije, když se hrana antialiasingu posune o jeden pixel, takže přirozeným ústupem je ověřit něco, co žádná rozumná změna nemůže porušit – a tento ústup vás přivede na predikáty, které nemůže porušit ani žádná nerozumná změna. Test separačního barevného prostoru ověřoval, že výstup je rozlišitelný od černé; prošla jím šedá na bílém, a stejně tak i bílá na bílém. Test neměřil, zda byla namalována správná barva. Měřil, zda se na plátně vůbec něco stalo
Jak napsat assert pro vykreslování, který skutečně selže?
Počítejte pixely očekávané barvy, v očekávaném množství, a nechte polohu a velikost vyplynout z tohoto počtu. Náhradní disciplína je ručně sestavené minimální PDF, jeden vizuální fakt na soubor a assert na to, kolik pixelů spadá do tolerance konkrétní trojice RGB. Obrázek 200 krát 120 čistě červené barvy umístěný na známém offsetu musí vyprodukovat zhruba 24000 červených pixelů. Pokud vyhledávání zdroje mine, počet je 0. Pokud je kaskáda cm obrácená, počet je 0. Pokud se obrázek vykreslí ve špatném barevném prostoru, počet je 0. Jedno číslo zachytí všechny tři případy a pásmo tolerance pohltí šum antialiasingu, který lidi od přesného porovnání odrazoval už na začátku
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');
Tímto způsobem byly přepsány čtyři kouřové testy – transformace odstínu Type 4, umístění obrázku přes Do, případ viditelnosti volitelného obsahu a režim tahu Tr – a dohromady odhalily celou tuto rodinu chyb. To je to skutečné poučení a přesahuje tuto codebase: v renderovací pipeline musí assert pojmenovat barvu. Cokoli měkčího je kontrola, že renderer proběhl, nikoli kontrola, že něco nakreslil. Pokud si stavíte vlastní harness pro převod stránky na bitmapu, přehled rasterizace stránky je přirozené místo, kam přišroubovat pomocnou funkci pro počítání pixelů k vaší první regresi
Poctivé hranice
Dvě omezení stojí za jasné vyslovení. Ořezové režimy vykreslování textu 4 až 7 se kreslí jako jejich základní režim výplně nebo tahu, protože renderer nemodeluje akumulované ořezové cesty z obrysů glyfů; dokumenty, které spoléhají na ořez tvarovaný textem, vykreslí text místo ořezané grafiky pod ním. A zde popsaná disciplína počítání pixelů je technika kouřového testu, nikoli konformní sada – dokazuje, že konkrétní vizuální fakt dorazil do framebufferu, což je mnohem nižší laťka než dokázat, že výstup odpovídá referenčnímu rasterizéru. Je to nicméně přesně ta laťka, kterou tyto čtyři chyby nedokázaly zdolat po tři roky vydání
Zde probíraný renderer je součástí standardní HotPDF Component pro Delphi a C++Builder; produktová stránka obsahuje kompletní referenci API pro vykreslování stránek, včetně vstupních bodů pro cache bitmap a background prefetch