PDFium Component делает воспроизводимый вывод PDF одним вызовом: TPdf.SaveAsReproducible переписывает документ так, что двукратное сохранение одного контента даёт побайтово одинаковые файлы. Он удаляет /CreationDate и /ModDate из информационного словаря документа, не пишет трейлерный /ID, держит порядок объектов стабильным и отчитывается о сделанном в TPdfReproducibleReport. Шифрованные файлы отвергаются, подписанным нужно явное согласие
Потребность обычно всплывает в сборочном конвейере. Кто-то кладёт сгенерированные PDF в репозиторий или золотофайловый тестовый набор, и каждый прогон выдаёт diff, хотя на страницах ничего не поменялось. Или архив дедуплицирует по SHA-256 и тихо хранит пятьдесят копий одного счёта, потому что каждое сохранение штамповало в файл новое время. PDF-писатель, встраивающий настенные часы, нормален для настольного приложения и враждебен всему, что считает байты идентичностью
Почему двукратное сохранение одного PDF даёт разные байты?
Два сохранения неизменённого документа различаются ровно из-за двух сортов данных: штампов времени в информационном словаре документа и файлового идентификатора в трейлере. Всё остальное, что испускает аккуратный писатель, — нумерацию объектов, порядок объектов, раскладку cross-reference — можно сделать детерминированным, и полный путь перезаписи PDFium Component это уже делает: объекты пишутся по возрастанию номера объекта, а таблица xref следует тому же порядку
Штампы времени — очевидный виновник. ISO 32000-1 §14.3.3 определяет /CreationDate и /ModDate в словаре /Info, а нативное сохранение PDFium пишет свежий /ModDate при каждой сериализации документа. Файловый идентификатор — тонкое место. ISO 32000-1 §14.4 описывает /ID как двухэлементный массив байтовых строк, где первый элемент идентифицирует исходный файл, а второй меняется с каждой ревизией. Писатели обычно выводят эти строки хэшированием чего-то о файле, и если то что-то включает заштампованные временем байты, идентификатор наследует недетерминизм
Использование TPdf.SaveAsReproducible в Delphi
У TPdf.SaveAsReproducible две перегрузки — запись в TStream и в имя файла, — обе возвращают Boolean с out Report: TPdfReproducibleReport и опциональным параметром AllowSignedDocument, по умолчанию False. Возвращаемое значение повторяет Report.Succeeded; при False Report.ErrorMessage говорит почему. Файловая перегрузка пишет во временный файл рядом с целью и подменяет цель только после успешного сохранения, так что неудавшийся прогон не оставит недописанный PDF там, где стоял ваш прежний сборочный вывод
var
Pdf: TPdf;
Report: TPdfReproducibleReport;
begin
Pdf:= TPdf.Create(nil);
try
Pdf.FileName:= 'invoice-2026-10.pdf';
Pdf.Active:= True;
if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
'%d timestamps stripped', [Report.SourceByteCount,
Report.OutputByteCount, Report.KeptObjectCount,
Report.PrunedObjectCount, Report.StrippedTimestampCount]))
else
Writeln('Reproducible save failed: ', Report.ErrorMessage);
finally
Pdf.Free;
end;
end;
Поля отчёта стоит писать в журнал в CI. StrippedTimestampCount обычно 2 для документа, чей /Info несёт обе даты, и 0 для документа вовсе без словаря /Info. PrunedObjectCount считает объекты, присутствовавшие в нативном сохранении, но недостижимые из каталога или словаря /Info; воспроизводимая перезапись их выбрасывает, так что ждите вывод того же размера или меньше обычного сохранения
Почему срезания штампов времени не хватило
Первая реализация удаляла оба штампа и всё равно производила два разных файла. Страницы совпадали, словарь /Info совпадал, а трейлеры расходились во втором элементе /ID. Первопричиной был порядок: перезапись сперва просит у PDFium нативное сохранение, затем постобрабатывает те байты, и строитель трейлера вычислял /ID хэшированием нативного вывода. Тот нативный вывод всё ещё содержал свежий /ModDate, поэтому хэш, а значит и идентификатор, менялся на каждом прогоне, хотя сам штамп никогда не доходил до финального файла
Это хороший пример общего правила воспроизводимых сборок: удалить недетерминированное значение недостаточно, если из него уже что-то выведено. Фикс — вовсе перестать нести /ID. TPdf.SaveAsReproducible пишет трейлер только с /Size, /Root и, когда у источника он есть, /Info. В трейлере не осталось ничего зависящего от времени — и два подряд сохранения одного документа равны побайтово; ровно так это поведение и верифицируется
function SavesAreIdentical(Pdf: TPdf): Boolean;
var
A, B: TMemoryStream;
RA, RB: TPdfReproducibleReport;
begin
A:= TMemoryStream.Create;
B:= TMemoryStream.Create;
try
Result:= Pdf.SaveAsReproducible(A, RA) and
Pdf.SaveAsReproducible(B, RB) and
(A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
finally
B.Free;
A.Free;
end;
end;
Удаление ключей словаря без порчи строк
Штампы удаляются лексерно-осведомлённым сканом тела объекта /Info, а не текстовым поиском. Наивная побайтовая замена /ModDate зацепила бы и строку /Subject или /Keywords, случайно содержащую тот текст, а вырезание байтов из литеральной строки молча портит метаданные вместо громкого провала. PDFium Component сперва нейтрализует литеральные строки и комментарии в сканирующей копии тела, затем принимает совпадение только там, где начинается настоящий токен имени, находит грамматикой PDF-объектов, где кончается значение того ключа, и перескакивает всю пару ключ-значение
Практическое следствие — каждый прочий вход в /Info, заголовок, автор, продюсер, пользовательские ключи, выживает нетронутым. Удаляются только два ключа, определённые как даты, и только из словаря, на который реально указывает трейлерная запись /Info
Что стоит опускание /ID для шифрованных и подписанных PDF?
Опускание /ID безопасно для обычного вывода PDF 1.x, но исключает шифрование и меняет то, что происходит с подписями. В ISO 32000-1 трейлерный /ID опционален, если только нет записи /Encrypt: стандартный security handler подмешивает первую строку идентификатора в ключ шифрования. Файл без /ID не может быть корректно зашифрованным файлом, поэтому TPdf.SaveAsReproducible и не пытается: когда трейлер несёт /Encrypt, он возвращает False, а Report.ErrorMessage говорит, что документ зашифрован. Расшифруйте сперва, если конвейер позволяет, или держите шифрованные поставки вне проверок байтовой идентичности. Если целитесь в PDF 2.0, учтите: ISO 32000-2 ужесточает правило и требует /ID в каждом файле, так что воспроизводимый вывод естественнее всего ложится на цели PDF 1.x; о том, как компонент обращается с целевыми версиями, рассказывает статья о точном соответствии версии PDF в Delphi
Подписанные документы — другая проблема. TPdf.SaveAsReproducible — полная, не-инкрементальная перезапись, поэтому смещения байтов съезжают, и /ByteRange существующих подписей перестаёт покрывать подписанные байты. Воспроизводимый вывод подписанного файла — стало быть, осознанное решение пожертвовать валидностью существующих подписей ради стабильных байтов, потому метод поднимает EPdfError при SignatureCount больше нуля, если вы не передали AllowSignedDocument = True
if Pdf.SignatureCount > 0 then
// Явное согласие: перезапись сдвигает смещения байтов, поэтому существующие
// подписи после этого следует ожидать проваливающими проверку
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);
Если подписанный оригинал — запись истины, держите его как есть и используйте воспроизводимый вывод только для производных копий, например нормализованной версии, которую вы диффите в CI. О дописывании в защищённый файл без тревоги уже лежащего смотрите в том, почему шифрованный PDF нельзя тихо пропатчить в Delphi
Границы, которые знать до того, как полагаться на байтовую идентичность
Байтовая идентичность держится для одного документа, сохраняемого одной сборкой компонента, и покрывает ровно то, чего касается метод. TPdf.SaveAsReproducible срезает только две даты /Info; XMP-поток метаданных со своими xmp:CreateDate или xmp:ModifyDate проносится как те байты, что документ уже держит. Это стабильно между сохранениями, но значит, что даты XMP и /Info больше не описывают одно и то же, — важно для профилей PDF/A, требующих их согласия; правила объясняет статья об эквивалентности метаданных Info и XMP в PDF/A. Иной бинарник PDFium или версия компонента может сериализовать объекты иначе, так что фиксируйте и то и другое в любом конвейере, сравнивающем хэши между машинами
В этих пределах метод даёт то, что нужно diff-ам CI, золотофайловым тестам и архивам с адресацией по содержимому: тот же вход — те же байты. TPdf.SaveAsReproducible и TPdfReproducibleReport поставляются с PDFium Component for Delphi и C++Builder — вместе с остальным его API сохранения, оптимизации и соответствия
FAQ
Меняет ли TPdf.SaveAsReproducible содержимое страниц?
Нет. Он удаляет ключи /CreationDate и /ModDate из словаря /Info, опускает трейлерный /ID и выбрасывает объекты, недостижимые из каталога или /Info; содержимое страниц пишется без изменений
Можно ли получить воспроизводимый вывод из шифрованного PDF?
Нет. Шифрование зависит от трейлерного /ID, который воспроизводимый вывод опускает, поэтому метод возвращает False с поясняющим ErrorMessage для шифрованных источников
Почему подписанному PDF нужен AllowSignedDocument?
Воспроизводимое сохранение — полная перезапись, сдвигающая смещения байтов, поэтому существующие подписи перестают совпадать со своими подписанными диапазонами байтов. Передача AllowSignedDocument = True подтверждает, что вы принимаете этот размен