Technický článek

Porovnání dvou souborů PDF v Delphi: struktura a pixely

HotPDF porovnává dva dokumenty PDF z Delphi pomocí THPDFDocComparison, který projde graf objektů obou souborů od katalogu směrem ven a na požádání také vykreslí každou dvojici stránek a změří pixely, které se liší. Výsledkem je zpráva JSON, jež pojmenuje každý nalezený rozdíl, spotřebovaný rozpočet a informaci, zda porovnání proběhlo do konce. Na obou průchodech záleží, protože strukturální a vizuální porovnání odpovídají na různé otázky

Otázka za touto funkcí je obvykle otázkou vydání. Generátor sestav dostane změnu, výstup se přegeneruje a někdo musí rozhodnout, jestli se něco pohnulo. Otevření obou souborů vedle sebe funguje asi do tří stránek, než selže pozornost. Porovnání syrových bajtů selže okamžitě, protože dva běhy stejného generátoru produkují odlišné bajty z důvodů, které nemají nic společného s tím, co vidí čtenář

Proč mohou být PDF bajtově odlišné a přitom vizuálně shodné?

Dva nezávisle vygenerované PDF soubory, které se tisknou identicky, se běžně liší ve svých bajtech, a důvody jsou strukturální, nikoli kosmetické. Čísla objektů se přidělují v pořadí, v jakém se objekty náhodou zapíšou. Podmnožiny fontů přidělují CID v pořadí, v jakém jsou glyfy poprvé nalezeny, takže podmnožina vytvořená během mírně odlišného průchodu produkuje pro stejný viditelný text odlišné bajty obsahového proudu. Posuny referenčních tabulek se mění pokaždé, když se výše v souboru změní délka čehokoli

Proto nelze čísla objektů použít jako identitu napříč dokumenty. HotPDF místo toho sestavuje každý snímek průchodem od katalogu, přičemž slovníky rozvíjí podle bajtového pořadí jejich klíčů a pole podle indexu, takže každý objekt je pojmenován cestou, která k němu vede. Objekty, ke kterým se průchod nedostane od kořene, spadají do syntetické cesty $Unreachable[...] nesoucí číslo objektu a generaci, což udrží osiřelý obsah viditelný ve zprávě místo toho, aby tiše zmizel

Proudy se neporovnávají kopírováním. Každý proud přispívá inkrementálním podpisem SHA-256, počítaným při zpětném obnovení původní pozice v proudu, takže porovnání dvou stovkamegabajtových souborů neznamená dvojí materializaci dvou set megabajtů

Zarovnání stránek, když jeden dokument obsahuje vloženou stránku

Porovnávání stránky 1 se stránkou 1, stránky 2 se stránkou 2 a tak dále je správné pouze tehdy, když nic nebylo vloženo. Vložte titulní stránku a naivní porovnání ohlásí jako změněnou úplně každou stránku, což je technicky pravda, ale provozně k ničemu

HotPDF stránky před porovnáním zarovná. Sestaví podpis každé stránky z extrahovatelného textu, u stránek bez textu se vrátí ke strukturálnímu podpisu a poté spočítá nejdelší rostoucí podposloupnost přes spárované indexy cíle. Stránky uvnitř této podposloupnosti jsou ty, které se pouze posunuly; stránky mimo ni jsou skutečné přesuny. Tento rozdíl dělá porovnání 400stránkové příručky čitelným, protože zpráva řekne, že byla vložena jedna stránka, místo aby tvrdila, že se změnilo čtyři sta stránek

Spuštění strukturálního porovnání

Nejjednodušší volání přebírá dva načtené dokumenty a režim. cmStructural provádí průchod grafem objektů, cmRenderedImage provádí pixelové porovnání, cmFull dělá obojí a lehčí režimy cmPageCount, cmPageText a cmObjectCount existují pro levné kouřové testy:

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;

Zpráva rozlišuje tři stavy, které booleovská hodnota vyjádřit nedokáže. identical říká, zda se něco lišilo, comparisonComplete říká, zda průchod doběhl do konce, a comparisonBudget pojmenuje limit, který jej případně zastavil. Porovnání, které vyčerpá rozpočet, hlásí společně comparisonComplete=false a identical=false, protože přerušený průchod nemá žádný podklad pro tvrzení o shodě. Jakákoli automatizace, která čte pouze identical, bude nakonec zastavení kvůli rozpočtu považovat za skutečný rozdíl, takže čtěte všechny tři hodnoty

Jaké limity udržují průchod omezený?

Výchozí hodnoty v THPDFStructuralCompareLimits.Default jsou dimenzované pro skutečné dokumenty, ne pro nepřátelské, a každý sémanticky relevantní rozpočet má vlastní strop: 250 000 objektů, 2 000 000 hran, hloubka 128, 10 000 hlášených rozdílů, 64 MB na proud a celkem 512 MB bajtů proudů, 1 MB na hodnotu a 4 096 bajtů na cestu. Zvyšte je záměrně, když znáte svůj korpus, a snižte je při porovnávání souborů, které přišly zvenčí:

var
  Limits: THPDFStructuralCompareLimits;
  Options: THPDFRenderedCompareOptions;
begin
  Limits := THPDFStructuralCompareLimits.Default;
  Limits.MaxDifferences := 200;        // rychlé selhání v CI
  Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // výchozí je 72
  Options.ColorTolerance := 2;         // ignorovat šum zaokrouhlení o 1-2 úrovně
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // zapsat obrázky s překryvem pro kontrolu

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

Vykreslovací průchod odhaduje počet pixelů z rozměrů stránky a požadovaného DPI ještě předtím, než se alokuje jakákoli bitmapa, a poté kontroluje skutečnou bitmapu, takže poškozená geometrie stránky nemůže proklouznout kolem rozpočtu lhaním o své velikosti. Zvýšení DPI zvyšuje věrnost i náklady kvadraticky: 150 DPI má čtyřikrát víc pixelů než 72, a stropy pixelů na stránku i celkové existují přesně proto, že dávková úloha při 300 DPI by se jinak alokovala do potíží

Jak podobné je dost podobné?

Dvě stránky se počítají jako podobné pouze tehdy, když platí obě podmínky: poměr změněných pixelů je na úrovni nebo pod MaxChangedPixelRatio a podobnost je na úrovni nebo nad MinimumSimilarity. Dva prahy místo jednoho, protože hrstka katastrofálně chybných pixelů a široký nádech drobných barevných posunů jsou odlišná selhání, a kterékoli z nich může být v jednom pracovním postupu přijatelné a v jiném diskvalifikující. Testy prahů používají nezaokrouhlené hodnoty; šest desetinných míst v JSON existuje kvůli stabilitě a porovnatelnosti zpráv, ne kvůli definici porovnání

Změněné pixely se seskupují do oblastí pomocí dlaždic pevné velikosti jako uzlů se čtyřsměrnou sousedností, nikoli pomocí vyplňování po jednotlivých pixelech. To udržuje paměť omezenou a seznam oblastí stabilní napříč běhy. Oříznutí uchovávaných detailů oblastí ovlivňuje pouze výpis, nikoli hlášený počet oblastí, takže stránka s více změněnými oblastmi, než je MaxChangedRegions, přesto uvede, kolik jich skutečně bylo

Jedno chování stojí za to výslovně zmínit, protože obrací obvyklý instinkt. Selhání vykreslovače, selhání alokace a selhání překryvu se nikdy nepolykají potichu. Cokoli takového se zaznamená jako renderError nebo renderBudget a vynutí renderComparisonComplete=false, protože stránka, kterou se nepodařilo vykreslit, je stránka, kterou nikdo neporovnal, a označit ji za shodnou je horší než neuvést nic

Kam který režim v pipeline patří

Strukturální porovnání odpovídá na otázku, co se změnilo, a je správnou výchozí volbou pro regresní sady: pojmenuje cestu, index stránky a zúčastněná čísla objektů, takže selhání ukazuje přímo na kód, který jej způsobil. Vykreslené porovnání odpovídá na otázku, jestli si toho někdo všimne, což je otázka pro schvalování a pro ověření, že optimalizační průchod byl skutečně bezztrátový

Dobře se kombinují. Spouštějte cmStructural při každém sestavení a nechte jej hlasitě selhat na neočekávaných změnách na úrovni objektů; spouštějte cmFull s teplotními mapami před vydáním, kdy je k dispozici člověk, který se na překryvy podívá. Pro pipeline, které už z jiných důvodů generují textový popis stránky, nabízí textový výstup popsaný v článku export stránek PDF do SVG třetí, člověkem porovnatelný pohled, a automatizované kontroly v článku automatizace preflight zpráv pokrývají otázky shody, na které žádný z režimů porovnání odpovídat nemá

Porovnání, preflight a vykreslování sdílejí stejný objektový model načteného dokumentu, takže jeden průchod souborem může sytit všechny tři. Kompletní seznam funkcí pro Delphi a C++Builder je na stránce komponenty HotPDF PDF pro Delphi