Техническая статья

Визуальный diff PDF в Delphi: пиксели и сведение страниц

Визуальное сравнение PDFlibPas отрисовывает два PDF с одним DPI и сравнивает то, что читатель видит на самом деле. TPDFlib.CompareRenderedPagesWith сравнивает одну пару страниц и возвращает изменившиеся пиксели, регионы, маску и overlay. TPDFlib.CompareDocumentVisualsWith и TPDFlib.CompareFileVisuals сводят целые документы страница к странице, обнаруживают вставленные и удалённые страницы и отчитываются обо всём этом в JSON

Сценарий — визуальное регрессионное тестирование. Кто-то переписал шаблон счёта, обновил пакет шрифтов или сменил движок отрисовки, и вопрос не в том, изменились ли байты (они меняются всегда), а в том, увидит ли разницу хоть один клиент. Структурный diff отвечает на другой вопрос. Два файла могут расходиться в каждом content stream и при этом рисовать идентичные страницы, а один съехавший операнд способен сдвинуть итог на три пункта, не тронув ничего, что заметил бы текстовый экстрактор. Пиксели — единственный слой, где "выглядит одинаково" поддаётся проверке

Что визуальный PDF-diff сравнивает на самом деле?

PDFlibPas сравнивает видимый результат на белой странице, а не сырое содержимое буферов. Каждая страница отрисовывается через RenderPageToString в несжатый BMP, декодируется в битмап с началом координат сверху, и любой пиксель, несущий альфу, накладывается на белый до сравнения каналов. RGB-битмап и RGBA-битмап, выглядящие на экране одинаково, поэтому и сравниваются как одинаковые — ровно то, что нужно, когда две стороны пришли с разных путей отрисовки

У BMP-декодера есть один намеренный нюанс, о котором стоит знать. 32-битный битмап BI_RGB несёт четвёртый байт на пиксель, но формат оставляет его неопределённым: одни писатели заполняют его нулём, другие — 255, третьи — мусором. PLDecodeVisualBMP его игнорирует и сохраняет цвет. Считать этот байт непрозрачностью значило бы позволить рендереру, пишущему нули, стереть каждый пиксель страницы, и diff с радостью отчитался бы о двух пустых идентичных документах. Если вы подаёте в низкоуровневый API свои битмапы, альфа учитывается, только когда вы сами объявите формат как vpfRGBA или vpfBGRA

Пиксель считается изменившимся, когда любой из его каналов — красный, зелёный или синий — отличается больше чем на ColorTolerance. Допуск 0 означает побайтовое совпадение. На практике однозначное значение поглощает дрожание антиалиасинга между сборками рендерера, не пряча изменившийся глиф: настоящая смена текста сдвигает каналы на порядок сильнее. Число берите с заведомо хорошей базовой прогонки, а не наугад

Сравнение одной пары страниц через CompareRenderedPagesWith

TPDFlib.CompareRenderedPagesWith — инструмент на случай, когда вы уже знаете, какие страницы принадлежат друг другу, и хотите деталей: bounding box, связные регионы изменений, опциональную попиксельную маску и опциональный overlay с красным тонированием. Запись опций и типы результата живут в модуле PDFlibVisualCompare, а PLDefaultVisualCompareOptions даёт допуск 0, тайлы 32 пикселя, ни одной области игнорирования и ни маски, ни overlay

uses
  PDFlibrary, PDFlibVisualCompare;

var
  Baseline, Candidate: TPDFlib;
  Options: TPDFVisualCompareOptions;
  Comparison: TPDFVisualCompareResult;
  ErrorText: WideString;
begin
  Baseline := TPDFlib.Create;
  Candidate := TPDFlib.Create;
  try
    if (Baseline.LoadFromFile('invoice-v1.pdf', '') = 1) and
       (Candidate.LoadFromFile('invoice-v2.pdf', '') = 1) then
    begin
      Options := PLDefaultVisualCompareOptions;
      Options.ColorTolerance := 8;
      Options.IncludeOverlay := True;
      // Маскировать штамп времени печати; единицы — пиксели при DPI ниже
      SetLength(Options.IgnoreRects, 1);
      Options.IgnoreRects[0].Left := 600;
      Options.IgnoreRects[0].Top := 20;
      Options.IgnoreRects[0].Right := 790;
      Options.IgnoreRects[0].Bottom := 50;

      if Baseline.CompareRenderedPagesWith(Candidate, 1, 1, 96, Options,
        Comparison, ErrorText) then
      begin
        if Comparison.ChangedPixels > 0 then
          Writeln(PLVisualCompareResultToJSON(Comparison));
      end
      else
        Writeln('Compare failed: ', ErrorText);
    end;
  finally
    Candidate.Free;
    Baseline.Free;
  end;
end;

Три детали здесь кусаются. Прямоугольники игнорирования задаются в отрисованных пикселях с началом координат в левом верхнем углу и полуоткрытыми интервалами, поэтому Right и Bottom исключающие, и тот же прямоугольник накрывает другое место страницы, если сменить DPI. Перекрывающиеся прямоугольники игнорирования считаются один раз, а игнорированные пиксели не исчезают, а попадают в IgnoredPixels — так ChangedRatio остаётся честным, потому что делит только на сравненные пиксели. Наконец, вызов одной пары страниц требует одинаковых размеров обеих отрисовок; страница, изменившая размер, падает с ошибкой, а не растягивается до нужного

Регионы выходят из тайлового прохода: изменившиеся пиксели собираются в квадраты TileSize, и соседние тайлы сливаются в один регион со своими границами и счётчиком пикселей. Крупнее тайлы — меньше и грубее регионы, для тестового отчёта обычно нужно именно это. Overlay — это RGB-копия второй страницы с изменёнными пикселями, тонированными красным, возвращаемая сырыми байтами в Comparison.Overlay; записать её в PNG для артефактов CI — ваш код, а не забота библиотеки

Как сведение страниц на уровне документов обходится со вставленными страницами?

TPDFlib.CompareDocumentVisualsWith сводит страницы наибольшей общей подпоследовательностью по точным пиксельным отпечаткам, так что одна вставленная страница не превращает все последующие в ложные изменения. Наивный цикл «страница 1 к странице 1, страница 2 к странице 2» бесполезен, как только шаблон обзавёлся обложкой: каждая страница после вставки сдвинута на одну, и отчёт загорается красным сверху донизу

Каждая страница отрисовывается и сворачивается в ключ из пиксельной ширины, высоты и дайджеста SHA-256 видимых пикселей. Размер значит больше, чем кажется: полностью белая книжная страница и полностью белая альбомная той же площади имеют побайтово одинаковые пиксельные буферы, и без размеров в ключе они зацепились бы друг за друга. Одинаковые ключи по обе стороны становятся якорями, LCS выбирает самую длинную упорядоченную цепочку из них, а страницы между двумя соседними якорями сводятся по порядку. Когда в одном промежутке с одной стороны страниц больше, излишек сообщается как added или removed

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline и Candidate загружены, как и раньше, с одинаковыми настройками отрисовки
  Options := PLDefaultVisualCompareOptions;
  Options.ColorTolerance := 8;
  Options.TileSize := 64;
  Report := Baseline.CompareDocumentVisualsWith(Candidate, 96, Options);
  if Baseline.LastErrorCode <> 0 then
    Writeln('Visual diff failed: ', Report)
  else
    Writeln(Report);
end;

Якоря побайтово точные, и это осознанное решение, а не недосмотр. ColorTolerance применяется только к сравнению сведённых страниц и никогда — к отпечатку. Страница, в которой один шум антиалиасинга, якорем не станет, но она всё равно попадёт в промежуток, сведётся по порядку со своей парой и под допуском сравняется как unchanged. Нечёткие якоря были бы снисходительнее и куда менее предсказуемы: почти дубликаты (пустые разделители, повторяющиеся страницы условий) начали бы цепляться друг за друга

Отдавайте себе отчёт, что это за выравнивание. JSON называет его exact-raster-lcs-gap-order не случайно: это растровое выравнивание, а не семантическое распознавание страниц. Если в промежутке попались переписанная страница и вставленная, сведение по порядку может сравнить переписанную со вставленной и объявить оригинал удалённым. Документы, полные одинаковых страниц, так же двусмысленны. Когда сведение важно для вердикта, смотрите на пары, а не только на счётчики

Чтение JSON-отчёта

Документный отчёт имеет фиксированную форму, на которую можно писать проверки в тест-раннере. При успехе он несёт "status":"complete", "complete":true, имя выравнивания, firstPageCount, secondPageCount и счётчики changedPages, addedPages, removedPages и unchangedPages. Массив pairs затем перечисляет каждую строку с firstPage, secondPage (0 для отсутствующей стороны) и kind — unchanged, changed, size_changed, added или removed

Строки с kind changed и unchanged несут вложенный объект comparison с width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null, когда ничего не изменилось) и списком regions из bounds плюс changedPixels. Строка size_changed сравнения не содержит вовсе: она сообщает firstWidth, firstHeight, secondWidth и secondHeight и засчитывается в changedPages. Растянуть изменившую размер страницу ради сравнения значило бы получить стену бессмысленных изменённых пикселей, поэтому библиотека сообщает смену геометрии и на этом останавливается

В документном отчёте никогда нет масок и overlay. CompareDocumentVisualsWith принудительно выключает IncludeMask и IncludeOverlay, даже если ваши опции их просят, чтобы память оставалась плоской с ростом числа страниц, а не держала полностраничный превью на каждую пару. Используйте отчёт, чтобы найти интересные страницы, и вызовите CompareRenderedPagesWith только на них, чтобы получить overlay. Провалы возвращаются как "status":"error", "complete":false со строкой error, а LastErrorCode выставляется в 401

Сравнение с файлом через CompareFileVisuals

TPDFlib.CompareFileVisuals — форма одного вызова для CI: он загружает второй PDF, переносит на него текущие настройки рендерера и возвращает тот же документный JSON. Сигнатура — CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), где допуск обязан быть от 0 до 255, а DPI — конечным и положительным

var
  Lib: TPDFlib;
  Report: WideString;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('baseline\statement.pdf', '') = 1 then
    begin
      Report := Lib.CompareFileVisuals('build\statement.pdf', '', 96, 8);
      Writeln(Report);
    end;
  finally
    Lib.Free;
  end;
end;

Наследование настроек рендерера — причина предпочесть этот вызов самостоятельной загрузке второго файла. Загруженный документ забирает текущие масштаб отрисовки, тип кропа и графические настройки экземпляра, плюс выбор движка Cairo или PDFium, если те вкомпилированы. Если вы вручную строите два экземпляра TPDFlib для CompareDocumentVisualsWith, настроить их одинаково — ваша забота, и любое расхождение проявится как изменились все страницы. Игнорирующих областей этот вызов тоже принять не может; когда они нужны, берите типизированный API

Пределы, цена и куда встаёт структурный diff

PDFlibPas явно ограничивает работу, чтобы враждебный или огромный вход падал с ошибкой, а не съедал память. Отрисованная страница не может превышать 64 Mi пикселей, сетка тайлов ограничена 4 Mi тайлами, а выравнивание страниц отказывает входу, где сумма двух количеств страниц переваливает за 8192 или матрица LCS вышла бы за 4 Mi ячеек. Документное сравнение к тому же отрисовывает каждую страницу дважды — один раз для отпечатка, второй для парного сравнения, — закладывайте это при 150 DPI и выше. Для большинства регрессионных наборов 72–96 DPI ловит смены вёрстки и текста за малую долю времени

Визуальный и структурный diff отвечают на разные вопросы, и серьёзному конвейеру обычно нужны оба. Пиксельное сравнение говорит, заметил бы что-то читатель. Оно не скажет, обзавёлся ли подписанный документ аннотацией, отрисовываемой за пределами страницы, или тихо подменил ли инкрементальное обновление значение поля формы тем же видимым текстом. За этим слоем — классификация того, что изменилось в PDF после подписания: она проходит по diff уровня ревизий. А поскольку выбор рендерера определяет пиксели, перед тем как прибивать базовую линию к одному движку, стоит прочесть мультидвижковую отрисовку PDF в Delphi

FAQ

Растягивает ли визуальное сравнение PDFlibPas страницы, изменившие размер?

Нет. CompareRenderedPagesWith возвращает ошибку для страниц разного пиксельного размера, а документный отчёт помечает такие пары size_changed с обеими размерностями вместо их сравнения

Почему почти одинаковые страницы не используются как якоря?

Якоря используют точный отпечаток SHA-256 видимых пикселей плюс размер страницы, поэтому выравнивание остаётся предсказуемым. Допуск применяется потом, при сравнении сведённых страниц, так что страницы из одного шума всё равно заканчиваются как unchanged

Можно ли получить overlay-изображение для каждой страницы документа?

Не из документного вызова: он намеренно опускает маски и overlay, чтобы память оставалась плоской. Найдите изменённые страницы по JSON, затем вызовите CompareRenderedPagesWith с IncludeOverlay на этих страницах

Визуальное сравнение, сведение страниц и остальные API отрисовки и документов поставляются вместе с PDF-библиотекой PDFlibPas для Delphi