مقاله فنی

چرا یک ذخیرهٔ no-op در PDF می‌تواند Info و XMP را خراب کند

نسخه‌های v3.539.18 و v3.539.20 از PDF Library for Delphi دو راه را بستند که یک ذخیرهٔ PDF بی‌هیچ تغییری باز هم می‌توانست متادیتای سند را خراب کند: وقتی /CreationDate و /ModDate به یک شیء رشته‌ای یکسان ارجاع می‌دادند، به‌روزرسانی خودکار ModDate هر دو را بازنویسی می‌کرد؛ و وقتی شیء XMP پیش از خوانده شدن stream اصلی /Metadata ساخته می‌شد، یک بستهٔ پیش‌فرض جای بستهٔ اصلی را می‌گرفت. fixها به‌جای تغییر دادن objectهای مشترک، ارجاع‌های dictionary را جایگزین می‌کنند و بستهٔ موجود را پیش از مقداردهی اولیهٔ تنبل XMP برمی‌دارند

این تنظیم، کم‌جذاب‌ترین عملیاتی است که یک کتابخانهٔ PDF انجام می‌دهد: فایل را بارگذاری کن، با نامی جدید ذخیره کن، در فاصلهٔ میان این دو به هیچ چیز دست نزن. صفحه‌ها پیش و پس یکسان رندر می‌شدند. hashهای stream محتوا می‌خواندند. فایل از هر بررسی‌ای که داشتیم قبول بیرون می‌آمد، و باز هم در دو جا اشتباه بود که هیچ rendererی هرگز نشانت نمی‌دهد. هر دو نقص در همان مسیر read-modify-write نشسته بودند که هر ویرایش واقعی از آن می‌گذرد، پس هر ذخیره‌ای برای فعال کردنشان کافی بود، و هر دو فقط وقتی پیدا شدند که یک parser دوم و مستقل معناشناسی غیربصری آن دو فایل را با هم مقایسه کرد

چرا ذخیره کردن یک PDF تاریخ CreationDate را عوض می‌کند؟

چون dictionary اطلاعات سند مجاز است از دو کلید به یک شیء رشته‌ای غیرمستقیم ارجاع بدهد و کتابخانه داشت خود شیء را به‌روز می‌کرد، نه کلید را. ISO 32000-1 §7.3.10 اجازه می‌دهد هر مقدار dictionary یک ارجاع غیرمستقیم باشد و هیچ‌جای §14.3.3 جدول 317 نمی‌گوید مقدار زیر /CreationDate باید شیئی متفاوت از مقدار زیر /ModDate باشد. تولیدکننده‌ای که در لحظهٔ ساخت سند همان timestamp را دو بار نوشته، کاملاً قانونی می‌تواند هر دو کلید را به یک 2728 0 R واحد اشاره بدهد؛ و دقیقاً همین کار را یک سند طراحی CJK در کورپوس محلی ما کرده بود

محرک ماجرا همان تاریخ تغییر خودکار است. تا وقتی UserModDate ست نشده باشد، SaveToFile پیش از نوشتن SetInfo('ModDate', ...) را با زمان جاری صدا می‌زند و این فراخوانی به SetRawInfo می‌رسد. SetRawInfo قدیمی شیء زیر آن کلید را پیدا می‌کرد و اگر یک TPDFString بود، SetTo را روی آن صدا می‌زد. این یک نوشتن درجاست روی هر شیئی که کلید در آن لحظه به آن resolve می‌شود، و وقتی آن شیء مشترک باشد، /CreationDate هم حالا زمان ذخیره را گزارش می‌کند. سند باز هم باز، چاپ و پیکسل‌به‌پیکسل مثل قبل رندر می‌شود، پس یک سویت رگرسیونِ بصری بدون پلک زدن قبول می‌شود

تغییر رشتهٔ مشترک در Info در PDFlibPas: کلیدهای /CreationDate و /ModDate به‌طور قانونی به یک شیء رشته‌ای 2728 0 R ارجاع می‌دهند، SetRawInfo قدیمی روی هر چه کلید به آن resolve می‌شد SetTo را صدا می‌زد و هر دو تاریخ را با زمان ذخیره بازنویسی می‌کرد، و SetRawInfo جدید یک رشتهٔ تازه زیر همان کلید اضافه می‌کند و حالت رشتهٔ hex را هم حفظ می‌کند
به‌روزرسانی یک درایهٔ dictionary حالا ارجاع همان درایه را جایگزین می‌کند، نه شیء مشترکی را که تصادفاً به آن resolve شده بود؛ پس یک نوشتن خودکار ModDate دیگر نمی‌تواند CreationDate را عوض کند و شیء کنارگذاشته‌شده برای ارجاع‌های دیگر نگه داشته می‌شود
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate و 8 = ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

fix در TPDFDocument.SetRawInfo کوچک است و اصلی که پشتش ایستاده کلی: به‌روزرسانی یک درایهٔ dictionary ارجاع همان درایه را جایگزین می‌کند، هرگز شیئی را که تصادفاً به آن resolve شده بود. کد جدید TPDFStringMode موجود را می‌خواند تا رشتهٔ hex همچنان hex بماند و رشتهٔ literal همچنان literal، و بعد یک رشتهٔ تازه از FStructure.NewString(Value, StringMode) زیر آن کلید اضافه می‌کند. دو جزئیات دیگر همان‌قدر مهم‌اند که تغییر اصلی. شاخهٔ قدیمی برای درایه‌ای که مقدارش stream است، پیش از جایگزینی stream را با SetTo('') خالی می‌کرد، که مقدار را برای هر کلید دیگری که هنوز به آن stream اشاره داشت تهی می‌کرد؛ پس آن پاک کردن حذف شده. و شیء کنارگذاشته‌شده پاک نمی‌شود، چون structure مالکش است و ارجاع‌های دیگر ممکن است هنوز لازمش داشته باشند

// قبل: هر شیئی که کلید در آن لحظه به آن resolve می‌شود را تغییر بده
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// بعد: بازنمایی را نگه دار، فقط ارجاع همین کلید را جایگزین کن
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

رگرسیون موجود در Tests\SharedInfoSemantics.inc این aliasing را به‌عمد می‌سازد، نه با اتکا به یک فایل کورپوس: یک رشتهٔ hex که هر دو کلید تاریخ به آن ارجاع می‌دهند، یک رشتهٔ مستقیم که /Title و /Subject شریکش هستند، و یک stream که /Author و /Keywords در آن سهیم‌اند. بعد از به‌روزرسانی یک کلید از هر جفت، آن یکی باید هنوز مقدار اصلی‌اش را بخواند و رشتهٔ به‌روزشده باید هنوز hex باشد. مرجع عمومی SetInformation حالا این تضمین را در یک جمله بیان می‌کند: به‌روزرسانی یک فیلد Info فقط همان فیلد را جایگزین می‌کند، حتی وقتی فیلدهای دیگر به همان شیء ارجاع می‌دهند

چرا یک بستهٔ XMP موجود با پیش‌فرض‌ها جایگزین می‌شود؟

به خاطر ترتیب دو خط. TPDFDocument.GetMetadata یک مسیر سریع دارد: وقتی فیلد XMP از قبل مقدار گرفته باشد، به‌جای decode کردن stream /Metadata از catalog، XMP.SaveToString را برمی‌گرداند. چند call site با XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); به‌صورت تنبل مقداردهی اولیه می‌کردند، که همین‌طور که خوانده می‌شود طبیعی به نظر می‌رسد و غلط است: تا وقتی GetMetadata اجرا شود، XMP مقدار گرفته، پس آن «منبعی» که بارگذاری می‌شود، همان بستهٔ پیش‌فرض سریالایز‌شدهٔ شیئی است که یک خط قبل‌تر ساخته شده. بستهٔ اصلی با dc:creator و namespaceهای سفارشی و هر شناسهٔ استانداردی‌اش هرگز به آن شیء نمی‌رسد و هنگام ذخیره بازنویسی می‌شود. همان تاریخ تغییر خودکار برای فعال کردنش کافی است، چون SetInfo پیش از دست زدن به dictionary مربوط به Info، شیء XMP را مقداردهی می‌کند تا xmp:ModifyDate با /ModDate هم‌قدم بماند. به آنچه این نقص پشتش پنهان می‌شود دقت کن: مقایسهٔ dictionary مربوط به Info از باگ اول قبول می‌شود، چون /Author و /Title در /Info دست‌نخورده‌اند. فقط درخت XMP عوض شده و تنها بررسی‌ای که آن درخت را parse و مقایسه کند متوجه می‌شود

ترتیب مقداردهی اولیهٔ تنبل XMP در PDFlibPas: ساختن شیء XMP پیش از فراخوانی GetMetadata باعث می‌شود مسیر سریع یک بستهٔ پیش‌فرض را سریالایز کند و dc:creator و namespaceهای سفارشی و شناسهٔ استاندارد را دور بریزد، در حالی که برداشتن Source پیش از TPDFlibXMP.Create، stream اصلی /Metadata را از catalog بارگذاری می‌کند
هر ذخیره‌ای این جابه‌جایی را فعال می‌کرد چون SetInfo شیء XMP را مقداردهی می‌کند تا xmp:ModifyDate با /ModDate هم‌قدم بماند، پس هر مقداردهی اولیهٔ تنبل در سند حالا از یک EnsureXMP واحد می‌گذرد که بستهٔ موجود را پیش از ساختن شیء برمی‌دارد
// غلط: GetMetadata حالا شیئی را سریالایز می‌کند که خط قبل ساخته شد
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// درست: اول stream مربوط به /Metadata را بردار، بعد بساز و بارگذاری کن
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

fix دو کار می‌کند. TPDFDocument.EnsureXMP حالا Source := GetMetadata را پیش از TPDFlibXMP.Create برمی‌دارد و هر مقداردهی اولیهٔ تنبل در سند با فراخوانی آن جایگزین شده: SetInfo و SetXMPInformation و GetXMPInformation، setterهای حالت PDF/A و PDF/X و PDF/E و PDF/VT و PDF/VCR و PDF/UA، و مسیر ترمیم متادیتا. نقاط ورودی عمومی مثل SetXMPProperty از قبل از EnsureXMP می‌گذشتند و GetXMPProperty هم از طریق GetDocumentMetadata می‌خواند، پس کل سطح عمومی یک ترتیب مقداردهی مشترک دارد. یک نسخهٔ درست از یک توالی سه-خطی ارزشش از ده نسخه‌ای که امروز تصادفاً با هم توافق دارند بیشتر است

دو تلهٔ کوچک‌تر در همین مسیر

سریالایزر XMP روی ویندوز از XML writer پلتفرم استفاده می‌کند که یک اعلان XML تولید می‌کند که بسته نباید آن را داشته باشد. کد قدیمی با پاک کردن کاراکترها تا رسیدن به <?xpacket آن را برمی‌داشت. ISO 16684-1 §7.3.2 پوشش xpacket را اختیاری می‌داند و تولیدکننده‌ای که یک عنصر خالی <x:xmpmeta> بنویسد در چارچوب استاندارد است؛ پس روی چنین بسته‌ای آن حلقه کل یک سند معتبر را پاک می‌کرد. سریالایزر حالا محل ?> پایانی اعلان را پیدا می‌کند و فقط همان را حذف می‌کند. Tests\XMPRetentionSemantics.inc بررسی نگه‌داشتش را دو بار اجرا می‌کند، یک بار با پوشش و یک بار بدون آن، و assertion می‌کند که یک نشانگر namespace سفارشی و نویسندهٔ اصلی از SetInfo و GetMetadata و SaveToString و یک بارگذاری مجدد جان سالم به در می‌برند. تلهٔ دوم یک سیمبل پیش‌پردازنده بود: همگام‌سازی Info به XMP در SetInfo با NOVCL محافظت می‌شد که برای buildهای Free Pascal ست می‌شود، اما بک‌اند XMP با سیستم‌عامل دروازه‌بندی می‌شود نه با فریم‌ورک، چون PDFlibXMP.pas سیمبل NO_XMP را فقط وقتی OS_WINDOWS غایب باشد تعریف می‌کند. پس یک build لازاروس روی ویندوز شیء XMP کارآمدی داشت و یک SetInfo که بی‌صدا از به‌روز کردنش می‌گذشت. نگهبان حالا NO_XMP است، پس یک اپلیکیشن Free Pascal روی ویندوز همان همگام‌سازی Delphi را می‌گیرد

چطور ModDate اصلی را در یک ذخیرهٔ pass-through نگه می‌دارید؟

KeepModDate را در TPDFlibSaveOptions ست کن و از طریق SaveToFileOptions ذخیره کن. این گزینه برای طول همان فراخوانی UserModDate را ست می‌کند و SaveToFile بعد از آن از timestamp خودکار صرف‌نظر می‌کند؛ که همان مرحله‌ای است که شیء XMP را هم به‌صورت تنبل مقداردهی می‌کند. سندی که هرگز متادیتایش را دست نزده‌اید و هیچ حالت انطباقی برایش فعال نشده، هم dictionary مربوط به Info و هم stream /Metadata اش را همان‌طور که بارگذاری شده بود نگه می‌دارد. صدا زدن SetInformation(8, ...) همین اثر را برای همیشه دارد، چون تعیین دستی تاریخ تغییر آن را به‌عنوان مقدار تحت کنترل کاربر علامت می‌زند

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // بدون /ModDate خودکار و بدون مقداردهی تنبل XMP
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

دربارهٔ اینکه این گزینه چه چیزی به دستت می‌دهد صادق باش. KeepModDate انتخاب درست برای یک مرحلهٔ pass-through است که خروجی‌اش باید همان بازنگری ورودی را توصیف کند و انتخاب غلط برای هر چیزی است که واقعاً محتوا را ویرایش می‌کند، چون §14.3.3 انتظار دارد /ModDate آخرین تغییر را بازتاب بدهد. همچنین کتابخانه‌ای که objectهای مشترک را تغییر می‌دهد را به‌عقب fix نمی‌کند؛ فقط همان یک نوشتنی را دور می‌زند که نقص را رو کرده بود. آن دو fix بالا هستند که یک ذخیرهٔ معمولی را ایمن می‌کنند و این گزینه است که یک no-op عمدی را صادق می‌کند

چطور تأیید می‌کنید که یک ذخیره جز ModDate چیزی را عوض نکرده؟

نه با پیکسل‌ها و نه با hashهای stream، چون هر دو نقص همهٔ صفحه‌ها و همهٔ streamهای محتوا را byte-به-byte دست‌نخورده می‌گذارند. بررسی‌ای که آن‌ها را گرفت یک snapshot معناشناختی غیربصری است که یک parser مستقل — یکی که هیچ کدی با کتابخانهٔ تحت تست شریک نیست — از فایل منبع و از فایل ذخیره‌شده می‌گیرد و بعد مقایسهٔ ساختاری انجام می‌دهد. این snapshot dictionary مربوط به Info را بدون /ModDate پوشش می‌دهد، درخت outline را با هر bookmark که به شمارهٔ صفحه resolve شده باشد نه به شمارهٔ شیء، مقصدهای نام‌دار و هدف‌های لینک را با همان شیوه resolve شده، مقدارهای فیلد فرم، byteهای پیوست به‌صورت hash، و بستهٔ XMP را که به‌صورت درخت parse می‌شود نه به‌صورت متن مقایسه. شماره‌های شیء عمداً بخشی از آن نیستند، چون یک بازنویسی کامل همه‌چیز را دوباره شماره می‌دهد و مقایسه‌ای که بر مبنای آن‌ها کلید بخورد فقط نویز گزارش می‌دهد

تأیید معناشناختی غیربصری برای ذخیره‌های PDFlibPas: یک parser مستقل بدون کد مشترک از dictionary مربوط به Info منهای /ModDate و صفحه‌های outline و مقصد، مقدارهای فرم، hashهای پیوست و درخت XMP snapshot می‌گیرد و بعد فایل منبع را با فایل ذخیره‌شده مقایسه می‌کند و /ModDate و xmp:ModifyDate و xmp:MetadataDate را به‌عنوان تغییرهای موردانتظار کنار می‌گذارد
پیکسل‌ها و hashهای stream در هر دو نقص byte-به-byte همان می‌مانند، پس مقایسه روی معناشناسی resolve‌شده کار می‌کند نه روی شماره‌های شیء، و متادیتای جان‌سالماده صادقانه به‌عنوان نگه‌داشته‌شده گزارش می‌شود، نه به‌عنوان سازگار با schema یا منطبق با PDF/UA و PDF/A

استثناها همان‌قدر مهم‌اند که شمول‌ها. /ModDate و xmp:ModifyDate و xmp:MetadataDate قرار است عوض شوند و پیش از مقایسه کنار گذاشته می‌شوند؛ فایلی که منبعش اصلاً XMP نداشته هم برای به‌دست آوردن یک بسته جریمه نمی‌شود. آنچه این بررسی ادعا نمی‌کند هم به همان اندازه صریح است: نگه داشتن یک بستهٔ موجود هیچ نمی‌گوید که آن بسته از نظر schema معتبر است یا سند با PDF/UA یا هر بخشی از PDF/A منطبق است. این‌ها پرسش‌های جداگانه‌ای با ابزارهای جداگانه‌اند و یکی گرفتن «متادیتا جان سالم برد» با «متادیتا منطبق است» همان چیزی است که باعث شد باگ اول این‌قدر دوام بیاورد. از سمت کتابخانه، این دو رگرسیون حالا روی هر پاس هدف‌گیری‌شده در Delphi Win32 و Win64 و Free Pascal Win32 و Win64 اجرا می‌شوند و مقایسهٔ معناشناختی یک شرط قبولی برای بنچمارک کورپوس اسناد واقعی است

اگر در سطحی پایین‌تر از این fixها کار می‌کنی، مکانیک اینکه یک ذخیره چگونه objectها را بازنویسی می‌کند در به‌روزرسانی افزایشی و ذخیرهٔ ضمیمه‌محور پوشش داده شده، که تنها حالت ذخیره‌ای است که در آن شیء مشترک صرفاً همان‌جا که بود رها می‌شود، و در سطح‌های تغییر و diff بازنگری‌ها، که آن جای دیگر است که یک تاریخ کهنه یا بازنویسی‌شده خواننده را گمراه می‌کند. نمای سمت ترمیم از همین جفت Info و XMP، جایی که دو نیمه به توافق رسانده می‌شوند نه فقط نگه داشته، در تبدیل به PDF/A و ترمیم متادیتا آمده است

PDF Library for Delphi یک کتابخانهٔ PDF بومی پاسکالی برای Delphi و C++Builder و Lazarus است و مسیر read-modify-write که اینجا توصیف شد همان مسیری است که هر ویرایش در پروسهٔ خودت از آن می‌گذرد، پس تضمین‌های بالا چه روزی یک بار ذخیره کنی و چه هزار بار در روز برقرارند — برای کامپایلرها و پلتفرم‌های پشتیبانی‌شده صفحهٔ محصول PDF Library for Delphi را ببین