مقاله فنی

شناسه قطعی PDF در Delphi برای Build تکرارپذیر

losLab PDF Library می‌تواند برای ورودی یکسان خروجی PDF بایت‌به‌بایت یکسان تولید کند، به‌محض اینکه SetDeterministicDocumentID(1) را فراخوانی کنید. به‌طور پیش‌فرض آرایه /ID در trailer یک digest MD5 از ساعت سیستم است، بنابراین دو اجرای یک تولیدکننده یکسان دست‌کم در آن بایت‌ها متفاوت‌اند. حالت قطعی به‌جای آن /ID را از یک seed پایدار مشتق می‌کند، که build تکرارپذیر را بازمی‌گرداند

این علامت معمولاً در CI پیش از آنکه کسی دنبالش بگردد ظاهر می‌شود. الگو تغییر نکرده، رکورد ورودی تغییر نکرده، فونت‌ها تغییر نکرده‌اند، و PDF تولیدشده همچنان در هر اجرای pipeline هش متفاوتی دارد. build cache هرگز hit نمی‌خورد. content addressable storage به ازای هر build شبانه یک blob تازه انباشته می‌کند. diffهای regression در سطح بایت روی فایل‌هایی که کسی دست نزده روشن می‌شوند. اگر diff را تا بایت‌های واقعی دنبال کنید، تقریباً همیشه همان چند رقم hex هستند که در trailer فایل نشسته‌اند

آرایه ID در trailer برای چیست؟

/ID در trailer یک نشانگر هویت فایل است، نه یک checksum از محتوا. ISO 32000-1 §14.4 آن را آرایه‌ای از دو رشته بایتی تعریف می‌کند: عنصر اول شناسه دائمی است که هنگام ایجاد سند تخصیص می‌یابد و قرار است از هر ویرایش بعدی جان سالم به در ببرد، و عنصر دوم شناسه متغیری است که یک writer هر بار فایل ویرایش می‌شود آن را تازه می‌کند. این دو با هم به یک سیستم اجازه می‌دهند تصمیم بگیرد دو فایل نسخه‌های یک سند هستند یا دو سند بی‌ربط. §7.5.5 در عمل این entry را عملاً اجباری می‌کند، چون trailer هر وقت /Encrypt را هم داشته باشد باید /ID را هم حمل کند

هیچ‌چیز در مشخصات نمی‌گوید مقدار چگونه محاسبه شود. توصیه یک digest از چیزهایی مانند زمان فعلی، مسیر فایل، اندازه فایل و دیکشنری اطلاعات سند است، و ساعت سیستم همان ماده‌ای است که نتیجه را یکتا می‌کند. این دقیقاً همان ویژگی است که برای هویت می‌خواهید و دقیقاً همان ویژگی است که تکرارپذیری را از بین می‌برد، به همین دلیل این باید یک سوییچ صریح باشد نه یک تغییر رفتار خاموش

چرا یک build یکسان هر بار PDF متفاوتی تولید می‌کند؟

چون شناسه پیش‌فرض از لحظه تولید مشتق می‌شود. تاریخاً losLab PDF Library رشته‌های /ID را از MD5 برچسب زمانی فعلی می‌ساخت، بنابراین سندی که دو بار با فاصله یک ثانیه ساخته شود، حتی اگر هر بایت دیگر در فایل یکسان باشد، دو شناسه دائمی متفاوت حمل می‌کند. هزینه پایین‌دستی واقعی است: یک سیستم build که artifactها را با hash کلیدگذاری می‌کند هرگز نمی‌تواند یک مرحله PDF را دوباره استفاده کند، یک object store deduplicating به ازای هر build یک کپی نگه می‌دارد نه به ازای هر سند، و یک reviewer که به یک binary diff نگاه می‌کند باید ثابت کند تنها تغییر noise است پیش از آنکه به بقیه diff اعتماد کند. تولید قطعی /ID برای حذف آن noise وجود دارد، در همان روحیه کار پایداری چیدمان که در یادداشت‌های object stream و cross reference stream شرح داده شده

سوییچ به یک شناسه تکرارپذیر

حالت قطعی opt-in است، به ازای هر سند، و به‌طور پیش‌فرض خاموش، بنابراین خروجی موجود تا وقتی درخواستش نکنید بدون تغییر می‌ماند. SetDeterministicDocumentID مقدار ۰ یا ۱ را می‌پذیرد و هنگام پذیرفته شدن مقدار ۱ برمی‌گرداند، برای هر چیز خارج از بازه ۰؛ GetDeterministicDocumentID وضعیت فعلی را گزارش می‌دهد. SetDocumentIDSeed یک رشته seed صریح فراهم می‌کند که بر هرچیز دیگر برتری دارد، و دادن یک seed خالی به seed مشتق‌شده بازمی‌گردد. GetDocumentFileID پس از save مقدار /ID[0] را بازمی‌خواند تا بتوانید آن را ثبت یا assert کنید

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

تازه‌سازی در زمان save رخ می‌دهد، نه هنگامی که flag را برمی‌گردانید، بنابراین فعال‌سازی حالت قطعی در مراحل دیرتر ساخت سند همچنان اثر می‌کند. این همچنین یعنی یک seed تغییریافته در save کامل بعدی به فایل می‌رسد: seed A را تنظیم کنید، save کنید، seed B را تنظیم کنید، save کنید، و دو فایل شناسه‌های متفاوتی حمل می‌کنند، درحالی‌که بازگرداندن seed A مقدار اصلی را بازمی‌گرداند. یک seed صریح انتخاب درستی است هر وقت سند شما کلید پایدار طبیعی مانند شماره فاکتور، ویرایش رکورد یا شناسه commit در git داشته باشد، چون شناسه را از metadata اتفاقی جدا می‌کند

وقتی seed را خودتان تأمین نمی‌کنید، از کجا می‌آید؟

بدون یک seed صریح، losLab PDF Library یکی را از وضعیت سند که باید در بازتولیدهای یکسان ثابت بماند مشتق می‌کند: هدر نسخه PDF، تعداد صفحات، و هر entry در دیکشنری اطلاعات سند. مقادیر string و name به‌صورت عیناً گرفته می‌شوند، انواع دیگر شیء فرم سریالایز خود را مشارکت می‌دهند، و کل آن به رشته‌های /ID هش می‌شود. پیامد مهم این است که CreationDate و ModDate بخشی از دیکشنری اطلاعات هستند و بنابراین طبق طراحی بخشی از seed هستند. دو اجرا فقط زمانی شناسه یکسانی به‌دست می‌آورند که واقعاً همان metadata سند را تولید کنند

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

پین کردن ModDate با کلید ۸ نقش دوگانه دارد، و این بخشی است که افراد را گیر می‌اندازد. یک /ID قطعی به‌تنهایی فایل را بایت‌به‌بایت یکسان نمی‌کند، چون مسیر save مقدار ModDate را با زمان فعلی مهر می‌کند مگر فراخواننده صریحاً آن را تنظیم کرده باشد. تنظیم کلید ۸ آن مقدار را به‌عنوان تأمین‌شده توسط فراخواننده علامت‌گذاری می‌کند و آن مهر را سرکوب می‌کند. اگر یک فایل تکرارپذیر می‌خواهید نه صرفاً یک شناسه تکرارپذیر، برچسب‌های زمانی metadata را به‌عنوان ورودی build در نظر بگیرید: آن‌ها را از رکورد منبع یا از یک epoch ثابت مشتق کنید، هرگز از Now

چرا بازنویسی ID یک PDF رمزنگاری‌شده را می‌شکند؟

چون /ID[0] در یک سند رمزنگاری‌شده صرفاً metadata نیست، ماده کلیدی است. ISO 32000-1 §7.6.3.3 Algorithm 2 عنصر اول شناسه فایل را در محاسبه کلید رمزنگاری برای handler امنیتی استاندارد در revision های ۲ تا ۴ وارد می‌کند، در کنار رمزعبور padded، مقدار /O و بیت‌های مجوز. کلید مشتق‌شده سپس رشته اعتبارسنجی /U را تولید می‌کند که یک reader هنگام باز کردن بررسی می‌کند، و کلید فایل هنگام فراخوانی Encrypt یا هنگام بارگذاری یک سند رمزنگاری‌شده مشتق و کش می‌شود، هر دو پیش از save رخ می‌دهند. بازنویسی شناسه در طول save بنابراین یک فایل ساختاری معتبر تولید می‌کند که بررسی /U آن در باز شدن دوباره شکست می‌خورد: نه یک فساد ظریف بلکه سندی که هیچ‌کس، حتی خودتان، نمی‌تواند باز کند. به همین دلیل تازه‌سازی قطعی به اسنادی محدود می‌شود که وضعیت رمزنگاری حمل نمی‌کنند، و به همین دلیل یک سند رمزنگاری‌شده هر /IDای که قبلاً داشته را حفظ می‌کند، حالت قطعی یا نه، و تنظیم به‌سادگی روی آن مسیر اثری ندارد. مدیریت revision مرتبط و معنای مجوزها در بررسی رمزنگاری PDF و ممیزی مجوز پوشش داده شده. توجه کنید مسیر بازیابی رمزنگاری تنها /ID[1]، شناسه تغییر، را تازه می‌کند، دقیقاً همان‌طور که §14.4 در نظر دارد

چرا save های افزایشی شناسه اصلی را حفظ می‌کنند

مرز دوم append mode است. یک به‌روزرسانی افزایشی هر بایت پیشین فایل را دست‌نخورده رها می‌کند و یک revision جدید پس از آن می‌نویسد، و ماندگاری /ID[0] در سراسر §14.4 همان چیزی است که به یک مصرف‌کننده می‌گوید revision جدید متعلق به همان سندی است که قدیمی بود. بازنویسی آن آن پیوند را قطع می‌کند، با revisionهایی که از قبل در فایل نشسته‌اند تناقض دارد، و با معنای امضا تداخل می‌کند، چون یک امضا یک بازه بایت از یک revision خاص از یک سند خاص را پوشش می‌دهد. losLab PDF Library بنابراین شناسه قطعی را تنها در save های کامل تازه می‌کند و هرگز در طول append mode، که تضمین شرح‌داده‌شده در مقاله به‌روزرسانی‌های افزایشی PDF و append به stream را دست‌نخورده نگه می‌دارد

یک نقطه گلوگاه برای تولید شناسه

همه تولید /ID در losLab PDF Library اکنون از طریق یک روتین داخلی واحد، NewFileIDString، عبور می‌کند، همان چیزی که سوییچ قطعی را قابل‌اعتماد می‌کند نه یک وصله روی یک مسیر کد. ایجاد سند خالی، ایجاد تنبل یک آرایه /ID گمشده هنگام تقاضا، و مسیر بازیابی fingerprint رمزنگاری همگی آن را فراخوانی می‌کنند، بنابراین دقیقاً یک مکان وجود دارد که ساعت سیستم می‌توانست دوباره نشت کند. این همچنین یعنی نوع‌های آینده، مانند یک شناسه مشتق از محتوا، تغییری در یک تابع هستند نه یک ممیزی از کل serializer

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

پیش از آنکه هر جای دیگری روی خروجی تکرارپذیر تکیه کنید، آن مقایسه را در test suite خود سیم‌کشی کنید، چون به‌محض اینکه یک ویژگی جدید یک برچسب زمانی را دوباره وارد کند به‌بلندی شکست می‌خورد. تکرارپذیری در غیر این صورت ویژگی‌ای است که به‌آرامی تحلیل می‌رود، و یک assertion واحد روی دو save در حافظه تقریباً هیچ هزینه‌ای برای اجرا در هر build ندارد

API شناسه قطعی که اینجا نشان داده شد همراه با losLab PDF Library برای Delphi و C++Builder، در کنار مرجع کامل اطلاعات سند، رمزنگاری و save افزایشی عرضه می‌شود