Műszaki cikk

PDF renderelő semmit nem rajzol: négy csendes Delphi hiba

Egy PDF renderelő, amely semmit nem rajzol, általában nem tartalmaz hibát a rajzolókódjában. A HotPDF Component-ben Delphihez és C++Builderhez négy külön hiba tette az oldalakat üresre, miközben minden naplósor tiszta maradt: névoperandusok vezető perjellel, egy megfordított cm konkatenáció, és egy tokenindex, amely nullát olvasott. Egyik sem dobott kivételt. Egyik sem naplózott. A tartalomfolyam helyesen tokenizálódott, az operátor-diszpécser minden operátort felismert, a kép XObject érvényes bitmapbe dekódolódott, és aztán az oldal üresen jött ki. Ez a kombináció — egy pipeline, amely minden lépésnél sikert jelent, és semmi látható eredményt produkál — egy keresés vagy egy index jele, amely csendben téveszt, nem hibázik hangosan. Ez egy ilyen hibacsalád post-mortemje, és annak a tesztfegyelemnek is, amely lehetővé tette, hogy 38 kiadáson át túlélje

Miért nem rajzol semmit egy PDF renderelő?

Mert egy sikertelen erőforrás-keresés egy PDF renderelőben megkülönböztethetetlen egy üres oldaltól. A tartalomfolyam-névoperandusok és az erőforrás-szótár kulcsai két különböző sztringtér, és a HotPDF köztük normalizálás nélkül hasonlított össze. A tokenizáló beolvassa a /Im0-t, és megtartja a perjelet, mert az a token ez; a betöltött /Resources /XObject szótár a kulcsot Im0-ként tárolja, mert az elemző eltávolítja az elhatárolót, amikor felépíti a szótárkulcsokat. Minden FindValue egy operandusnév ellen ezért -1-et adott vissza. A robbanási sugár szélesebb volt a képeknél. Az ISO 32000-1 §8.9 a Do-t fedi le, a §8.4 a gs-t és annak /ExtGState keresését, a §8.6 a cs-t és CS-t, a §8.7.4.3 pedig az sh-t. Mind az öt operátor a nyers operandus alapján kulcsolta erőforrás-alszótárát, így mind az öt tévesztett. A megnevezett színterek visszaestek DeviceGray-re, ami az 1 scn-t fehér tintává alakítja fehér oldalon. A kép XObjectek soha nem lettek megrajzolva — a bitmap-kép útvonal a gyakorlatban sosem működött azóta, hogy landolt. A javítás egy egység-szintű segédfüggvény, amelyet minden operandus-kulcsolt keresésnél alkalmaznak, ez az egyetlen mód, hogy megakadályozzuk a konvenció újbóli elcsúszását

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

Egy második, kapcsolódó tévesztés egy réteggel lejjebb ült. A renderelőnek csak streamekhez és szótárakhoz voltak típusos feloldói, így egy indirekt hivatkozás, amely egy felső szintű tömbobjektumra mutatott — a gyakori /CS0 5 0 R a másik végén [/Separation ...]-vel — nil-re oldódott fel mindkettőn keresztül, és visszaesett a feloldatlan linkre. Egy generikus objektumfeloldó hozzáadása egyszerre javította a megnevezett színtereket és a függvénytömböket. Ha árnyalás-szótárakat kötsz be, ugyanaz a feloldási fegyelem vonatkozik a axiális és radiális árnyalás útvonalra, ahol a /Function bejegyzés nagyon gyakran indirekt

A cm operátor és egy visszafelé írt konkatenáció

A második hiba a képeket nagyjából százezer pixellel az oldalon kívülre helyezte, ami pontosan úgy néz ki, mintha nem rajzolná meg őket. Az ISO 32000-1 §8.3.4 sorvektorokkal definiálja a PDF transzformációkat, és a cm operátor az operandus-mátrixot M-ként konkatenálja az aktuális transzformációs mátrixra, mint M × CTM — M lép életbe elsőként, a meglévő CTM utána. A HotPDF a HPDFMatMul(A, B)-n keresztül komponálja a mátrixokat, amely B-t alkalmazza A előtt. A helyes hívás ezért a régi CTM-et adja át A-ként. A kiszállított kód az operandus-mátrixot adta át A-ként, CTM × M-et produkálva

A megfordított sorrend ártalmatlan egyetlen cm-nél, és katasztrofális a standard kétlépéses idiómánál. Helyezz el egy képet 1 0 0 1 x y cm-mel, amelyet w 0 0 h 0 0 cm követ, és a helyes kaszkád skálázza az egységnégyzetet (w, h)-val, majd eltolja (x, y)-nal. A megfordított kaszkád alatt az eltolás megy be elsőként, és a skála megszorozza azt, így egy nominálisan (60, 400)-nál lévő kép, amelyet 200-ra és 120-ra skáláznak, (12000, 48000)-nál landol. A blit tetején lévő vágásteszt elutasítja, a blit kimarad, és semmi sehol nem jelent problémát

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

Ami ebben tanulságos, az az, hogy ugyanaz a forrásfájl már tartalmazta a helyes sorrendet. Egy Form XObject /Matrix bejegyzésének ugyanaz volt a megfordított kompozíciója, de a Type 3 glyph-útvonal és a beágyazott glyph-körvonal útvonal mindkettő elsőtől fogva helyesen csinálta, mert a glyph-elhelyezés láthatóan az origóba omlik össze, ha megfordítod, és valakit már korábban is rákényszerítettek, hogy javítsa. Két konvenció élt egymás mellett egy egységben három tucat kiadáson át, mindegyik helyes a saját függvényén belül, és egyetlen reviewer sem vette észre, mert egyik hívási pont sem tűnt önmagában rossznak

Mi történik, amikor egy tokenindex eggyel eltolódik?

Tizenkét operátort kapsz, amelyek szintaktikailag kezeltek, és szemantikailag holtak. Az operandus-hozzáférő a renderelőben a NumAt(Back), amely a Tokens[OpIndex - Back]-t olvassa, és az OpIndex magának az operátor-tokennek az indexe. Egy egyoperandusú operátor ezért a számát a back 1-nél találja. Tizenkettőt NumAt(0)-ként írtak, ami az operátor-tokent olvassa, elbukik a ctOperandNumber típusellenőrzésen, és visszaadja a nulla alapértéket. A lista a Tc, Tw, Tz, TL, Ts és Tr az ISO 32000-1 §9.3 szövegállapot-operátoraiból, plusz a w, J, j, M, ri és i a §8.4.3 grafikaállapot-operátoraiból. A karakter- és szóköztávolság no-oppá vált, a vízszintes skálázás sosem alkalmazódott, a sorköz nullán maradt, így a T* sosem lépett tovább egy sort, a szövegemelkedés semmit nem tett, a rendermód mindig kitöltés volt, és minden vonás minden dokumentumban egypixeles hajszálvonalként jött ki, függetlenül a deklarált vonalvastagságtól. A többoperandusú operátorok, mint az m, rg és Tm, NumAt(1..6)-ot használtak, és mind helyesek voltak, így egy reviewer, aki átfutotta a függvényt, hihető indexaritmetika falát látta, tizenkét rossz bejegyzéssel beágyazva

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)

Miért maradt zöld a tesztcsomag 38 verzión át?

Mert az assertek túl gyengék voltak ahhoz, hogy megkülönböztessenek egy renderelt oldalt egy részlegesen renderelttől. A renderelési smoke-tesztek olyasmit állítottak, mint hogy a kimeneti bitmap nem teljesen fekete, vagy hogy az oldal nem üres, vagy hogy a kép digestje nem nulla. Mindegyik teljesül, amikor a szöveg renderelődik, de a képek nem. A szöveg jól rajzolódott, így a keretbufer sosem volt egyenletes, a digest sosem volt nulla, és a csomag sikert jelentett, miközben a teljes képpipeline gyakorlatilag holt kód volt. A gyenge assertek csábítóak grafikánál pontosan azért, mert az erősek törékenynek látszanak. Senki nem akar olyan tesztet, amely eltörik, ha egy élsimító él egy pixellel eltolódik, így a természetes visszavonulás olyasmit állítani, amit semmilyen ésszerű változás nem sérthet meg — és ez a visszavonulás olyan predikátumokhoz vezet, amelyeket semmilyen ésszerűtlen változás sem sérthet meg. Egy szeparációs színtér-teszt azt állította, hogy a kimenet megkülönböztethető a feketétől; a szürke fehéren átment rajta, és a fehér fehéren is. A teszt nem azt mérte, hogy a helyes szín lett-e megfestve. Azt mérte, hogy egyáltalán történt-e valami a vásznon

Hogyan írj egy renderelési assertet, amely tényleg elbukik?

Számold meg a várt szín pixeleit, a várt mennyiségben, és hagyd, hogy a pozíció és méret a számból következzen. A helyettesítő fegyelem egy kézzel épített minimális PDF, egyetlen vizuális tény fájlonként, és egy assert arról, hány pixel esik egy adott RGB hármas toleranciasávján belül. Egy 200x120-as tiszta piros kép, egy ismert eltolásnál elhelyezve, nagyjából 24000 piros pixelt kell produkáljon. Ha az erőforrás-keresés tévesztésbe fut, a szám 0. Ha a cm kaszkád megfordult, a szám 0. Ha a kép rossz színtérben renderelődik, a szám 0. Egy szám mindhármat elkapja, és a toleranciasáv elnyeli azt az élsimítási zajt, amely miatt az emberek visszariadtak a pontos összehasonlítástól

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

Négy smoke-tesztet írtak át így — egy Type 4 tint-transzformációt, egy kép Do elhelyezést, egy opcionális-tartalom láthatósági esetet, és egy Tr vonás-módot — és együttesen leleplezték az egész hibacsaládot. Ez a valódi tanulság, és ez túlmutat ezen a kódbázison: egy renderelési pipeline-ban az assertnek meg kell neveznie a színt. Bármi ennél lágyabb csak azt ellenőrzi, hogy a renderelő futott-e, nem azt, hogy rajzolt-e. Ha a saját oldal-bitmap keretrendszeredet építed, a oldal-raszterizáció bemutató a természetes hely, ahová a pixelszámláló segédfüggvényt csavarhatod az első regressziódra

Őszinte határok

Két korlátot érdemes tisztán kimondani. A 4-től 7-ig terjedő szövegvágó rendermódok az alap kitöltési vagy vonás-módjukként rajzolódnak, mert a renderelő nem modellezi a glyph-körvonalakból felhalmozódott vágóutakat; olyan dokumentumok, amelyek szöveg-alakú vágásra hagyatkoznak, a szöveget rajzolják meg az alatta lévő vágott grafika helyett. És az itt leírt pixelszámláló fegyelem smoke-teszt technika, nem megfelelőségi csomag — azt bizonyítja, hogy egy specifikus vizuális tény elérte a keretbuffert, ami sokkal alacsonyabb mérce, mint bizonyítani, hogy a kimenet megegyezik egy referencia-raszterizálóéval. Ez azonban pontosan az a mérce, amelyet ez a négy hiba három évnyi kiadáson át nem tudott átlépni

Az itt tárgyalt renderelő a szabványos HotPDF Component részeként érkezik Delphihez és C++Builderhez; a termékoldal tartalmazza a teljes oldal-renderelési API-referenciát, beleértve a bitmap-gyorsítótárat és a háttér-előbetöltési belépési pontokat