مقال تقني

لماذا يمكن لحفظ PDF بلا تغيير أن يفسد بيانات Info و XMP

يصلح PDF Library for Delphi في v3.539.18 و v3.539.20 طريقتين كانت لحفظ PDF لا يغيّر شيئاً فيهما أن يفسد بذلك بيانات المستند الوصفية: عندما أشارت /CreationDate و /ModDate إلى كائن السلسلة نفسه، كانت تحديث ModDate التلقائي يعيد كتابة كليهما، وعندما أُنشئ كائن XMP قبل قراءة تدفق /Metadata الأصلي، استبدلت حزمة افتراضية الحزمة الأصلية. الإصلاحان يستبدلان مراجع القاموس بدل تعديل الكائنات المشتركة، ويلتقطان الحزمة القائمة قبل تهيئة XMP الكسولة

الإعداد أقل عملية تثير الاهتمام مما تؤديه مكتبة PDF: حمّل ملفاً، واحفظه باسم جديد، ولا تلمس شيئاً بينهما. الصفحات عُرضت متطابقة قبل وبعد. وبصمات تدفقات المحتوى طابقت. والملف اجتاز كل فحص نملكه، ومع ذلك كان خاطئاً في موضعين لن يعرضهما عليك أي عارض أبداً. جلس العيبان في مسار القراءة-التعديل-الكتابة الذي تمر به كل عملية تحرير حقيقية، فكان أي حفظ مهما كان كافياً لإشعالهما، ولم يُكتشف أي منهما إلا حين قارن محلل مستقل ثانٍ الدلالات غير البصرية للملفين

لماذا يغيّر حفظ PDF تاريخ إنشائه CreationDate؟

لأن قاموس معلومات المستند مسموح له أن يشير إلى كائن سلسلة غير مباشر واحد من مفتاحين، والمكتبة كانت تحدّث الكائن لا المفتاح. يتيح ISO 32000-1 §7.3.10 لأي قيمة قاموس أن تكون مرجعاً غير مباشر، ولا يقول شيء في §14.3.3 الجدول 317 إن القيمة تحت /CreationDate يجب أن تختلف عن القيمة تحت /ModDate. يمكن لمنتج كتب الختم الزمني نفسه مرتين وقت الإنشاء أن يشير بالمفتاحين إلى 2728 0 R واحد بكل شرعية، وهو ما فعله بالضبط مستند تصميمي من عائلة CJK في مدونة ملفاتنا المحلية

المشغّل هو تاريخ التعديل التلقائي. إلا إذا ضُبط UserModDate يستدعي SaveToFile التابع SetInfo('ModDate', ...) بالوقت الحالي قبل الكتابة، وهو ما يصل إلى SetRawInfo. كانت SetRawInfo القديمة تبحث عن الكائن تحت المفتاح وإن وجدت TPDFString استدعت SetTo عليه. تلك كتابة في الموقع على أي كائن يحل إليه المفتاح حالياً، وحين يكون ذلك الكائن مشتركاً يأتي /CreationDate يبلّغ عن وقت الحفظ أيضاً. ما زال المستند يفتح ويطبع ويعرض بكسلاً ببكسل كما كان، فتجتاز حزمة اختبارات الانحدار البصرية بلا رمش

تعديل سلسلة Info المشتركة في PDFlibPas: تشير /CreationDate و /ModDate بكل شرعية إلى كائن سلسلة واحد 2728 0 R، وكانت SetRawInfo القديمة تستدعي SetTo على ما يحل إليه المفتاح فتعيد كتابة التاريخين بوقت الحفظ، بينما تضيف SetRawInfo الجديدة سلسلة جديدة تحت المفتاح مع الحفاظ على وضع السلسلة الست عشرية
تحديث إدخال قاموس يستبدل الآن مرجع ذلك الإدخال بدل تعديل الكائن المشترك، فلم يعد يمكن لكتابة 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;

الإصلاح في TPDFDocument.SetRawInfo صغير والمبدأ الذي يحتضنه عام: تحديث إدخال قاموس يستبدل مرجع ذلك الإدخال، ولا يعدّم الكائن الذي صادف أنه حلّ إليه أبداً. يقرأ الكود الجديد TPDFStringMode القائمة حتى تبقى السلسلة الست عشرية ست عشرية والحرفية حرفية، ثم يضيف سلسلة جديدة من FStructure.NewString(Value, StringMode) تحت المفتاح. تفصيلان آخران يوازيان التغيير الرئيسي أهمية. الفرع القديم لإدخال قيمته تدفق كان يمسح التدفق بـ SetTo('') قبل استبداله، وهو ما كان سيفرّغ القيمة لكل مفتاح آخر ما زال يشير إلى ذلك التدفق، فذُبح ذلك المسح. ولا يُحذف الكائن الملغى، لأن البنية تملكه ومراجع أخرى قد تحتاجه

// قبل: عدّم أي كائن يحل إليه المفتاح حالياً
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 المحاكاة عمداً بدل الاعتماد على ملف من المدونة: سلسلة ست عشرية واحدة يشير إليها مفتاحا التاريخ معاً، وسلسلة مباشرة واحدة يتشاركها /Title و /Subject، وتدفق واحد يتشاركه /Author و /Keywords. بعد تحديث مفتاح واحد من كل زوج يجب أن يقرأ الآخر قيمته الأصلية وأن تبقى السلسلة المحدثة ست عشرية. والمرجع العام لـ SetInformation ينص الآن على الضمانة في جملة واحدة: تحديث حقل Info يستبدل ذلك الحقل وحده، حتى حين تشير حقول أخرى إلى الكائن نفسه

لماذا تُستبدل حزمة XMP قائمة بالقيم الافتراضية؟

بسبب ترتيب سطرين. لدى TPDFDocument.GetMetadata مسار سريع: عندما يكون حقل XMP معيناً أصلاً يعيد XMP.SaveToString بدل فك تدفق /Metadata من الكتالوج. وعدة مواضع استدعاء مهّأت الكسل بـ XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);، وهي صيغة تُقرأ بشكل طبيعي وهي خاطئة: حين يجري GetMetadata يكون XMP معيناً، فالمصدر الذي يُحمَّل هو الحزمة الافتراضية المصوغة لكائن أُنشئ قبل سطر واحد. الحزمة الأصلية، بـ dc:creator وأسماء النطاقات المخصصة وأي تعريف معايير فيها، لا تصل إلى الكائن أبداً وتُكتب فوقها عند الحفظ. يكفي تاريخ التعديل التلقائي نفسه لإشعال ذلك، لأن SetInfo يهيئ XMP قبل أن يلمس قاموس Info حتى يبقى xmp:ModifyDate متزامناً مع /ModDate. ولاحظ خلف ما يختبئ هذا العيب: مقارنة قاموس Info من العيب الأول تنجح، لأن /Author و /Title في /Info لم يُمسّا. تغيّرت شجرة XMP وحدها، وفقط فحص يحلل تلك الشجرة ويقارنها هو الذي ينتبه

ترتيب تهيئة XMP الكسولة في PDFlibPas: إنشاء كائن XMP قبل استدعاء GetMetadata يجعل المسار السريع يصوغ حزمة افتراضية ويسقط dc:creator وأسماء النطاقات المخصصة وتعريف المعايير، بينما يلتقط التقاط Source قبل TPDFlibXMP.Create تدفق /Metadata الأصلي من الكتالوج
أي حفظ أشعل الاستبدال لأن SetInfo يهيئ XMP ليُبقي xmp:ModifyDate متزامناً مع /ModDate، فصارت كل تهيئة كسلية في المستند تجري عبر EnsureXMP واحد يلتقط الحزمة القائمة قبل إنشاء الكائن
// خطأ: يصوغ GetMetadata الآن الكائن الذي أُنشئ في السطر السابق
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// صواب: التقط تدفق /Metadata أولاً ثم أنشئ وحمّل
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

يفعل الإصلاح أمرين. تلتقط TPDFDocument.EnsureXMP الآن Source := GetMetadata قبل TPDFlibXMP.Create، واستُبدلت كل تهيئة كسلية في المستند باستدعاء لها: SetInfo و SetXMPInformation و GetXMPInformation، ومضبط أوضاع PDF/A و PDF/X و PDF/E و PDF/VT و PDF/VCR و PDF/UA، ومسار إصلاح البيانات الوصفية. والمداخل العامة مثل SetXMPProperty كانت تمر أصلاً عبر EnsureXMP، و GetXMPProperty تقرأ عبر GetDocumentMetadata، فتتشارك السطح كلها ترتيب تهيئة واحداً. نسخة صحيحة واحدة من تتابع من ثلاثة أسطر أثمن من عشر نسخ تصادف أنها متفقة اليوم

فَخّان أصغر وُجدا على المسار نفسه

يستخدم مصوغ XMP على Windows كاتب XML الخاص بالمنصة، الذي يصدر تصريح XML لا يجوز للحزمة أن تحمله. كان الكود القديم يجردّه بحذف محارف حتى يبلغ <?xpacket. يجعل ISO 16684-1 §7.3.2 غلاف xpacket اختيارياً، والمنتج الذي يكتب عنصر <x:xmpmeta> عارياً داخل حدود المعيار، فعلى مثل تلك الحزمة حذفت الحلقة المستند الصحيح كله. يحدد المصوغ الآن ?> الختامية للتصريح ويزيلها وحدها. وتجري Tests\XMPRetentionSemantics.inc فحص الاحتفاظ مرتين، مرة بالغلاف ومرة مقطوعاً، وتؤكد أن علامة نطاق أسماء مخصصة والمؤلف الأصلي ينجوان من SetInfo و GetMetadata و SaveToString وإعادة تحميل. وكان الفَخّ الثاني رمز معالج مسبق: كان تزامن Info إلى XMP في SetInfo محروساً بـ NOVCL، المضبوط لبنيات Free Pascal، بينما تُحوس خلفية XMP بنظام التشغيل لا بالإطار، لأن PDFlibXMP.pas يعرف NO_XMP فقط عند غياب OS_WINDOWS. فصار بناء Lazarus على Windows يملك كائن XMP عاملاً و SetInfo يتخطى تحديثه بصمت. الحارس الآن NO_XMP، فيحصل تطبيق Free Pascal على Windows على التزامن نفسه الذي يحصل عليه Delphi

كيف تحتفظ بـ ModDate الأصلي في حفظ عابر؟

اضبط KeepModDate في TPDFlibSaveOptions واحفظ عبر SaveToFileOptions. يضبط الخيار UserModDate على مدة الاستدعاء، فيتخطى SaveToFile الختم الزمني التلقائي، وهو نفسه الخطوة التي تهيئ كائن XMP بشكل كسول. يبقي المستند الذي لم تلمس بياناته الوصفية ولم تفعّل له أي وضع امتثال قاموس Info وتدفق /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 هو الخيار الصحيح لخطوة عابرة ينبغي أن يصف مخرجها المراجعة نفسها التي يصفها مدخلا، وهو الخيار الخطأ لكل ما يحرر المحتوى فعلاً، لأن §14.3.3 يتوقع أن يعكس /ModDate أحدث تعديل. وهو كذلك لا يصلح بأثر رجعي مكتبة تعدّم كائنات مشتركة؛ إنه يتجنب الكتابة الواحدة التي كشفت العيب فحسب. الإصلاحان أعلاه هما ما يجعل الحفظ العادي آمناً، والخيار هو ما يجعل عدم-العمل المتعمد صادقاً

كيف تتحقق أن حفظاً لم يغيّر شيئاً سوى ModDate؟

ليس بالبكسلات ولا ببصمات التدفقات، لأن العيبين يتركان كل صفحة وكل تدفق محتوى متطابقاً بايتاً ببايت. الفحص الذي مسكهما هو لقطة دلالات غير بصرية يلتقطها محلل مستقل، لا يتشارك سطر كود واحد مع المكتبة المختبرة، من الملف المصدر ومن الملف المحفوظ، يليها مقارنة بنيوية. تغطي اللقطة قاموس Info مع استبعاد /ModDate، وشجرة المخطط مع حل كل إشارة مرجعية إلى رقم صفحة لا رقم كائن، والوجهات المسماة وأهداف الروابط محلولة بالطريقة نفسها، وقيم حقول النماذج، وبايتات المرفقات كبصمات، وحزمة XMP محللة كشجرة لا مقارنة كنص. أرقام الكائنات ليست جزءاً منها عمداً، لأن إعادة الكتابة الكاملة تعيد ترقيم كل شيء والمقارنة المفتاح عليها ستبلّغ ضجيجاً

تحقق دلالي غير بصري لحفظ PDFlibPas: محلل مستقل بلا كود مشترك يلتقط لقطة لقاموس Info مطروحاً منه /ModDate، وصفحات المخطط والوجهات، وقيم النماذج، وبصمات المرفقات، وشجرة XMP، ثم يقارن المصدر بالملف المحفوظ موضعاً /ModDate و xmp:ModifyDate و xmp:MetadataDate تغييرات متوقعة
تبقى البكسلات وبصمات التدفقات متطابقة بايتاً ببايت عبر العيبين معاً، فتشتغل المقارنة على الدلالات المحلولة لا على أرقام الكائنات، وتُبلَّغ البيانات الوصفية الناجية بصدق كمحفوظة لا كصالحة المخطط أو مطابقة PDF/UA و PDF/A

الاستبعادات لا تقل أهمية عن الإدخالات. يُتوقع أن تتغير /ModDate و xmp:ModifyDate و xmp:MetadataDate فيُسقطن قبل المقارنة؛ والملف الذي جاء مصدره بلا XMP إطلاقاً لا يعاقب على كسب حزمة. وما لا تدّعيه المقارنة واضح بالقدر نفسه: احتفاظ حزمة قائمة لا يقول شيئاً عن كونها صالحة المخطط أو عن مطابقة المستند لـ PDF/UA أو أي جزء من PDF/A. تلك أسئلة منفصلة بأدوات منفصلة، والخلط بين "البيانات الوصفية نجت" و"البيانات الوصفية مطابقة" هو ما أخفى العيب الأول المدّ الذي أخفاه. وعلى جهة المكتبة يجري اختبارا الانحدار الآن في كل جولة موجّهة عبر Delphi Win32 و Win64 و Free Pascal Win32 و Win64، والمقارنة الدلالية شرط نجاح لاختبار معيار مدونة المستندات الحقيقية

إن كنت تعمل تحت مستوى هذين الإصلاحين، فميكانيكا كيفية إعادة حفظ لاستدعاء الكائنات مغطاة في التحديثات التزايدية والحفظ الإضافي فقط، وهو وضع الحفظ الوحيد الذي يُترك فيه كائن مشترك حيث كان، وفي مستويات التعديل وفرق المراجعات، وهو الموضع الآخر الذي يضلل فيه تاريخ قديم أو معاد كتابته قارئاً. أما وجه الإصلاح لزوج Info و XMP نفسه، حيث يُجعل النصفان يتفقان لا أن يُحفظا فحسب، فهو في التحويل إلى PDF/A وإصلاح البيانات الوصفية

PDF Library for Delphi مكتبة PDF بلغة Pascal أصلية لـ Delphi و C++Builder و Lazarus، ومسار القراءة-التعديل-الكتابة الموصوف هنا هو نفسه الذي تمر به كل عملية تحرير في عمليتك أنت، فتسري الضمانات أعلاه سواء حفظت مرة واحدة أو ألف مرة في اليوم — انظر صفحة منتج PDF Library for Delphi للمترجمات والمنصات المدعومة