Műszaki cikk

Két PDF-fájl összehasonlítása Delphiben: struktúra és pixelek

A HotPDF Delphiből a THPDFDocComparison segítségével hasonlít össze két PDF-dokumentumot, amely mindkét fájl objektumgráfját a katalógustól kifelé haladva járja be, és kérésre minden oldalpárt le is renderel, majd megméri az eltérő pixeleket. Az eredmény egy JSON-jelentés, amely megnevezi az összes talált eltérést, a felhasznált költségkeretet, és azt, hogy az összehasonlítás lefutott-e a végéig. Mindkét folyamat számít, mert a strukturális és a vizuális összehasonlítás más-más kérdésre válaszol

A funkció mögötti kérdés általában egy kiadási kérdés. Egy jelentéskészítő motor módosítást kap, a kimenet újragenerálódik, és valakinek el kell döntenie, hogy változott-e bármi. A két fájl egymás mellett történő megnyitása kb. három oldalig skálázódik, utána elfogy a figyelem. A nyers bájtok összehasonlítása azonnal kudarcot vall, mivel ugyanannak a generátornak két futása olyan okokból ad eltérő bájtokat, amelyeknek semmi közük ahhoz, amit egy olvasó lát

Miért lehetnek a PDF-ek bájtszinten eltérők, de vizuálisan azonosak?

Két egymástól függetlenül generált, azonosan nyomtatódó PDF rendszeresen eltér a bájtjaiban, és az okok strukturálisak, nem kozmetikaiak. Az objektumszámok abban a sorrendben kerülnek kiosztásra, ahogy az objektumok éppen íródnak. A betűtípus-részhalmazok abban a sorrendben osztanak ki CID-eket, ahogy a glifákkal először találkoznak, így egy, kissé eltérő bejárás során felépített részhalmaz eltérő tartalomfolyam-bájtokat ad ugyanahhoz a látható szöveghez. A kereszthivatkozási eltolások minden alkalommal elmozdulnak, amikor egy megelőző elem hossza megváltozik

Ezért nem használhatók az objektumszámok dokumentumok közötti azonosítóként. A HotPDF ehelyett úgy építi fel az egyes pillanatképeket, hogy a katalógustól kiindulva bejárja a struktúrát, a szótárakat a kulcsaik bájtsorrendjében, a tömböket index szerint bontva ki, így minden objektumot az azonosít, hogy milyen útvonalon érhető el. Azok az objektumok, amelyeket a bejárás nem tud elérni a gyökértől, egy szintetikus $Unreachable[...] útvonalra esnek vissza, amely tartalmazza az objektumszámot és a generációt, ez pedig láthatóvá teszi az árva tartalmat a jelentésben, ahelyett hogy csendben hiányozna

A streameket nem másolással hasonlítja össze. Minden stream egy inkrementális SHA-256 aláírással járul hozzá, amelyet úgy számol ki, hogy utána visszaállítja az eredeti stream-pozíciót, így két, egyenként kétszáz megabájtos fájl összehasonlítása nem jelenti kétszer kétszáz megabájt materializálását

Oldalak igazítása, ha az egyik dokumentumban beszúrás történt

Az 1. oldal összehasonlítása az 1. oldallal, a 2. oldalé a 2. oldallal és így tovább csak akkor helyes, ha semmi nem lett beszúrva. Szúrj be egy borítóoldalt, és egy naiv összehasonlítás minden oldalt megváltozottként jelent, ami technikailag igaz, operatívan viszont haszontalan

A HotPDF az oldalak összehasonlítása előtt igazítja azokat. Oldalanként egy aláírást épít a kinyerhető szövegből, szöveg nélküli oldalaknál strukturális aláírásra vált, majd kiszámítja a leghosszabb növekvő részsorozatot az egyeztetett célindexek felett. Az e részsorozaton belüli oldalak azok, amelyek csupán eltolódtak; az azon kívüliek valódi mozgatások. Ez a megkülönböztetés teszi olvashatóvá egy 400 oldalas kézikönyv összehasonlítását, mert a jelentés azt mondja, egy oldal lett beszúrva, nem azt, hogy négyszáz oldal változott

Strukturális összehasonlítás futtatása

A legegyszerűbb hívás két betöltött dokumentumot és egy módot vár. A cmStructural az objektumgráf bejárását végzi, a cmRenderedImage a pixelösszehasonlítást, a cmFull mindkettőt, a könnyebb cmPageCount, cmPageText és cmObjectCount módok pedig olcsó gyorsellenőrzésekhez léteznek:

uses
  HPDFDoc, HPDFDocCompare;

var
  DocA, DocB: THotPDF;
  Report: AnsiString;
begin
  DocA := THotPDF.Create(nil);
  DocB := THotPDF.Create(nil);
  try
    if (DocA.LoadFromFile('baseline.pdf') <= 0) or
       (DocB.LoadFromFile('candidate.pdf') <= 0) then
      Exit;
    Report := THPDFDocComparison.Compare(DocA, DocB, cmStructural);
    with TFileStream.Create('diff.json', fmCreate) do
    try
      WriteBuffer(Report[1], Length(Report));
    finally
      Free;
    end;
  finally
    DocB.Free;
    DocA.Free;
  end;
end;

A jelentés három állapotot különböztet meg, amit egy logikai érték nem tud. Az identical azt mondja meg, hogy volt-e bármi eltérés, a comparisonComplete azt, hogy a bejárás befejeződött-e, a comparisonBudget pedig megnevezi azt a korlátot, amely leállította a folyamatot, ha ez történt. Egy olyan összehasonlítás, amely kimeríti a költségkeretet, egyszerre jelent comparisonComplete=false és identical=false értéket, mert egy csonkolt bejárásnak nincs alapja egyenlőséget állítani. Bármely automatizálás, amely csak az identical-t olvassa, előbb-utóbb valódi eltérésként fogja kezelni a költségkeret-leállást, ezért mindhárom értéket olvasd el

Milyen korlátok tartják kordában a bejárást?

A THPDFStructuralCompareLimits.Default alapértelmezett értékei valódi dokumentumokhoz vannak méretezve, nem rosszindulatúakhoz, és minden szemantikailag releváns költségkeretnek megvan a saját felső határa: 250 000 objektum, 2 000 000 él, 128-as mélység, 10 000 jelentett eltérés, streamenként 64 MB, összesen 512 MB stream-bájt, értékenként 1 MB, útvonalanként pedig 4096 bájt. Emeld meg őket tudatosan, ha ismered a korpuszodat, és csökkentsd, amikor kívülről érkezett fájlokat hasonlítasz össze:

var
  Limits: THPDFStructuralCompareLimits;
  Options: THPDFRenderedCompareOptions;
begin
  Limits := THPDFStructuralCompareLimits.Default;
  Limits.MaxDifferences := 200;        // gyors hibajelzés CI-ben
  Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // az alapértelmezett érték 72
  Options.ColorTolerance := 2;         // az 1-2 szintű kerekítési zajt figyelmen kívül hagyja
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // átfedő képek írása áttekintéshez

  Report := THPDFDocComparison.CompareWithOptions(DocA, DocB, cmFull,
    Limits, Options);
end;

A renderelt folyamat a pixelszámot az oldalméretekből és a kért DPI-ből becsli meg, mielőtt bármilyen bitkép lefoglalásra kerülne, majd utólag újraellenőrzi a tényleges bitképet, így egy hibás oldalgeometria nem tud átcsúszni a költségkereten azzal, hogy hazudik a méretéről. A DPI emelése négyzetesen növeli a hűséget és a költséget: a 150 DPI négyszer annyi pixelt jelent, mint a 72, az oldalankénti és a teljes pixelhatár pedig pontosan azért létezik, mert egy 300 DPI-s kötegelt feladat egyébként bajba foglalhatja magát

Mennyire hasonló a kellően hasonló?

Két oldal csak akkor számít hasonlónak, ha mindkét feltétel teljesül: a megváltozott pixelek aránya eléri vagy alatta marad a MaxChangedPixelRatio értéknek, és a hasonlóság eléri vagy meghaladja a MinimumSimilarity értéket. Két küszöbérték egy helyett, mert egy maroknyi katasztrofálisan rossz pixel és a színek apró elmozdulásainak széles elterjedése két különböző hiba, és bármelyik önmagában elfogadható lehet az egyik munkafolyamatban, kizáró ok a másikban. A küszöbtesztek kerekítés nélküli értékeket használnak; a JSON-ban szereplő hat tizedesjegy a jelentések stabilitását és összehasonlíthatóságát szolgálja, nem az összehasonlítás definiálását

A megváltozott pixeleket fix méretű csempéket négyirányú szomszédossággal használó csomópontokkal csoportosítja régiókká, nem pedig pixelenkénti kitöltéssel. Ez korlátozza a memóriahasználatot, és futtatások között stabilan tartja a régiólistát. A megőrzött régiórészletek csonkolása csak a listázást érinti, nem a jelentett régiószámot, így egy olyan oldal, amelyen több megváltozott régió van, mint a MaxChangedRegions, továbbra is jelenti, hány volt belőlük

Egy viselkedést érdemes egyértelműen kimondani, mert ez megfordítja a szokásos ösztönt. A renderelési hibákat, a lefoglalási hibákat és az átfedési hibákat soha nem nyeli el a rendszer. Minden ilyen jellegű esemény renderError vagy renderBudget formájában kerül rögzítésre, és renderComparisonComplete=false értéket kényszerít ki, mert az az oldal, amelyet nem sikerült renderelni, olyan oldal, amelyet senki sem hasonlított össze, és azonosként jelenteni rosszabb, mint egyáltalán nem jelenteni

Hol a helye az egyes módoknak egy folyamatban?

A strukturális összehasonlítás arra válaszol, hogy mi változott, és a helyes alapértelmezés regressziós tesztkészletekhez: megnevezi az útvonalat, az oldalindexet és az érintett objektumszámokat, így egy hiba egyenesen az azt előidéző kódra mutat. A renderelt összehasonlítás arra válaszol, hogy bárki észreveszi-e, ami a jóváhagyások kérdése, valamint annak ellenőrzése, hogy egy optimalizálási lépés valóban veszteségmentes volt-e

Jól kiegészítik egymást. Futtasd a cmStructural-t minden buildnél, és hagyd, hogy hangosan elbukjon a váratlan objektumszintű változásoknál; futtasd a cmFull-t hőtérképekkel egy kiadás előtt, amikor van ember, aki megnézi az átfedéseket. Azoknál a folyamatoknál, amelyek más okból már oldaljelölést állítanak elő, a PDF-oldalak SVG-be exportálásában leírt szöveges kimenet egy harmadik, ember számára is összehasonlítható nézetet ad, a preflight jelentés automatizálásban szereplő automatizált ellenőrzések pedig olyan megfelelőségi kérdéseket fednek le, amelyekre egyik összehasonlítási mód sem hivatott válaszolni

Az összehasonlítás, a preflight és a renderelés ugyanazt a betöltöttdokumentum-objektummodellt osztja meg, így egyetlen áthaladás egy fájlon mindhármat táplálhatja. A Delphihez és C++Builderhez elérhető teljes funkciólista a HotPDF Delphi PDF-komponens oldalán található