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

Відтворюваний вивід PDF у Delphi: побайтово ідентичні файли

HotPDF Delphi Component дає побайтово ідентичний вивід PDF між збереженнями, коли властивість ReproducibleOutput дорівнює True: він прибиває /CreationDate і /ModDate зі словника Info до фіксованої дати, замінює ідентифікатор документа, прив'язаний до настінного годинника, на хеш із сідом або похідний від вмісту, підставляє константи замість кожного випадкового байта, який інакше взяли б шляхи шифрування AES, і сортує кожен словник, який серіалізує. Прапорець існує для наборів регресій і порівняння артефактів збірки, а не для продакшн-документів, і причини цієї межі — найцікавіше. Сценарій, який рухає цю функцію, — це тест із еталонним файлом. Ви рендерите рахунок, комітите PDF і стверджуєте, що завтрашня збірка дасть ті самі байти. Так не буває ніколи. Файл відкривається без проблем у будь-якому переглядачі, текст ідентичний, дерево сторінок ідентичне, а diff усе одно світиться в чотирьох чи п'яти місцях. Кожен, хто намагався поставити генератор PDF під побайтовий регресійний тест, натикався на цю стіну, і виправлення тут не «прибрати мітки часу», а точний облік кожного місця, де письменник звертається до чогось, крім самого документа

Чому два збереження того самого PDF різняться?

Два збереження того самого документа різняться, бо письменник PDF, зокрема HotPDF, звертається до чотирьох джерел ентропії, які не мають нічого спільного зі вмістом сторінок: настінного годинника, ідентифікатора документа, криптографічного генератора випадкових чисел і порядку записів у словнику в пам'яті. Кожне з них окремо законне. ISO 32000-1 хотів би бачити їх там. Вони просто роблять файл функцією від того, коли і де його записали, а не від того, що він містить

  • Годинник. Словник Info несе /CreationDate і /ModDate (ISO 32000-1 §14.3.3, Table 317) як рядки D:YYYYMMDDHHmmSS із суфіксом часового поясу (§7.9.4), а пакет XMP повторює той самий момент як xmp:CreateDate і xmp:ModifyDate. HotPDF ставить обидві з FCreationDate, який конструктор ініціалізує значенням Now, тож два збереження різняться в секунді, коли їх записали
  • Ідентифікатор. Масив /ID у trailer (ISO 32000-1 §14.4) тримає постійний ідентифікатор та ідентифікатор модифікації. Типовий рецепт HotPDF хешує ім'я файлу разом із поточним часом аж до мілісекунд для першого елемента й хешує його плюс GetTickCount для другого. Два ідентифікатори — два свіжі значення на кожен прогін
  • Випадкові байти. Стандартна безпека залежить від ідентифікатора й від справжньої випадковості. Для AES-256 ключ шифрування файлу, солі перевірки й ключа та кожен вектор ініціалізації CBC беруться із системного джерела випадковості (ISO 32000-2 §7.6.4.4.7 вимагає випадкових солей). Оскільки /U, /UE, /O і /OE усі обчислюються з тих байтів, зашифрований документ змінюється цілком, навіть коли відкритий текст не змінився. Старіші алгоритми вбирають перший елемент /ID у ключ (ISO 32000-1 §7.6.3.3, §7.6.3.4), тож одного свіжого ідентифікатора достатньо, щоб перешифрувати файл
  • Порядок. Словник PDF — це невпорядковане відображення, а письменник, який обходить свій список у пам'яті, видає ключі в порядку вставки. Будь-який шлях коду, що будує словник ресурсів в іншій послідовності, або завантажений документ, розібраний із іншого layout, дає файл законний, але текстуально інший
Чотири джерела ентропії, через які два збереження одного документа в HotPDF різняться: FCreationDate, виставлений із Now, живить дати D: і пакет XMP, /ID у trailer хешує ім'я файлу, годинник і GetTickCount, AES бере ключовий матеріал із системного джерела випадковості, а словники серіалізуються в порядку вставки в пам'яті
Кожне джерело окремо законне, і ISO 32000-1 хотів би бачити їх там, але разом вони перетворюють файл на функцію від того, коли і де його записали, а не від того, що він містить

Що саме прибиває ReproducibleOutput?

Встановлення ReproducibleOutput := True до BeginDoc або до SaveLoadedDocument замінює кожне з чотирьох джерел фіксованим значенням, і робить це в тих самих шляхах коду, які інакше тягнулися б до годинника чи генератора випадкових чисел, тож окремий прохід очищення не потрібен. Зверніть увагу, чого в наведеному списку немає: вмісту. Шрифти, потоки сторінок, дані зображень і таблиця cross-reference уже детерміновані для того самого входу; шум живе цілком у метаданих і рівні безпеки — саме тому одна цілеспрямована властивість його прибирає. Властивість типово дорівнює False, і ніщо в бібліотеці не вмикає її за вас

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // до BeginDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Усередині BeginDoc відтворювана гілка присвоює FCreationDate := EncodeDate(2026, 1, 1) і засіває ідентифікатор документа через MD5CalcString('HotPDF-reproducible-seed') замість дайджесту з імені файлу й годинника. Це єдине присвоєння покриває обидві дати Info і обидві дати XMP, бо всі чотири рендеряться з одного поля. Коли файл нарешті записується, BuildDocumentIdentifiers питає в ComputeCanonicalDocumentIdentifier ідентифікатор для trailer: він експортує весь граф об'єктів у канонічному порядку, зануляє цифри кожного рядка дати D:, який знаходить, щоб мітки часу не просочилися назад через хеш, і бере MD5 від результату. Обидва елементи /ID отримують це значення. Той самий ідентифікатор, похідний від вмісту, використовується, коли завантажений документ шифрується, жодного разу не проходячи через BeginDoc, — це випадок ActivateProtection для файлу, який ви відкрили через LoadFromFile

Випадкові байти — найменш очевидна підстановка. Рутина ключа AES-256 обгортає своє джерело випадковості в локальний хелпер, який під прапорцем викликає FillChar(P^, Count, $5A) для 32-байтного ключа шифрування файлу й для кожної 8-байтної солі, а шифрувальники рядків і потоків AES-128 та AES-256 перемикаються з AESGenerateRandomIV на AESGenerateStaticIV, який заповнює вектор ініціалізації значенням 14 * (1 + I) для слота I. Коли ключ, солі й вектори всі фіксовані, /U, /UE, /O, /OE і кожен зашифрований потік виходять ідентичними на другому прогоні. Нарешті, SaveToStream вмикає DeterministicDictionaryOrder щоразу, коли встановлено відтворюваний прапорець, і серіалізатор тоді сортує кожен словник вставками за сирими байтами його імен ключів, коротший префікс першим, а початковий індекс як розв'язання нічиїх. Це той самий порядок, який використовує діагностичний письменник, описаний у статті про ручне редагування PDF і подальший ремонт; відтворюваний прапорець позичає лише порядок, а не решту простого текстового layout того письменника

Що прибиває ReproducibleOutput у HotPDF: дата створення стає EncodeDate 2026, 1, 1, ідентифікатор у trailer походить від ComputeCanonicalDocumentIdentifier над канонічним графом із зануленими цифрами D:, ключі й солі AES заповнюються байтами $5A, AESGenerateStaticIV заповнює кожен слот, а DeterministicDictionaryOrder сортує кожен словник
Підстановки виконуються в тих самих шляхах коду, які інакше тягнулися б до годинника чи генератора випадкових чисел, тож окремий прохід очищення не потрібен, а обидва елементи /ID отримують те саме значення, похідне від вмісту

Чому фіксована дата все одно видавала настінний годинник?

Виправлення у v2.752.2 існує тому, що фіксовану дату створення спершу визначали в конструкторі, а конструктор не може знати властивість, яку виклик ще не встановив. Нормальна послідовність викликів — це Create, потім ReproducibleOutput := True, потім BeginDoc. На момент конструювання FReproducibleOutput усе ще False, тож FCreationDate отримував Now і зберігав його. Ідентифікатор і випадкові байти були прибиті правильно, тож два файли збігалися майже всюди й розходилися рівно в двох рядках дат і двох полях XMP. Перенесення присвоєння у відтворювану гілку BeginDoc, поряд із засіяним ідентифікатором, поставило рішення в точку, де властивість має своє остаточне значення

Регресійний тест, який це пропустив, вартує більше за саме виправлення. Два збереження, які обидва виконуються в ту саму секунду настінного годинника, пишуть однаковий рядок D: випадково, і побайтове порівняння проходить для бага, який падає на будь-якій повільнішій машині. Виправлений тест спить 1100 мс між двома збереженнями, тож мітка часу PDF гарантовано перетинає межу секунди, проганяє випадок для звичайного виводу, AES-128 і AES-256 зі справжніми паролями на двох зашифрованих варіантах, і порівнює два буфери через CompareMem, повідомляючи перший відмінний зсув при відмові, щоб diff вказував на конкретний об'єкт, а не на весь файл. Побайтове порівняння доводить детермінізм і більше нічого, тож тримайте окреме твердження, яке перезавантажує зашифрований вивід із паролем користувача й читає кількість сторінок; зміна, яка робить файл стабільним і нечитабельним одночасно, не повинна проскочити на силі зеленого diff

function SaveOnce(const Target: string): TBytes;
var
  Pdf: THotPDF;
  Stream: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.CryptKeyLength := aes256;
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, Stream.Size);
    if Stream.Size > 0 then
      Stream.ReadBuffer(Result[0], Stream.Size);
  finally
    Stream.Free;
  end;
end;

// у тілі тесту
A := SaveOnce(PathA);
TThread.Sleep(1100);          // змусити іншу секунду мітки часу PDF
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
  'two saves under ReproducibleOutput must be byte-identical');

Чи залишається відтворюваний зашифрований PDF безпечним?

Ні. Документ, зашифрований під ReproducibleOutput, не захищений у жодному значущому сенсі, і прапорець мусить бути вимкнений для всього, що виходить за межі тестової директорії. Ключ шифрування файлу AES-256 — це тридцять два байти $5A, солі — вісім байтів $5A, а вектори ініціалізації йдуть за опублікованою арифметичною закономірністю. Пароль усе ще замикає обгортки /UE і /OE, але загорнутий ключ — константа, тож будь-хто, хто знає цю константу, розшифрує кожен content stream узагалі без пароля. Фіксовані солі також прибирають ту унікальність на документ, на яку ISO 32000-2 §7.6.4.4.7 покладається, щоб однакові паролі не давали однакових рядків /U в різних файлах. Про те, що обіцяють властивості шифрування, коли джерело випадковості ціле, читайте в статті про налаштування AES-256; під відтворюваним прапорцем ці обіцянки зупинені

Компроміс із ідентифікатором тонший. ISO 32000-1 §14.4 хоче, щоб другий елемент /ID змінювався на кожній модифікації, аби інструменти могли відрізнити оновлений файл від його предка, а відтворюване збереження пише те саме значення в обидва слоти. Оскільки це значення — хеш канонічного графа об'єктів, два документи з різним вмістом усе одно отримають різні ідентифікатори, що краще за константу. Але сід, який BeginDoc використовує для виведення ключа, — це той самий рядок для кожного документа на кожній машині, і читач, який орієнтується на /ID, щоб розрізняти файли, — скажімо, кеш анотацій або sidecar для даних форми, — сплутає всі відтворювані файли, які випадково хешуються однаково

Що прапорець не покриває?

ReproducibleOutput прибирає ентропію, яку письменник вносить сам; він не може прибрати ентропію, що входить через середовище або через шляхи коду, яких він не контролює, і на трьох із них легко спіткнутися

  • Суфікс часового поясу. _DateTimeToPdfDate дописує локальне зміщення від UTC, тож D:20260101000000+08'00' на одному агенті збірки й D:20260101000000-05'00' на іншому — це різні байти для тієї самої фіксованої дати. Відтворюваність тримається між прогонами на одній машині або між машинами, що поділяють часовий пояс; прибийте зону агента, якщо ваші еталонні файли подорожують
  • Інкрементальні оновлення. SaveIncrementalUpdate обчислює свій ідентифікатор модифікації з шляху цілі, GetTickCount і поточного часу без відтворюваної гілки, бо інкрементальна секція за визначенням є новою модифікацією. Порівнюйте повні перезаписи, а не дописані дельти
  • Ярлик наскрізного копіювання. SaveLoadedDocument зазвичай копіює незмінений, незашифрований вихідний файл побайтово замість того, щоб серіалізувати його заново. Відтворюваний прапорець вимикає цей ярлик і змушує повний перезапис, щоб правила порядку й ідентифікатора діяли, а це означає, що відтворюване збереження завантаженого файла повільніше за типове й ніколи не є копією входу. Порівнюйте його з попереднім відтворюваним збереженням, а не з оригіналом
Де зупиняються відтворювані збереження HotPDF: _DateTimeToPdfDate все ще дописує локальне зміщення від UTC, тож еталонні файли різняться між часовими поясами, SaveIncrementalUpdate не має відтворюваної гілки, бо дельта є новою модифікацією, а ярлик наскрізного копіювання вимкнено, тож завантажений файл завжди перезаписується повністю
Відтворюваність тримається між прогонами на одній машині або між машинами, що поділяють зону, а відтворюване збереження варто порівнювати з попереднім відтворюваним збереженням, а не з оригінальним входом

Ще один урок із того самого релізу — про те, що доводить успішна перевірка, а що ні. Тестова фікстура PDF/X-6 викликала CharProcs.DeleteValue('A'), що звільняло безпосередньо утримуваний потік гліфа, а потім знову вставляла той самий вказівник і окремо віддавала один прямий об'єкт ExtGState і словнику ресурсів, і патерну. Перевірка відповідності проходила на тому використанні після звільнення й подвійному володінні через раз, бо читала те, що випадково лежало в звільненій пам'яті. Коли структурна перевірка мерехтить, дивіться на володіння тестовим входом, перш ніж дивитися на валідатор. Відтворюваний вивід робить цю дисципліну дешевшою: щойно два збереження побайтово ідентичні, єдине джерело мерехтіння, що лишилося, — сам граф об'єктів, і структурний diff від каталогу вниз його знайде

Описані тут властивості ReproducibleOutput, DeterministicDictionaryOrder і шифрування постачаються у стандартному HotPDF Delphi Component для Delphi і C++Builder, і той самий прапорець рухає власний регресійний корпус бібліотеки, тож поведінка, яку ви отримуєте в наборі тестів, — це та поведінка, з якою компонент тестують