مقاله فنی

خروجی تکرارپذیر PDF در Delphi: ذخیره‌های byte-identical

HotPDF Delphi Component وقتی پراپرتی ReproducibleOutput برابر True باشد خروجی PDFی تولید می‌کند که میان ذخیره‌ها byte-به-byte یکسان است: /CreationDate و /ModDate در Info را روی یک تاریخ ثابت پین می‌کند، شناسهٔ سند مبتنی بر ساعت را با یک hash بذردار یا مشتق از محتوا عوض می‌کند، به‌جای هر byte تصادفی‌ای که مسیرهای رمزنگاری AES در غیر آن صورت می‌کشیدند ثابت می‌گذارد، و هر dictionaryی را که سریالایز می‌کند مرتب می‌کند. این پرچم برای سویت‌های رگرسیون و مقایسهٔ artifactهای build است، نه برای اسناد محیط عملیاتی، و دلیل‌های همین مرز بخش جالب ماجراست. سناریویی که این قابلیت را به راه می‌اندازد یک تست golden-file است. یک فاکتور رندر می‌کنی، PDF را commit می‌کنی و assertion می‌کنی که build فردا همان byteها را تولید کند. هیچ‌وقت نمی‌کند. فایل در هر viewerی بی‌مشکل باز می‌شود، متن یکسان است، درخت صفحه یکسان است، و diff باز هم در چهار پنج جا روشن می‌شود. هر کسی که تلاش کرده باشد یک تولیدکنندهٔ PDF را زیر یک تست رگرسیون سطح-byte بگذارد به این دیوار خورده، و راه‌حل «حذف timestampها» نیست بلکه حساب‌رسی دقیق هر جایی است که نویسنده به چیزی جز خود سند مراجعه می‌کند

چرا دو ذخیرهٔ یک PDF با هم فرق دارند؟

دو ذخیرهٔ یک سند با هم فرق دارند چون یک نویسندهٔ PDF، از جمله HotPDF، به چهار منبع آنتروپی مراجعه می‌کند که هیچ ربطی به محتوای صفحه ندارند: ساعت دیواری، شناسهٔ سند، مولّد اعداد تصادفی رمزنگاری، و ترتیب حافظهٔ درایه‌های dictionary. هر یک به‌تنهایی مشروع است. ISO 32000-1 آن‌ها را همان‌جا می‌خواهد. فقط باعث می‌شوند فایل تابعی از زمان و مکان نوشته شدنش باشد، نه تابعی از آنچه در خود دارد

  • ساعت. dictionary مربوط به Info کلیدهای /CreationDate و /ModDate (ISO 32000-1 §14.3.3، جدول 317) را به‌صورت رشته‌های D:YYYYMMDDHHmmSS با یک پسوند منطقهٔ زمانی (§7.9.4) حمل می‌کند و بستهٔ XMP همان لحظه را به‌صورت xmp:CreateDate و xmp:ModifyDate تکرار می‌کند. HotPDF هر دو را از FCreationDate مهر می‌زند که سازنده آن را با Now مقداردهی می‌کند، پس دو ذخیره در همان ثانیه‌ای که نوشته شده‌اند فرق دارند
  • شناسه. آرایهٔ /ID در trailer (ISO 32000-1 §14.4) یک شناسهٔ دائمی و یک شناسهٔ تغییر را نگه می‌دارد. دستور پیش‌فرض HotPDF برای عنصر اول، نام فایل را همراه زمان جاری تا میلی‌ثانیه hash می‌کند و برای عنصر دوم همان را به‌علاوهٔ GetTickCount. دو شناسه و دو مقدار تازه در هر اجرا
  • byteهای تصادفی. امنیت استاندارد به شناسه و به تصادف واقعی وابسته است. برای AES-256 کلید رمزنگاری فایل و saltهای اعتبارسنجی و کلید و هر بردار مقداردهی اولیهٔ CBC از منبع تصادفی سیستم کشیده می‌شوند (ISO 32000-2 §7.6.4.4.7 saltهای تصادفی را لازم می‌دارد). چون /U و /UE و /O و /OE همه از همان byteها حساب می‌شوند، یک سند رمزنگاری‌شده یکسره عوض می‌شود حتی وقتی متن‌روشن عوض نشده. الگوریتم‌های قدیمی‌تر عنصر اول /ID را در کلید می‌گنجانند (ISO 32000-1 §7.6.3.3، §7.6.3.4)، پس یک شناسهٔ تازه به‌تنهایی برای کلیدگذاری مجدد فایل کافی است
  • ترتیب. یک dictionary در PDF یک نگاشت بی‌ترتیب است و نویسنده‌ای که لیست درون-حافظه‌ای‌اش را می‌پیماید کلیدها را به ترتیب درج بیرون می‌دهد. هر مسیر کدی که یک dictionary منابع را با توالی متفاوتی بسازد، یا سندی بارگذاری‌شده که از چیدمان دیگری parse شده باشد، فایلی قانونی ولی متنی متفاوت تولید می‌کند
چهار منبع آنتروپی که دو ذخیرهٔ HotPDF از یک سند را متفاوت می‌کنند: FCreationDate که از Now مهر می‌خورد تاریخ‌های D: و بستهٔ XMP را تغذیه می‌کند، /ID در trailer نام فایل و ساعت و GetTickCount را hash می‌کند، AES مواد کلید را از منبع تصادفی سیستم می‌کشد و dictionaryها به ترتیب درج در حافظه سریالایز می‌شوند
هر منبع به‌تنهایی مشروع است و ISO 32000-1 آن‌ها را همان‌جا می‌خواهد، اما با هم فایل را به تابعی از زمان و مکان نوشتن تبدیل می‌کنند نه تابعی از محتوایش

ReproducibleOutput چه چیزی را پین می‌کند؟

ست کردن ReproducibleOutput := True پیش از BeginDoc یا پیش از SaveLoadedDocument هر یک از آن چهار منبع را با یک مقدار ثابت جایگزین می‌کند، و این کار را در همان مسیرهای کدی می‌کند که در غیر آن صورت سراغ ساعت یا مولّد تصادفی می‌رفتند، پس هیچ پاس پاک‌سازی جداگانه‌ای لازم نیست. به آنچه از فهرست بالا جا افتاده دقت کن: محتوا. فونت‌ها و streamهای صفحه و دادهٔ تصویر و جدول 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 برای شناسهٔ trailer از ComputeCanonicalDocumentIdentifier می‌پرسد: کل گراف شیء را به ترتیب canonical بیرون می‌دهد، رقم‌های هر رشتهٔ تاریخ D: ای را که پیدا کند صفر می‌کند تا timestampها نتوانند از راه hash دوباره برگردند، و MD5 نتیجه را می‌گیرد. هر دو عنصر /ID همان مقدار را می‌گیرند. همین شناسهٔ مشتق-از-محتوا وقتی هم استفاده می‌شود که یک سند بارگذاری‌شده بدون آنکه هرگز از BeginDoc بگذرد رمزنگاری می‌شود، که حالت ActivateProtection روی فایلی است که با LoadFromFile بازش کرده‌ای

byteهای تصادفی نامحسوس‌ترین جایگزینی‌اند. روتیین کلید AES-256 منبع تصادفی‌اش را در یک هلپر محلی می‌پیچد که زیر این پرچم برای کلید 32-byte ای رمزنگاری فایل و برای هر salt هشت-byte ای FillChar(P^, Count, $5A) را صدا می‌زند، و رمزکننده‌های رشته و stream در AES-128 و AES-256 از AESGenerateRandomIV به AESGenerateStaticIV سوئیچ می‌کنند که بردار مقداردهی اولیه را برای خانهٔ I با 14 * (1 + I) پر می‌کند. با ثابت شدن کلید و saltها و بردارها، /U و /UE و /O و /OE و هر stream رمزنگاری‌شده در اجرای دوم یکسان بیرون می‌آیند. در آخر، SaveToStream هر وقت پرچم تکرارپذیر ست باشد DeterministicDictionaryOrder را روشن می‌کند و سریالایزر بعد هر dictionary را با insertion sort بر مبنای byteهای خام نام کلیدهایش مرتب می‌کند، پیشوند کوتاه‌تر اول، و شاخص اصلی به‌عنوان تعیین‌کنندهٔ تساوی. این همان ترتیبی است که نویسندهٔ تشخیصی هم به کار می‌برد و در مقالهٔ ویرایش دستی یک PDF و ترمیم آن پس از آن توصیف شده؛ پرچم تکرارپذیر فقط همین ترتیب را قرض می‌گیرد، نه بقیهٔ چیدمان متن‌سادهٔ آن نویسنده را

آنچه ReproducibleOutput در HotPDF پین می‌کند: تاریخ ساخت به EncodeDate 2026 و 1 و 1 تبدیل می‌شود، شناسهٔ trailer از ComputeCanonicalDocumentIdentifier روی گراف canonical با صفر شدن رقم‌های D: می‌آید، کلیدها و saltهای AES با byteهای $5A پر می‌شوند و AESGenerateStaticIV هر خانه را پر می‌کند، و DeterministicDictionaryOrder هر dictionary را مرتب می‌کند
جایگزینی‌ها در همان مسیرهای کدی اجرا می‌شوند که در غیر آن صورت سراغ ساعت یا مولّد تصادفی می‌رفتند، پس هیچ پاس پاک‌سازی جداگانه‌ای لازم نیست و هر دو عنصر /ID همان مقدار مشتق-از-محتوا را می‌گیرند

چرا تاریخ ثابت باز هم ساعت دیواری را نشت می‌داد؟

fix مربوط به v2.752.2 وجود دارد چون تاریخ ساخت ثابت ابتدا در سازنده تصمیم‌گیری می‌شد و سازنده نمی‌تواند پراپرتی‌ای را بداند که caller هنوز ستش نکرده. توالی فراخوانی عادی این است: Create، بعد ReproducibleOutput := True، بعد BeginDoc. در زمان ساخت FReproducibleOutput هنوز False است، پس FCreationDate مقدار Now را گرفت و نگهش داشت. شناسه و byteهای تصادفی درست پین شده بودند، پس آن دو فایل تقریباً همه‌جا توافق داشتند و دقیقاً در دو رشتهٔ تاریخ و دو فیلد XMP اختلاف داشتند. بردن این نسبت دادن به شاخهٔ تکرارپذیر BeginDoc، کنار شناسهٔ بذردار، این تصمیم را به همان نقطه‌ای برد که پراپرتی مقدار نهایی‌اش را دارد

تست رگرسیونی که این را از دست داد از خود fix ارزشمندتر است. دو ذخیره‌ای که هر دو در همان ثانیهٔ ساعت دیواری اجرا شوند تصادفاً همان رشتهٔ D: را می‌نویسند و مقایسهٔ byte برای باگی قبول می‌شود که روی هر ماشین کندتری رد می‌شود. تست اصلاح‌شده بین آن دو ذخیره 1100 ms می‌خوابد تا timestamp مربوط به PDF حتماً از یک مرز ثانیه بگذرد، این مورد را برای خروجی ساده و AES-128 و AES-256 با رمز عبور واقعی روی آن دو گونهٔ رمزنگاری‌شده اجرا می‌کند، و آن دو بافر را با CompareMem مقایسه می‌کند و در شکست اولین offset متفاوت را گزارش می‌دهد تا diff به یک شیء مشخص اشاره کند نه به کل فایل. یک مقایسهٔ byte فقط قطعیت را اثبات می‌کند و هیچ چیز دیگر، پس یک assertion جداگانه نگه دار که خروجی رمزنگاری‌شده را با رمز عبور کاربر بارگذاری کند و تعداد صفحه بخواند؛ تغییردهنده‌ای که فایل را هم‌زمان پایدار و ناخوانا می‌کند نباید به اتکای یک 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);          // تحمیل یک ثانیهٔ متفاوت برای timestamp مربوط به 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 رمزنگاری شده به هیچ معنای معناداری محافظت‌شده نیست و این پرچم باید برای هر چیزی که از دایرکتوری تست بیرون می‌رود خاموش باشد. کلید 32-byte ای رمزنگاری فایل در AES-256 سی‌ودو byte از $5A است، saltها هشت byte از $5A و بردارهای مقداردهی اولیه از یک الگوی حسابی منتشرشده پیروی می‌کنند. رمز عبور همچنان دروازهٔ پوشش‌های /UE و /OE است، اما کلید پوشیده‌شده یک ثابت است، پس هر کسی که آن ثابت را بداند می‌تواند هر stream محتوا را بی‌هیچ رمز عبوری رمزگشایی کند. ثابت بودن saltها همان یکتایی به‌ازای-هر-سند را هم از بین می‌برد که ISO 32000-2 §7.6.4.4.7 برای جلوگیری از تولید رشته‌های /U یکسان از رمزهای عبور یکسان در فایل‌های مختلف به آن تکیه دارد. برای اینکه بدانی پراپرتی‌های رمزنگاری وقتی منبع تصادفی سالم است چه چیزی را وعده می‌دهند، مقالهٔ راه‌اندازی AES-256 را بخوان؛ زیر پرچم تکرارپذیر آن وعده‌ها معلق‌اند

معاوضهٔ مربوط به شناسه ظریف‌تر است. ISO 32000-1 §14.4 در نظر دارد که عنصر دوم /ID در هر تغییر عوض شود تا ابزارها بتوانند یک فایل به‌روزشده را از نیایش تشخیص بدهند، و یک ذخیرهٔ تکرارپذیر همان مقدار را در هر دو خانه می‌نویسد. چون آن مقدار hash گراف canonical شیء است، دو سند با محتوای متفاوت باز هم شناسه‌های متفاوت می‌گیرند، که از یک ثابت بهتر است. اما بذری که BeginDoc برای مشتق‌سازی کلید به کار می‌برد همان رشته برای هر سند روی هر ماشینی است، و خواننده‌ای که برای تشخیص فایل‌ها به /ID کلید می‌زند — مثلاً یک cache annotation یا یک sidecar دادهٔ فرم — هر فایل تکرارپذیری را که تصادفاً همان hash را بدهد یکی می‌گیرد

این پرچم چه چیزی را پوشش نمی‌دهد؟

ReproducibleOutput آنتروپی‌ای را که خود نویسنده وارد می‌کند برمی‌دارد؛ نمی‌تواند آنتروپی‌ای را بردارد که از محیط یا از مسیرهای کدی که کنترلشان نمی‌کند وارد می‌شود، و سه مورد از این‌ها به‌سادگی گیر می‌افتند

  • پسوند منطقهٔ زمانی. _DateTimeToPdfDate اختلاف UTC محلی را ضمیمه می‌کند، پس D:20260101000000+08'00' روی یک build agent و D:20260101000000-05'00' روی دیگری برای همان تاریخ ثابت byteهای متفاوتی هستند. تکرارپذیری میان اجراها روی یک ماشین، یا میان ماشین‌هایی که یک منطقهٔ زمانی دارند برقرار است؛ اگر فایل‌های golden تو سفر می‌کنند، منطقهٔ زمانی agent را پین کن
  • به‌روزرسانی‌های افزایشی. SaveIncrementalUpdate شناسهٔ تغییرش را از مسیر هدف و GetTickCount و زمان جاری حساب می‌کند، بدون هیچ شاخهٔ تکرارپذیری، چون یک بخش افزایشی بنا به تعریف یک تغییر جدید است. بازنویسی‌های کامل را مقایسه کن، نه deltaهای ضمیمه‌شده
  • میان‌بر pass-through. SaveLoadedDocument به‌طور معمول یک فایل مبدأ تغییرنکرده و رمزنگاری‌نشده را byte-به-byte کپی می‌کند به‌جای سریالایز دوبارهٔ آن. پرچم تکرارپذیر این میان‌بر را غیرفعال می‌کند و یک بازنویسی کامل را اجباری می‌کند تا قواعد ترتیب و شناسه اعمال شوند، که یعنی ذخیرهٔ تکرارپذیر یک فایل بارگذاری‌شده از حالت پیش‌فرض کندتر است و هرگز کپی ورودی نیست. آن را با یک ذخیرهٔ تکرارپذیر قبلی diff کن، هرگز با اصل
جایی که ذخیره‌های تکرارپذیر HotPDF می‌ایستند: _DateTimeToPdfDate باز هم اختلاف UTC محلی را ضمیمه می‌کند پس فایل‌های golden در مناطق زمانی مختلف فرق دارند، SaveIncrementalUpdate هیچ شاخهٔ تکرارپذیری ندارد چون یک delta تغییر جدیدی است، و میان‌بر pass-through غیرفعال است تا فایل بارگذاری‌شده همیشه کاملاً بازنویسی شود
تکرارپذیری میان اجراها روی یک ماشین یا میان ماشین‌هایی که یک منطقهٔ زمانی دارند برقرار است، و یک ذخیرهٔ تکرارپذیر باید با یک ذخیرهٔ تکرارپذیر قبلی diff شود، هرگز با ورودی اصل

یک درس دیگر از همان انتشار، دربارهٔ اینکه یک بررسی قبول‌شده چه چیزی را اثبات می‌کند و چه چیزی را نه. یک fixture تست PDF/X-6 چیزی به نام CharProcs.DeleteValue('A') را صدا می‌زد که یک stream glyph با مالکیت مستقیم را آزاد می‌کرد، بعد همان اشاره‌گر را دوباره درج می‌کرد، و جداگانه یک شیء ExtGState مستقیم را هم به یک dictionary منابع و هم به یک pattern می‌داد. validator مربوط به انطباق روی آن use-after-free و مالکیت دوگانه به‌صورت متناوب قبول می‌شد چون داشت هر چه حافظهٔ آزادشده در آن لحظه داشت می‌خواند. وقتی یک بررسی ساختاری سوسو می‌زند، پیش از آنکه validator را نگاه کنی به مالکیت ورودی تست نگاه کن. خروجی تکرارپذیر این انضباط را ارزان‌تر می‌کند: وقتی دو ذخیره byte-identical شدند، تنها منبع باقی‌ماندهٔ سوسو زدن خودِ گراف شیء است و یک diff ساختاری از catalog به پایین پیدایش می‌کند

پراپرتی‌های ReproducibleOutput و DeterministicDictionaryOrder و رمزنگاری‌ای که اینجا توصیف شد در HotPDF Delphi Component استاندارد برای Delphi و C++Builder عرضه می‌شوند و همین پرچم، کورپوس رگرسیون خود کتابخانه را هم به راه می‌اندازد، پس رفتاری که در یک سویت تست می‌گیری همان رفتاری است که کامپوننت با آن آزموده می‌شود