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

Възпроизводим PDF изход в Delphi: байт-идентични записвания

HotPDF Delphi Component произвежда байт-идентичен PDF изход през записванията, когато свойството ReproducibleOutput е True: фиксира Info /CreationDate и /ModDate на фиксирана дата, заменя wall-clock идентификатора на документа със seeded или от съдържанието изведен хеш, замества константи за всеки случаен байт, който AES криптиращите пътища иначе биха изтеглили, и сортира всеки речник, който сериализира. Флагът съществува за регресионни пакети и сравнение на build артефакти, не за продуктови документи, и причините за тази граница са интересната част. Сценарият, който движи функционалността, е golden-file тест. Рендирате фактура, commit-вате PDF-а и асертирате, че утрешният билд произвежда същите байтове. Никога не ги произвежда. Файлът се отваря добре във всеки viewer, текстът е идентичен, page tree-то е идентично, а diff-ът все още свети на четири-пет места. Всеки, който се е опитвал да сложи PDF генератор под байт-ниво регресионен тест, е блъснал тази стена, и поправката не е „махни timestamp-ите", а прецизно отчитане на всяко място, където writer-ът се консултира с нещо друго освен самия документ

Защо две записвания на един PDF се различават?

Две записвания на един и същ документ се различават, защото PDF writer — HotPDF включително — се консултира с четири източника на ентропия, които нямат нищо общо със съдържанието на страниците: wall clock, идентификаторът на документа, криптографският генератор на случайни числа и мемори редът на речниковите записи. Всеки поотделно е легитимен. ISO 32000-1 ги иска там. Те просто правят файла функция на кога и къде е записан, а не на какво съдържа

  • Часовникът. Info речникът носи /CreationDate и /ModDate (ISO 32000-1 §14.3.3, таблица 317) като D:YYYYMMDDHHmmSS стрингове със суфикс за часова зона (§7.9.4), а XMP пакетът повтаря същия момент като xmp:CreateDate и xmp:ModifyDate. HotPDF щемплова и двете от FCreationDate, който конструкторът инициализира с Now, така че двете записвания се различават в секундата, в която са написани
  • Идентификаторът. Trailer /ID масивът (ISO 32000-1 §14.4) държи постоянен идентификатор и идентификатор на модификация. Рецептата по подразбиране на HotPDF хешира името на файла заедно с текущото време до милисекундата за първия елемент, и хешира това плюс GetTickCount за втория. Два идентификатора, две пресни стойности на всяко изпълнение
  • Случайните байтове. Standard security зависи от идентификатора и от истинска случайност. За AES-256 file ключът за криптиране, validation и key солите и всеки CBC initialization vector се теглят от системния случаен източник (ISO 32000-2 §7.6.4.4.7 изисква случайни соли). Понеже /U, /UE, /O и /OE всички се изчисляват от тези байтове, криптиран документ се променя изцяло, дори когато plaintext-ът не се. По-старите алгоритми сгъват първия /ID елемент в ключа (ISO 32000-1 §7.6.3.3, §7.6.3.4), така че сама пресната стойност на идентификатора стига да ре-ключва файла
  • Редът. PDF речник е неуредено мапване, и writer, който обходи списъка си в паметта, излъчва ключове в ред на вмъкване. Всеки code path, който строи resource речник в различна последователност, или зареден документ, парснат от различно оформление, произвежда легален, но текстуално различен файл
Четирите източника на ентропия, каращи две HotPDF записвания на един документ да се различават: FCreationDate, щемплован от Now, храни D: датите и XMP пакета, trailer /ID хешира име на файл, часовник и GetTickCount, AES тегли key материал от системния случаен източник, а речниците се сериализират в ред на вмъкване в паметта
Всеки източник поотделно е легитимен и ISO 32000-1 ги иска там, но заедно превръщат файла в функция на кога и къде е записан, а не на какво съдържа

Какво фиксира ReproducibleOutput?

Задаването на ReproducibleOutput := True преди BeginDoc или преди SaveLoadedDocument заменя всеки от четирите източника с фиксирана стойност, и го прави в същите code пътища, които иначе биха протегнали ръка към часовника или случайния генератор, така че не е нужен отделен почистващ проход. Забележете какво липсва от списъка по-горе: съдържанието. Шрифтове, page stream-ове, image данни и cross-reference таблицата вече са детерминистични за един и същ вход; шумът живее изцяло в метаданните и security слоя, което е причината едно целенасочено свойство да може да го махне. Свойството по подразбиране е 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') вместо digest-а име-на-файл-плюс-часовник. Това единично задаване покрива и двете Info дати, и двете XMP дати, защото и четирите се рендират от същото поле. Когато файлът най-накрая се запише, BuildDocumentIdentifiers пита ComputeCanonicalDocumentIdentifier за trailer идентификатора: той експортира цялата обектна графа в каноничен ред, занулява цифрите на всеки D: date стринг, който намери, така че timestamp-ите не могат да се промъкнат обратно през хеша, и взима MD5 на резултата. И двата елемента на /ID получават тази стойност. Същият от съдържанието изведен идентификатор се ползва, когато зареден документ се криптира, без изобщо да мине през BeginDoc — случаят на ActivateProtection върху файл, отворен с LoadFromFile

Случайните байтове са най-неочевидната замяна. AES-256 key рутината увива своя случаен източник в локален helper, който под флага вика FillChar(P^, Count, $5A) за 32-байтовия file encryption key и за всяка 8-байтова сол, а AES-128 и AES-256 стринговите и stream криптатори превключват от AESGenerateRandomIV на AESGenerateStaticIV, който пълни initialization vector-а с 14 * (1 + I) за слот I. С ключа, солите и векторите всички фиксирани, /U, /UE, /O, /OE и всеки криптиран stream излизат идентични на второто изпълнение. Накрая SaveToStream палит DeterministicDictionaryOrder, щом възпроизводимият флаг е вдигнат, а сериализаторът тогава insertion-сортира всеки речник по суровите байтове на имената на ключовете му, по-краткият префикс първи, с оригиналния индекс като tie-breaker. Това е същото подреждане, което диагностичният writer ползва, описано в статията за ръчно редактиране на PDF и последващия му ремонт; възпроизводимият флаг заимства само подреждането, не останалата част от plain-text оформлението на онзи writer

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

Защо фиксираната дата все пак изтече wall clock-а?

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

Регресионният тест, който пропусна това, струва повече от поправката. Две записвания, които и двете се случват в същата wall-clock секунда, записват един и същ D: стринг по случайност, и байтовото сравнение минава за бъг, който проваля на всяка по-бавна машина. Коригираният тест спи 1100 ms между двете записвания, така че PDF timestamp-ът гарантирано прекоси граница на секунда, пуска случая за plain, AES-128 и AES-256 изход с истински пароли на двата криптирани варианта и сравнява двата буфера с CompareMem, докладвайки първия различаващ се offset при провал, така че 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 timestamp
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 file ключът за криптиране е трийсет и два байта $5A, солите са осем байта $5A, а initialization vector-ите следват публикуван аритметичен модел. Паролата все още гейтува /UE и /OE обвивките, но увитият ключ е константа, така че всеки, който знае константата, може да декриптира всеки content stream без изобщо парола. Фиксираните соли махат и per-document уникалността, на която ISO 32000-2 §7.6.4.4.7 разчита, за да не дава еднакви пароли еднакви /U стрингове през файлове. Прочетете статията за AES-256 настройката за това какво обещават свойствата за криптиране, когато случайният източник е цял; под възпроизводимия флаг тези обещания са суспендирани

Компромисът с идентификатора е по-фин. ISO 32000-1 §14.4 възнамерява вторият /ID елемент да се мени на всяка модификация, така че инструментите да различат обновен файл от неговия предшественик, а възпроизводимото записване пише една и съща стойност и в двата слота. Понеже тази стойност е хеш на каноничната обектна графа, два документа с различно съдържание все още получават различни идентификатори, което е по-добре от константа. Но seed-ът, който BeginDoc ползва за извеждане на ключ, е един и същ стринг за всеки документ на всяка машина, и четец, който ключова по /ID, за да различава файлове — кеш на анотации или form-data sidecar например — ще слее всеки възпроизводим файл, който случайно хешира еднакво

Какво флагът не покрива?

ReproducibleOutput маха ентропията, която writer-ът въвежда сам; не може да махне ентропия, влизаща през средата или през code пътища, които не контролира, и три от тях са лесни за закачане

  • Суфиксът на часовата зона. _DateTimeToPdfDate добавя локалното UTC отместване, така че D:20260101000000+08'00' на един build агент и D:20260101000000-05'00' на друг са различни байтове за една и съща фиксирана дата. Възпроизводимостта важи през изпълнения на една машина, или през машини със същата часова зона; фиксирайте зоната на агента, ако golden файловете ви пътуват
  • Инкременталните актуализации. SaveIncrementalUpdate изчислява своя идентификатор на модификация от target пътя, GetTickCount и текущото време без възпроизводим клон, защото инкременталната секция по дефиниция е нова модификация. Сравнявайте пълни пренаписвания, не appended делти
  • Passthrough съкращението. SaveLoadedDocument обикновено копира немодифициран, некриптиран изходен файл байт по байт, вместо да го пре-сериализира. Възпроизводимият флаг изключва това съкращение и принуждава пълно пренаписване, така че правилата за подреждане и идентификатор важат, което означава, че възпроизводимото записване на зареден файл е по-бавно от стандартното и никога не е копие на входа. Диф-вайте го срещу предишно възпроизводимо записване, никога срещу оригинала
Къде спират възпроизводимите записвания на HotPDF: _DateTimeToPdfDate все още добавя локалното UTC отместване, така че golden файлове се различават през часови зони, SaveIncrementalUpdate няма възпроизводим клон, защото делтата е нова модификация, а passthrough съкращението е изключено, така че зареден файл винаги се пренаписва изцяло
Възпроизводимостта важи през изпълнения на една машина или през машини, споделящи зона, а възпроизводимо записване бива дифвано срещу предишно възпроизводимо записване, никога срещу оригиналния вход

Още един урок от същото издание, за това какво едно минаващо докладване доказва и какво не. Тестов fixture за PDF/X-6 вика CharProcs.DeleteValue('A'), което освободи директно държан glyph stream, после пре-вмъкна същия пойнтер, и отделно подаде един директен ExtGState обект едновременно на resource речник и на pattern. Conformance валидаторът минаваше на интервали върху този use-after-free и двойна собственост, защото четеше каквото освободената памет случайно държеше. Когато структурна проверка мига, погледнете собствеността на тестовия вход, преди да погледнете валидатора. Възпроизводимият изход прави тази дисциплина по-евтина: щом две записвания са байт-идентични, единственият останал източник на мигане е самата обектна графа, а структурен diff от каталога надолу ще го намери

ReproducibleOutput, DeterministicDictionaryOrder и свойствата за криптиране, описани тук, се доставят в стандартния HotPDF Delphi Component за Delphi и C++Builder, а същият флаг движи собствения регресионен корпус на библиотеката, така че поведението, което получавате в тестов пакет, е поведението, с което компонентът е тестван