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

HotPDF: a perjellel előtagozott tartalomstream operandus Im0 nem egyezik a perjel nélküli erőforrásszótárkulccsal Im0, így a FindValue -1-et ad vissza, és az öt operátor Do, gs, cs, CS és sh mind kihagyják erőforrás-keresésüket
A tokenizáló megtartja a solidust az operandusban, míg az erőforrás-elemző leveszi a szótárkulcsokról, így minden nyers operanduskeresés -1-et ad, és mind az öt erőforrás-operátor csendben mellélő
// Oldaltartalom-folyam, a szokásos kép-elhelyezési minta:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// Az operandustoken '/Im0'. Az erőforrásszótár kulcsa 'Im0'

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

// Minden operandusnév alapján végzett erőforrás-keresés a segéden halad át
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// A /XObject részdictionary maga is lehet közvetett hivatkozás
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

HotPDF: PDF cm mátrix-összefűzési sorrend: az ISO szerződés szerint az új CTM egyenlő M szorozva CTM-mel, az HPDFMatMul először alkalmazza B argumentumát, és oldalak makettjein a kép 60 400-nál landol a helyes kaszkádban, szemben 12000 48000-nál a fordítottban
A HPDFMatMul előbb alkalmazza a B argumentumát, így az operandusmátrix A-ként való átadása megfordítja a kaszkádot, és a kétlépéses képelhelyezés nagyjából százezer pixellel az oldal mellé ér, ahol a vágásteszt csendben átugorja
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Hibás, és 38 verzión át így szállítottuk:
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

HotPDF: NumAt tokenindexelés eggyel eltérő: az OpIndex magát az operátortokent címezi, így a NumAt(0) megbukik az operandusfajta-ellenőrzésen, és nullát ad vissza, nullázva tizenkét egyoperandusos operátort a Tc Tw Tz TL Ts Trtől a w J j M ri és iig
Mivel az OpIndex magát az operátortokent célozza, a NumAt(0) az operátort olvassa, megbukik az operandusfajta-ellenőrzésen, és a nulla alapértelmezést adja vissza — tizenkét szöveg- és grafikaállapot-operátor marad szemantikailag halottan
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;

// Az OpIndex az operátortokenre mutat, ezért egyetlen operandus a hátsó 1-es pozícióban van
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 60,400-nál elhelyezett 200x120-as piros képnek körülbelül 24000 piros képpontot kell festenie
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 Delphi 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