مقال تقني

مخرج PDF قابل لإعادة الإنتاج: حفظ متطابق بايتاً ببايت

ينتج HotPDF Delphi Component مخرج PDF متطابقاً بايتاً ببايت عبر الحفظات عندما تكون خاصية ReproducibleOutput بالقيمة True: يثبّت Info /CreationDate و /ModDate على تاريخ ثابت، ويستبدل معرف المستند المأخوذ من ساعة الحائط ببصمة مبذورة أو مشتقة من المحتوى، ويحل ثوابت محل كل بايت عشوائي كانت مسارات تشفير AES سترسمه، ويرتب كل قاموس يصوغه. العَلَم موجود لحزم اختبارات الانحدار ومقارنة مخلفات البناء، لا للمستندات الإنتاجية، وأسباب ذلك الحد هي الجزء المثير. والسيناريو الذي يقود الميزة اختبار ملف ذهبي. تعرض فاتورة، وتودع الـ PDF، وتؤكد أن بناء الغد سينتج البايتات نفسها. لن يفعل أبداً. الملف يفتح حسناً في كل عارض، والنص متطابق، وشجرة الصفحات متطابقة، ويضيء الفرق مع ذلك في أربعة أو خمسة مواضع. كل من حاول وضع مولد PDF تحت اختبار انحدار على مستوى البايت قد اصطدم بهذه الجدار، والإصلاح ليس "جرّد الختمات الزمنية" بل محاسبة دقيقة لكل موضع يستشير فيه الكاتب شيئاً غير المستند نفسه

لماذا يختلف حفظان لنفس الـ PDF؟

يختلف حفظان لنفس المستند لأن كاتب PDF، و HotPDF منهم، يستشير أربعة مصادر إنتروبيا لا علاقة لها بمحتوى الصفحات: ساعة الحائط، ومعرف المستند، ومولّد الأعداد العشوائية التشفيري، وترتيب ذاكرة إدخالات القاموس. كل واحد منها مشروع بمعزل عن الآخر، و 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، فيختلف الحفظان في الثانية التي كُتبا فيها
  • المعرف. تحمل مصفوفة /ID الختامية (ISO 32000-1 §14.4) معرفاً دائماً ومعرف تعديل. ووصفة HotPDF الافتراضية تُبخّم اسم الملف مع الوقت الحالي حتى الميلي ثانية للعنصر الأول، وتُبخّم ذلك زائد GetTickCount للثاني. معرفان، قيمتان جديدتان في كل جولة
  • البايتات العشوائية. أمان المعيار يتوقف على المعرف وعلى عشوائية حقيقية. عند AES-256 يُرسم مفتاح تشفير الملف وأملاحا التحقق والمفتاح وكل متجه تهيئة CBC من مصدر العشوائية النظامي (يشترط ISO 32000-2 §7.6.4.4.7 أملاحاً عشوائية). ولأن /U و /UE و /O و /OE كلها تحسب من تلك البايتات، يتغير المستند المشفر في كليته حتى عندما لا يتغير النص الصريح. وتبنى الخوارزميات الأقدم عنصر /ID الأول في المفتاح (ISO 32000-1 §7.6.3.3، §7.6.3.4)، فيكفي معرف جديد وحده لإعادة تمديد مفتاح الملف
  • الترتيب. قاموس PDF ربط بلا ترتيب، والكاتب الذي يجول قائمته في الذاكرة يصدر المفاتيح بترتيب الإدراج. أي مسار كود يبني قاموس موارد بتتابع مختلف، أو مستند محمّل حُلل من تخطيط مختلف، ينتج ملفاً مشروعاً لكنه مختلف نصياً
مصادر الإنتروبيا الأربعة التي تجعل حفظي HotPDF لمستند واحد مختلفين: يغذي FCreationDate المختوم من Now التواريخ D: وحزمة XMP، وتُبخّم /ID الختامية اسم الملف والساعة و GetTickCount، وترسم AES مادة المفاتيح من مصدر العشوائية النظامي، وتُصاغ القواميس بترتيب الإدراج في الذاكرة
كل مصدر مشروع بمعزل عن الآخر و ISO 32000-1 يريده هناك، ومع ذلك تحوّلها جميعاً الملف إلى دالة في متى وأين كُتب لا فيما يحوي

ماذا يثبّت ReproducibleOutput؟

ضبط ReproducibleOutput := True قبل BeginDoc أو قبل SaveLoadedDocument يستبدل كل واحد من المصادر الأربعة بقيمة ثابتة، ويفعل ذلك في مسارات الكود نفسها التي كانت ستطاول الساعة أو المولّد العشوائي، فلا حاجة لجولة تنظيف منفصلة. لاحظ ما هو غائب عن القائمة أعلاه: المحتوى. الخطوط وتدفقات الصفحات وبيانات الصور وجدول المرجع المتقاطع حتمية أصلاً لنفس المدخل؛ الضجيج يسكن كلياً في البيانات الوصفية وطبقة الأمان، ولهذا تستطيع خاصية موجّهة واحدة إزالته. الخاصية تأتي افتراضياً بـ 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 التابع ComputeCanonicalDocumentIdentifier عن معرف المقطع الختامي: يصدّر رسم الكائنات كله بترتيب قياسي، ويصفّر أرقام أي سلسلة تاريخ D: يجدها حتى لا تتسرب الختمات الزمنية عائدة عبر البصمة، ويأخذ MD5 للنتيجة. ويتلقى عنصرا /ID كلاهما تلك القيمة. ويُستخدم المعرف المشتق من المحتوى نفسه حين يُشفَّر مستند محمّل دون أن يمر بـ BeginDoc قط، وهو حالة ActivateProtection على ملف فتحته بـ LoadFromFile

البايتات العشوائية هي الاستبدال الأقل وضوحاً. تلف روتينة مفتاح AES-256 مصدر عشوائيتها في مساعد محلي، تحت العَلَم، يستدعي FillChar(P^, Count, $5A) لمفتاح تشفير الملف ذي الـ 32 بايت ولكل ملح من 8 بايتات، وتنتقل المشفّرات النصية والتدفقية AES-128 و AES-256 من AESGenerateRandomIV إلى AESGenerateStaticIV، التي تملأ متجه التهيئة بـ 14 * (1 + I) للخانة I. ومع ثبات المفتاح والأملاح والمتجهات جميعاً يخرج /U و /UE و /O و /OE وكل تدفق مشفر متطابقاً في الجولة الثانية. وأخيراً يشغّل SaveToStream التابع DeterministicDictionaryOrder كلما ضُبط العَلَم القابل لإعادة الإنتاج، فيفرز المُصوِّغ حينها كل قاموس إدراجياً بالبايتات الخام لأسماء مفاتيحه، البادئة الأقصر أولاً، وبالفهرس الأصلي لكسر التعادل. ذلك الترتيب نفسه الذي يستخدمه الكاتب التشخيصي، الموصوف في مقالة تحرير PDF يدوياً وإصلاحه بعدها؛ ويستعير العَلَم القابل لإعادة الإنتاج الترتيب وحده، لا بقية تخطيط النص الصريح لذلك الكاتب

ماذا يثبّت ReproducibleOutput في HotPDF: يصير تاريخ الإنشاء EncodeDate 2026، 1، 1، ويأتي معرف المقطع الختامي من ComputeCanonicalDocumentIdentifier على الرسم القياسي مع تصفير أرقام D:، وتمتلئ مفاتيح وأملاح AES ببايتات $5A وتمتلئ كل خانة بـ AESGenerateStaticIV، ويرتب DeterministicDictionaryOrder كل قاموس
تجري الاستبدالات في مسارات الكود نفسها التي كانت ستطاول الساعة أو المولّد العشوائي، فلا حاجة لجولة تنظيف منفصلة ويتلقى عنصرا /ID كلاهما القيمة المشتقة من المحتوى نفسها

لماذا سرّب التاريخ الثابت ساعة الحائط مع ذلك؟

إصلاح v2.752.2 موجود لأن تاريخ الإنشاء الثابت كان يُحسم أصلاً في الباني، والباني لا يستطيع معرفة خاصية لم يضبطها المستدعي بعد. تتابع الاستدعاء المعتاد هو Create، ثم ReproducibleOutput := True، ثم BeginDoc. وقت البناء ما زالت FReproducibleOutput بالقيمة False، فتلقّت FCreationDate القيمة Now وبقيت عليها. وكان المعرف والبايتات العشوائية مثبّتَين صحيحين، فاتفقا الملفان في كل مكان تقريباً واختلفا في سلسلتَي تاريخ بالضبط وحقلَي XMP اثنين. نقل الإسناد إلى الفرع القابل لإعادة الإنتاج في BeginDoc، بجانب المعرف المبذور، وضع القرار عند النقطة التي تكون للخاصية فيها قيمتها النهائية

اختبار الانحدار الذي فوّت هذا أثمن من الإصلاح. حفظان يجريان كلاهما داخل ثانية ساعة الحائط نفسها يكتبان سلسلة D: واحدة بالصدفة، وتنجح مقارنة البايتات لعيب يفشل على أي آلة أبطأ. الاختبار المصحح ينام 1100 ms بين الحفظين حتى يضمن عبور ختم PDF الزمني حداً ثانية، ويجري الحالة لمخرج عادي و AES-128 و AES-256 بكلمات مرور حقيقية على النسختين المشفرتين، ويقارن المخزنين بـ CompareMem، مبلّغاً عن أول إزاحة مختلفة عند الفشل حتى يشير الفرق إلى كائن بعينه لا إلى ملف كامل. مقارنة البايتات تثبت الحتمية ولا تثبت غيرها، فأبقِ تأكيداً منفصلاً يعيد تحميل المخرج المشفر بكلمة مرور المستخدم ويقرأ عدد صفحات؛ فتغيير يجعل الملف مستقراً وغير مقروء في آن واحد يجب ألا يتسرب بقوة فرق أخضر

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 مختلفة
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 هو اثنان وثلاثون بايتاً من $5A، والأملاح ثمانية بايتات من $5A، ومتجهات التهيئة تتبع نمطاً حسابياً منشوراً. وما زالت كلمة المرور تسد بوابة غلافي /UE و /OE، لكن المفتاح الملفوف ثابت، فأي من يعرف الثابت يستطيع فك تشفير كل تدفق محتوى دون كلمة مرور إطلاقاً. وثبات الأملاح يزيل أيضاً التفرّد لكل مستند الذي يعتمد عليه ISO 32000-2 §7.6.4.4.7 كي لا تسفر كلمات المرور المتطابقة عن سلاسل /U متطابقة عبر الملفات. اقرأ مقالة إعداد AES-256 عما تَعِد به خصائص التشفير حين يكون مصدر العشوائية سليماً؛ تحت العَلَم القابل لإعادة الإنتاج تُعلّق تلك الوعود

والمفاضلة في المعرف ألطف. يقصد ISO 32000-1 §14.4 أن يتغير عنصر /ID الثاني عند كل تعديل حتى تفرق الأدوات بين ملف محدّث وسلفه، والحفظ القابل لإعادة الإنتاج يكتب القيمة نفسها في الخانتين. ولأن تلك القيمة بصمة للرسم الكائني القياسي، يحصل مستندان بمحتوى مختلف على معرفين مختلفين مع ذلك، وهو أفضل من ثابت. لكن البذرة التي يستخدمها BeginDoc لاشتقاق المفتاح هي السلسلة نفسها لكل مستند على كل آلة، والقارئ الذي يفترك على /ID لتمييز الملفات، كذاكرة تعليقات مخزنة أو ملف جانبي لبيانات نماذج، سيخلط بين كل ملف قابل لإعادة الإنتاج صادف أن بصمته تطابقت

ماذا لا يغطيه العَلَم؟

يزيل ReproducibleOutput الإنتروبيا التي يُدخلها الكاتب من تلقاء نفسه؛ ولا يستطيع إزالة إنتروبيا تدخل عبر البيئة أو عبر مسارات كود لا يتحكم فيها، وثلاثة منها سهلة التعثر

  • لاحقة المنطقة الزمنية. تلحق _DateTimeToPdfDate إزاحة UTC المحلية، فسلسلة D:20260101000000+08'00' على عامل بناء وسلسلة D:20260101000000-05'00' على آخر بايتات مختلفة للتاريخ الثابت نفسه. القابلية لإعادة الإنتاج تصح عبر الجولات على آلة واحدة، أو عبر آلات تتشارك منطقة زمنية؛ ثبّت منطقة العامل إن كانت ملفاتك الذهبية مسافرة
  • التحديثات التزايدية. يحسب SaveIncrementalUpdate معرف تعديله من مسار الهدف و GetTickCount والوقت الحالي بلا فرع قابل لإعادة الإنتاج، لأن القسم التزايدي هو بتعريفه تعديل جديد. قارن إعادة الكتابة الكاملة لا الدلتا الملحقة
  • الاختصار العابر. ينقل SaveLoadedDocument في المعتاد ملف مصدر غير معدل وغير مشفر بايتاً ببايت بدل إعادة صواغته. يعطّل العَلَم القابل لإعادة الإنتاج ذلك الاختصار ويفرض إعادة كتابة كاملة حتى تسري قواعد الترتيب والمعرف، مما يعني أن الحفظ القابل لإعادة الإنتاج لملف محمّل أبطأ من الافتراضي وليس نسخة من المدخل قط. قارنه بحفظ سابق قابل لإعادة الإنتاج، أبداً مع الأصل
أين تتوقف حفظات HotPDF القابلة لإعادة الإنتاج: ما زالت _DateTimeToPdfDate تلحق إزاحة UTC المحلية فتختلف الملفات الذهبية عبر المناطق الزمنية، ولا فرع قابل لإعادة الإنتاج لـ SaveIncrementalUpdate لأن الدلتا تعديل جديد، ويُعطَّل الاختصار العابر فيُعاد كتابة الملف المحمّل كاملاً دائماً
القابلية لإعادة الإنتاج تصح عبر الجولات على آلة واحدة أو عبر آلات تتشارك منطقة زمنية، ويجب مقارنة الحفظ القابل لإعادة الإنتاج بحفظ سابق من عائلته لا أبداً بالمدخل الأصلي

درس إضافي من الإصدار نفسه، عما يثبته فحص ناجح وما لا يثبته. عينة اختبار PDF/X-6 استدعت CharProcs.DeleteValue('A')، التي حررت تدفق محارف محمولاً مباشرة، ثم أعادت إدراج الإشارة نفسها، وسلّمت على حدة كائن ExtGState مباشراً واحداً لقاموس موارد ولنمط معاً. فاجتاز محقق المطابقة على نحو متقطع ذلك الاستخدام بعد التحرير والملكية المزدوجة لأنه كان يقرأ ما صادف أن الذاكرة المحررة تحمله. حين يرتعش فحص بنيوي، انظر إلى ملكية مدخل الاختبار قبل أن تنظر إلى المحقق. المخرج القابل لإعادة الإنتاج يجعل ذلك الانضباط أرخص: متى صار حفظان متطابقين بايتاً ببايت، لم يبق لمصدر الرعشة إلا رسم الكائنات ذاته، و فرق بنيوي من الكتالوج نزولاً سيجده

خاصيتا ReproducibleOutput و DeterministicDictionaryOrder وخصائص التشفير الموصوفة هنا تشحن في HotPDF Delphi Component القياسي لـ Delphi و C++Builder، ويقود العَلَم نفسه مدونة انحدار المكتبة الخاصة، فالسلوك الذي تحصل عليه في حزمة اختبارات هو السلوك الذي اختُبر به المكوّن