يجعل مكوّن PDFium خرج PDF القابل للتكرار استدعاءً واحداً: يعيد TPdf.SaveAsReproducible كتابة المستند بحيث يعطي حفظ المحتوى نفسه مرتَين ملفات متطابقة بتّياً. يزيل /CreationDate و /ModDate من قاموس معلومات المستند، ولا يكتب مقطعاً ختامياً بـ /ID، ويحفظ ترتيب الكائنات مستقراً، ويبلّغ عمّا غيّره في TPdfReproducibleReport. والملفات المشفرة تُرفض، والموقَّعة تحتاج موافقة صريحة
الحاجة تظهر عادةً في خط بناء. أحدهم يودع ملفات PDF المولَّدة في مستودع أو في طقم اختبارات ذي ملفات ذهبية، وينتج كل تشغيل فروقاً رغم أن لا شيء على أي صفحة تغيّر. أو يزيل أرشيف التكرار بواسطة SHA-256 ويخزن بهدوء خمسين نسخة من الفاتورة نفسها، لأن كل حفظ ختم زمناً جديداً في الملف. كاتب PDF يضمّن ساعة الحائط مناسب لتطبيق مكتبي ومعادٍ لأي شيء يعامل البايتات بوصفها هوية
لماذا يعطي حفظ الملف نفسه مرتَين بايتات مختلفة؟
يختلف حفظَان لمستند لم يتغير بسبب نوعَي بيانات بالضبط: الأزمنة في قاموس معلومات المستند ومعرف الملف في المقطع الختامي. كل شيء آخر يُصدِره كاتب حريص، من ترقيم الكائنات وترتيبها إلى تخطيط المراجع المتقاطعة، يمكن جعله حتمياً، ومسار إعادة الكتابة الكاملة في مكوّن PDFium يفعل ذلك أصلاً: تُكتب الكائنات برقم كائن تصاعدي ويتبع جدول xref الترتيب نفسه
الأزمنة هي المشتبه البديهي. تعرّف ISO 32000-1 §14.3.3 قيمَي /CreationDate و /ModDate في قاموس /Info، ويكتب حفظ PDFium الأصلي /ModDate طازجةً في كل مرة يُسلسل فيها المستند. ومعرف الملف هو الدقيق. يصف ISO 32000-1 §14.4 قيمة /ID بوصفها مصفوفة من عنصرَين من سلاسل بايت، يعرّف الأول الملف الأصلي ويتغير الثاني كلما نُقحت النسخة. يشتق الكتّاب عادةً تلك السلاسل بهاش شيء ما في الملف، وإذا كان ذلك الشيء يتضمن البايتات المؤرَّخة، ورث المعرف عدم الحتمية
استخدام TPdf.SaveAsReproducible في Delphi
لدى TPdf.SaveAsReproducible تحميلان زائدان، أحدهما يكتب إلى TStream والآخر إلى اسم ملف، ويعيد كلاهما قيمة Boolean مع out Report: TPdfReproducibleReport ومعامل اختياري AllowSignedDocument افتراضه False. تعكس القيمة المعادة Report.Succeeded؛ وعندما تكون False يخبرك Report.ErrorMessage بالسبب. يكتب التحميل الملفي إلى ملف مؤقت بجوار الهدف ولا يستبدل الهدف إلا بعد حفظ ناجح، فلا يترك تشغيل فاشل نصف ملف PDF مكتوب حيث كان خرج بنائك السابق
var
Pdf: TPdf;
Report: TPdfReproducibleReport;
begin
Pdf:= TPdf.Create(nil);
try
Pdf.FileName:= 'invoice-2026-10.pdf';
Pdf.Active:= True;
if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
'%d timestamps stripped', [Report.SourceByteCount,
Report.OutputByteCount, Report.KeptObjectCount,
Report.PrunedObjectCount, Report.StrippedTimestampCount]))
else
Writeln('Reproducible save failed: ', Report.ErrorMessage);
finally
Pdf.Free;
end;
end;
حقول التقرير تستحق التسجيل في CI. الـ StrippedTimestampCount يساوي عادةً 2 لمستند يحمل /Info كلا التاريخَين، و0 لواحد لا يمتلك قاموس /Info إطلاقاً. ويحصي PrunedObjectCount الكائنات التي كانت حاضرة في الحفظ الأصلي لكنها غير قابلة للوصول من الفهرس أو من قاموس /Info؛ تُسقطها إعادة الكتابة القابلة للتكرار، فتوقع أن يكون الإخراج بحجم الحفظ العادي أو أصغر
لماذا لم تكفِ إزالة الأزمنة
أزالت أول تنفيذ كلا الزمنَين وما يزال ينتج ملفين مختلفين. تطابقت الصفحات، وتطابق قاموس /Info، واختلف المقطعان الختاميان في العنصر الثاني من /ID. كان السبب الجذري هو الترتيب: يطلب الفصل أولاً من PDFium حفظاً أصلياً، ثم يعالج تلك البايتات لاحقاً، وكان بانيا المقطع الختامي يحسب /ID بهاش الخرج الأصلي. وكان ذلك الخرج الأصلي ما يزال يحمل /ModDate الطازجة، فتغير الهاش، وبالتالي المعرف، في كل تشغيل مع أن الزمن نفسه لم يصل قط إلى الملف النهائي
وهو مثال جيد لقاعدة عامة للبناءات القابلة للتكرار: إزالة قيمة غير حتمية لا تكفي إذا كان أي شيء اشتُق منها أولاً. كان الإصلاح هو التوقف عن حمل /ID كلياً. يكتب TPdf.SaveAsReproducible مقطعاً ختامياً يحوي /Size و /Root فقط، و /Info عندما يملكه المصدر. ولم يبق في المقطع الختامي شيء معتمد على الزمن، يقارن حفظان متتاليان للمستند نفسه متساويَين بايتاً-ببايت، وهذا بالضبط كيف يُتحقق من السلوك
function SavesAreIdentical(Pdf: TPdf): Boolean;
var
A, B: TMemoryStream;
RA, RB: TPdfReproducibleReport;
begin
A:= TMemoryStream.Create;
B:= TMemoryStream.Create;
try
Result:= Pdf.SaveAsReproducible(A, RA) and
Pdf.SaveAsReproducible(B, RB) and
(A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
finally
B.Free;
A.Free;
end;
end;
إزالة مفاتيح القاموس دون إفساد السلاسل
تُزال الأزمنة بمسح يدرك الرموز لجسم كائن /Info، لا بالبحث النصي. الاستبدال البايتي الساذج لقيمة /ModDate كان سيصيب أيضاً سلسلة /Subject أو /Keywords تصادف أن تحوي ذلك النص، وبتر بايتات من سلسلة حرفية يفسد البيانات الوصفية بصمت بدلاً من أن يفشل بصوت عال. يحيّد مكوّن PDFium أولاً السلاسل الحرفية والتعليقات في نسخة مسح من الجسم، ثم لا يقبل تطابقاً إلا حيث يبدأ رمز اسم حقيقي، ويستخدم قواعد كائن PDF لإيجاد موضع نهاية قيمة ذلك المفتاح، ويتخطى زوج المفتاح-القيمة كله
والنتيجة العملية أن كل مدخل آخر في /Info، من عنوان ومؤلف ومنتج ومفاتيح مخصصة، ينجو دون مساس. يُزالان المفتاحان المعرفان بوصفهما تاريخَين فقط، ومن القاموس الذي يشير إليه فعلاً مدخل /Info في المقطع الختامي فقط
ماذا يكلّف حذف /ID للملفات المشفرة والموقعة؟
حذف /ID آمن لخرج PDF 1.x عادي، لكنه يستبعد التشفير ويغيّر ما يحدث للتوقيعات. في ISO 32000-1 يكون المقطع الختامي /ID اختيارياً ما لم يكن مدخل /Encrypt حاضراً، لأن معالج الأمان القياسي يمزج سلسلة المعرف الأولى في مفتاح التشفير. ملف بلا /ID لا يمكن أن يكون ملفاً مشفراً بشكل صحيح، فلا يحاول TPdf.SaveAsReproducible ذلك: عندما يحمل المقطع الختامي /Encrypt يعيد False ويقول Report.ErrorMessage إن المستند مشفر. فك التشفير أولاً إذا سمح خط أنابيبك، أو أبقِ المخرجات المشفرة خارج فحوصات التطابق البتّي. وإذا كنت تستهدف PDF 2.0، فلاحظ أن ISO 32000-2 يشدّد القاعدة ويشترط /ID في كل ملف، فيلائم الخرج القابل للتكرار أهداف PDF 1.x بأكثر الطرق طبيعية؛ وتتناول مقالة مطابقة نسخة PDF الدقيقة في Delphi كيفية تعامل المكوّن مع أهداف النسخ
المستندات الموقَّعة مشكلة أخرى. TPdf.SaveAsReproducible إعادة كتابة كاملة غير تزايدية، فتتحرك إزاحات البايت ولم يعد /ByteRange للتواقيع الموجودة يغطي البايتات التي وُقّعت. فالخرج القابل للتكرار لملف موقَّع قرار متعمد بالتخلي عن صلاحية التوقيعات الموجودة مقابل بايتات مستقرة، ولهذا يرفع التابع EPdfError عندما تكون SignatureCount أكبر من صفر ما لم تمرر AllowSignedDocument = True
if Pdf.SignatureCount > 0 then
// موافقة صريحة: إعادة الكتابة تحرك إزاحات البايت، فالتوقيعات
// الموجودة ينبغي توقع أن تفشل في التحقق بعد ذلك
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);
وإذا كان الأصل الموقَّع هو سجل الحقيقة، فأبقه كما هو واستخدم الخرج القابل للتكرار فقط للنسخ المشتقة، كعرض مطبَّع تقارنه في CI. ولالحق ملف محمي دون إزعاج ما هو فيه أصلاً، انظر لماذا لا يمكن ترقيع ملف PDF مشفر بصمت في Delphi
حدود يجب معرفتها قبل الاعتماد على التطابق البتّي
ينطبق التطابق البتّي على المستند نفسه محفوظاً ببنية المكوّن نفسها، ويغطي بالضبط ما يلمسه التابع. يزيل TPdf.SaveAsReproducible تاريخَي /Info فقط؛ وتدفق بيانات وصفية XMP له xmp:CreateDate أو xmp:ModifyDate الخاص به يُحمل عبره بوصفه البايتات التي يحملها المستند أصلاً. ذلك مستقر عبر الحفظات، لكنه يعني أن تاريخَي XMP و /Info لم يعودا يصفان الشيء نفسه، وهذا مهم لملفات تعريف PDF/A التي تشترط اتفاقهما؛ وتشرح مقالة تكافؤ بيانات Info و XMP الوصفية في PDF/A تلك القواعد. كما قد يُسلسل ثنائي PDFium مختلف أو إصدار مكوّن مختلف الكائنات بشكل مختلف، فثبّت كليهما في أي خط أنابيب يقارن الهاشات عبر الأجهزة
وفي حدود تلك القيود، يعطيك التابع ما تحتاجه فروق CI واختبارات الملفات الذهبية والأرشيفات الموجهة بالمحتوى: الإدخال نفسه، البايتات نفسها. يأتي TPdf.SaveAsReproducible و TPdfReproducibleReport ضمن مكوّن PDFium لـ Delphi و C++Builder، إلى جانب بقية واجهته للحفظ والتحسين والمطابقة
الأسئلة الشائعة
هل يغيّر TPdf.SaveAsReproducible محتوى الصفحات؟
لا. يزيل مفتاحي /CreationDate و /ModDate من قاموس /Info، ويحذف المقطع الختامي /ID، ويُسقط الكائنات التي لا يمكن الوصول إليها من الفهرس أو /Info؛ ويُكتب محتوى الصفحات دون تغيير
هل يمكنني إنتاج خرج قابل للتكرار من ملف PDF مشفر؟
لا. يعتمد التشفير على المقطع الختامي /ID الذي يحذفه الخرج القابل للتكرار، فيعيد التابع False مع ErrorMessage تفسيرية للمصادر المشفرة
لماذا يحتاج ملف PDF موقَّع إلى AllowSignedDocument؟
الحفظ القابل للتكرار إعادة كتابة كاملة تحرك إزاحات البايت، فتتوقف التوقيعات الموجودة عن مطابقة نطاقات البايت الموقَّعة. تمرير AllowSignedDocument = True يؤكد أنك تقبل تلك المقايضة