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, даёт третий, пригодный для человеческого сравнения вид, а автоматические проверки в статье автоматизация преflight-отчётов охватывают вопросы соответствия стандарту, на которые не рассчитан ни один из режимов сравнения
Сравнение, preflight-проверка и рендеринг используют одну и ту же объектную модель загруженного документа, поэтому один проход по файлу может обслужить все три задачи. Полный список возможностей для Delphi и C++Builder — на странице компонента HotPDF для Delphi PDF