Технічна стаття

Порівняння двох файлів PDF у Delphi: структура і пікселі

HotPDF порівнює два PDF-документи з Delphi через THPDFDocComparison, який обходить граф об'єктів обох файлів від каталогу назовні і, за запитом, також рендерить кожну пару сторінок та вимірює пікселі, що відрізняються. Результат — це звіт у форматі JSON, що називає кожну знайдену різницю, витрачений бюджет і чи завершилося порівняння повністю. Обидва проходи важливі, бо структурна різниця та візуальна різниця відповідають на різні питання

Питання, що стоїть за цією можливістю, зазвичай є питанням релізу. Рушій звітів отримує зміну, результат перегенеровується, і комусь треба вирішити, чи щось зрушилося. Відкриття обох файлів поруч масштабується приблизно до трьох сторінок, поки не зраджує увага. Порівняння необроблених байтів провалюється одразу, оскільки два запуски того самого генератора дають різні байти з причин, що не мають жодного стосунку до того, що бачить читач

Чому файли PDF можуть відрізнятися побайтово, але виглядати однаково?

Два незалежно згенерованих PDF, що друкуються ідентично, регулярно відрізняються своїми байтами, і причини цього структурні, а не косметичні. Номери об'єктів призначаються в порядку, у якому об'єкти випадково записуються. Підмножини шрифтів виділяють CID у порядку першого виявлення гліфів, тож підмножина, побудована під час трохи іншого обходу, дає інші байти потоку вмісту для того самого видимого тексту. Зсуви таблиці перехресних посилань змінюються щоразу, коли щось попереднє змінює довжину

Ось чому номери об'єктів не можна використовувати як міждокументну ідентичність. Натомість HotPDF будує кожен знімок, обходячи від каталогу, розгортаючи словники в порядку байтів їхніх ключів і масиви за індексом, тож кожен об'єкт іменується шляхом, що до нього веде. Об'єкти, яких обхід не може досягти від кореня, отримують синтетичний шлях $Unreachable[...], що несе номер об'єкта та покоління, — це зберігає осиротілий вміст видимим у звіті замість того, щоб він мовчки зникав

Потоки не порівнюються шляхом копіювання. Кожен потік вносить інкрементний підпис SHA-256, обчислений із подальшим відновленням початкової позиції потоку, тож порівняння двох двохсотмегабайтних файлів не означає матеріалізацію двохсот мегабайтів двічі

Вирівнювання сторінок, коли в одному документі є вставка

Порівняння сторінки 1 зі сторінкою 1, сторінки 2 зі сторінкою 2 і так далі коректне лише тоді, коли нічого не було вставлено. Вставте титульну сторінку, і наївне порівняння повідомить кожну сторінку як змінену, що технічно правда й практично марно

HotPDF вирівнює сторінки перед порівнянням. Він будує підпис для кожної сторінки з тексту, що можна витягти, відкочується до структурного підпису для сторінок без тексту, а потім обчислює найдовшу зростаючу підпослідовність над зіставленими цільовими індексами. Сторінки всередині цієї підпослідовності — це ті, що просто зсунулися; сторінки поза нею — справжні переміщення. Саме ця відмінність робить різницю 400-сторінкового посібника читабельною, бо звіт каже, що вставлено одну сторінку, а не що змінилося чотириста сторінок

Виконання структурного порівняння

Найпростіший виклик приймає два завантажені документи та режим. cmStructural виконує обхід графа об'єктів, cmRenderedImage виконує порівняння пікселів, cmFull робить обидва, а легші режими cmPageCount, cmPageText і cmObjectCount існують для дешевих димових перевірок:

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;

Звіт розрізняє три стани, які логічне значення розрізнити не може. identical каже, чи щось відрізнялося, comparisonComplete каже, чи обхід завершився, а comparisonBudget називає обмеження, що його зупинило, якщо таке було. Порівняння, що вичерпало бюджет, повідомляє comparisonComplete=false і identical=false разом, бо обірваний обхід не має підстав стверджувати рівність. Будь-яка автоматизація, що читає лише identical, зрештою сприйме зупинку через бюджет як справжню різницю, тож читайте всі три значення

Які обмеження стримують обхід?

Типові значення в THPDFStructuralCompareLimits.Default розраховані на реальні документи, а не на ворожі, і кожен семантично значущий бюджет має власну стелю: 250 000 об'єктів, 2 000 000 ребер, глибина 128, 10 000 повідомлених різниць, 64 МБ на потік і 512 МБ байтів потоків загалом, 1 МБ на значення і 4096 байтів на шлях. Підвищуйте їх свідомо, коли знаєте свій корпус, і знижуйте, коли порівнюєте файли, що надійшли ззовні:

var
  Limits: THPDFStructuralCompareLimits;
  Options: THPDFRenderedCompareOptions;
begin
  Limits := THPDFStructuralCompareLimits.Default;
  Limits.MaxDifferences := 200;        // швидко провалюватися в CI
  Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // типове значення - 72
  Options.ColorTolerance := 2;         // ігнорувати шум округлення в 1-2 рівні
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // записувати накладені зображення для перегляду

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

Прохід рендерингу оцінює кількість пікселів за розмірами сторінки та запитаним DPI ще до виділення будь-якого растрового зображення, а потім перевіряє фактичне растрове зображення, тож деформована геометрія сторінки не може обійти бюджет, брехнявши про свій розмір. Підвищення DPI підвищує точність і вартість квадратично: 150 DPI дає в чотири рази більше пікселів, ніж 72, і стелі на сторінку та загалом існують саме тому, що пакетне завдання при 300 DPI інакше виділятиме пам'ять аж до біди

Наскільки схожим має бути «достатньо схоже»?

Дві сторінки вважаються схожими лише тоді, коли виконуються обидві умови: частка змінених пікселів дорівнює або нижча за MaxChangedPixelRatio, а схожість дорівнює або вища за MinimumSimilarity. Два пороги замість одного, бо жменька катастрофічно неправильних пікселів і широкий розлив дрібних зсувів кольору — це різні типи збоїв, і кожен окремо може бути прийнятним в одному робочому процесі й неприйнятним в іншому. Перевірки порогів використовують неокруглені значення; шість десяткових знаків у JSON існують, щоб звіти лишалися стабільними й придатними для порівняння, а не для визначення самого порівняння

Змінені пікселі групуються в регіони за допомогою плиток фіксованого розміру як вузлів з чотирибічною суміжністю, а не порізиксельної заливки. Це утримує пам'ять обмеженою, а список регіонів стабільним між запусками. Обрізання збереженої деталізації регіонів впливає лише на перелік, а не на повідомлену кількість регіонів, тож сторінка з більшою кількістю змінених регіонів, ніж MaxChangedRegions, усе одно повідомляє, скільки їх було

Одну поведінку варто пояснити прямо, бо вона суперечить звичайному інстинкту. Збої рендерера, збої виділення пам'яті та збої накладання ніколи не проковтуються. Усе таке фіксується як renderError або renderBudget і примусово встановлює renderComparisonComplete=false, бо сторінка, яку не вдалося відрендерити, — це сторінка, яку ніхто не порівняв, а повідомити її як ідентичну гірше, ніж не повідомити нічого

Де кожен режим належить у конвеєрі

Структурне порівняння відповідає, що змінилося, і є правильним типовим вибором для регресійних наборів: воно називає шлях, індекс сторінки та задіяні номери об'єктів, тож збій вказує на код, який його спричинив. Порівняння рендерингу відповідає, чи хтось це помітить, а це питання для затверджень і для перевірки, що прохід оптимізації справді був без втрат

Вони добре поєднуються. Запускайте cmStructural на кожній збірці й дозвольте йому гучно провалюватися на неочікуваних змінах рівня об'єктів; запускайте cmFull з тепловими картами перед релізом, коли є людина, яка може подивитися на накладені зображення. Для конвеєрів, що вже видають розмітку сторінок з інших причин, текстовий вивід, описаний у статті експорт сторінок PDF у SVG, дає третій, придатний для перегляду людиною погляд, а автоматизовані перевірки у статті автоматизація звітів прольоту (preflight) охоплюють питання відповідності стандартам, на які жоден із режимів порівняння не покликаний відповідати

Порівняння, перевірка прольоту та рендеринг спільно використовують ту саму об'єктну модель завантаженого документа, тож один прохід по файлу може живити всі три. Повний перелік можливостей для Delphi та C++Builder на сторінці компонента HotPDF PDF для Delphi