عندما يحمل HotPDF Delphi Component ملف PDF 1.5 بـ LoadFromFile لا يحلل الكائنات المعبأة داخل حاويات /Type /ObjStm. بل يسجل موضع كل عضو مضغوط ويحلله فقط عندما يطلبه شيء ما. تلك الثابتة الكسولة هي ما يُبقي زمن التحميل متناسباً مع ما تلمسه فعلاً، وهي أيضاً سبب اضطرار إعادة الكتابة الكاملة إلى عمل إضافي واحد قبل خروج أي بايتات: توسيع كل عضو ما زال غير محلل، لأن إعادة الكتابة على وشك رمي الحاويات التي يسكنها أولئك الأعضاء
العَرَض الذي حفّز هذه الملاحظة سهل الوصف بغيض التتبع. حمّل ملفاً خطوطه وفضاءات ألوانه وشجرته البنيوية تسكن تدفقات كائنات، ومرره عبر زوج التوليد BeginDoc و EndDoc، فيفتح المخرج بلا ذنب. عدد الصفحات صحيح، والنص مرئي على الصفحات التي ترمي عليها نظرة. ثم يفتح زميلك الصفحة 40 فيرسم متن النص بخط بديل، أو يعيد أمر Extract Text خردة حيث كانت بديلة ActualText. لم ينهار شيء. الكاتب ببساطة صاغ كائناً لم يُحمَّل قط، والكائن غير المحمّل يُصاغ كلاشيء
ماذا يبقي LoadFromFile فعلاً عن كائن مضغوط؟
لكل إدخال مرجعي متقاطع من النوع 2 يبقي LoadFromFile سجلاً صغيراً في FCompactObjects: رقم الكائن، ومؤشر التدفق الحاوي في جدول الحاويات، وموضع العضو داخل ذلك التدفق، وإشارة ParsedObject تبدأ nil. الحاوية نفسها تُعين وتفك تشفيرها إن كان المستند مشفراً وتنفخ، لكن أجسام الأعضاء تُترك بايتات. يعرّف ISO 32000-1 §7.5.7 تخطيط الحاوية الذي يجعل هذا ممكناً: ترويسة من أزواج رقم كائن وإزاحة، ثم أجسام الأعضاء متماسكة بعد /First، فيمكن تقطيع أي عضو منفرد دون لمس جيرانه
EnsureCompressedObjectLoaded هي المسار الوحيد الذي يحوّل سجلاً إلى كائن. تجد السجل برقم الكائن، وإن كانت ParsedObject معينة أصلاً أعادت الكائن المخزن وعّدت إصابة ذاكرة تخزين مؤقت. وإلا أعادت تحميل الحاوية إن كانت قد أُخرجت، وحسبت مدى بايتات العضو من جدول الإزاحات، وسلّمت المحلل منظر قصّ بلا نسخ لتلك الشريحة، وخزّنت النتيجة في السجل. ومن عندها يصير الكائن غير مباشراً، ويحمل رقم كائنه الحقيقي، ويسجل في فهرس كائنات المستند مثل أي كائن حُلل من جسم الملف. الكتالوج وقاموس المعلومات وجذر شجرة الصفحات وكائنات الصفحات يمررون بهذا المسار وقت التحميل لأن التنقل يحتاجهم. الخطوط وفضاءات الألوان وقواميس ExtGState وعناصر البنية لا، فتبقى سجلات حتى تلمسها رندرة صفحة أو إعادة كتابة
تستطيع رؤية ذلك من الخارج. يبلّغ GetLoadedObjectStreamCacheInfo كم حاوية موجودة، وكم عضواً فُهرس، وكم من أولئك حُلل حتى الآن:
var
Pdf: THotPDF;
Info: THPDFObjectStreamCacheInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('tagged-report.pdf');
if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
Writeln(Format('%d containers, %d members indexed, %d parsed so far',
[Info.ContainerCount, Info.IndexedObjectCount,
Info.MaterializedObjectCount]));
finally
Pdf.Free;
end;
end;
على ملف ثقيل البنية يكون الرقم الثالث كسراً صغيراً من الثاني مباشرة بعد التحميل. تلك الفجوة هي جوهر التحميل الكسول كله، وهي بالضبط مجموعة الكائنات التي يجب أن تعود إليها إعادة الكتابة الكاملة
لماذا تسقط إعادة كتابة كاملة خطوطاً يبقيها حفظ تزايدي؟
إعادة الكتابة الكاملة ترمي حاويتَي /ObjStm و /XRef لملف المصدر وتعيد صواغة رسم الكائنات من الصفر، فأي عضو ParsedObject ما زال nil لا يبقى له تمثيل في المخرج. أما التحديث التزايدي فلا يعرف هذه المشكلة أبداً، لأنه يلحق كائنات جديدة بعد البايتات الأصلية ويترك الحاويات القديمة في موضعها ليخاطبها قسم المرجع المتقاطع السابق. الفرق ليس في كيفية معاملة الوضعين للخطوط. الفرق في هل تنجو الحاويات الأصلية ليقرأها العارض التالي
يقع الإصلاح في SaveToStream، المُصوِّغ الذي يقوده EndDoc سواء ضبطت FileName أو OutputStream. قبل أن يوجّه إلى أي فرع كاتب، يجول FCompactObjects ويستدعي EnsureCompressedObjectLoaded على كل إدخال. وإن تعذر تحميل عضو رفع الحفظ استثناءً بدل المتابعة، لأن إعادة كتابة تسقط قاموس خط بصمت أسوأ من واحدة تتوقف. لا بد أن يجلس التوسيع عند ذلك المستوى، فوق فروع classic و packed و linearized، وفوق تقليم مسار linearized للتدفقات البنيوية المعاد تحميلها. إصدار سابق وسّع الأعضاء داخل SaveLoadedDocument وحدها، التي غطت مفردات المستند المحمّل وأغفلت مفردات التوليد كلياً. LoadFromFile يليها BeginDoc وتحريرات صفحات و EndDoc ذهبت مباشرة إلى الكاتب وكل عضو لم يُلمس ما زال غير محلل
// كلا مفردتَي إعادة الكتابة يوسعان الآن الأعضاء المضغوطين قبل أن يجري أي كاتب.
// مسار المستند المحمّل:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// مسار التوليد على ملف محمّل:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc; // يجسّد SaveToStream كل إدخال FCompactObjects أولاً
الأعضاء المخزنون يبقون ما فعلت بهم. كائن حُلل وعُدّل ومُيّز متسخاً قبل الحفظ يعاد من الذاكرة المؤقتة بتحريراته، وعضو حذفته يبقي حالة حذفه عبر الحفظات المتكررة. جولة التوسيع محافظة على النتيجة بالبناء: إنها لا تملأ إلا خانات nil
لماذا تفحوص البكسل على ثلاث صفحات تفوت حالة ActualText
عناصر البنية هي حيث يختبئ هذا العيب أطول مدة. إدخال ActualText على تتابع محتوى موسوم، المعرف في ISO 32000-1 §14.9.4، يستبدل المحارف للاستخراج والوصولية لكنه لا يؤثر في الرندرة. إن سكن عنصر البنية تدفق كائنات وخسرته إعادة الكتابة، ما زالت الصفحة ترسم صحيحة، وتقارن الصفحات الأولى والوسطى والأخيرة بكسلاً ببكسل مع المصدر، ولا يظهر الانحدار إلا حين يشغّل أحد استخراج النص أو قارئ شاشة. اختبار إعادة كتابة لا يعرض صفحات فقط ليس اختبار إعادة كتابة لـ PDF موسوم. قارن كذلك النص المستخرج وشجرة البنية
كيف يغيّر كلمة مرور المستخدم الفارغة التحميل؟
كلمة مرور مستخدم فارغة ما زالت تعني أن الملف مشفر، وتدفقات الكائنات في مثل ذلك الملف نص مشفر حتى يُستعاد مفتاح الملف. يشتق ISO 32000-1 §7.6.3.4 الخوارزمية 2 ذلك المفتاح من كلمة المرور وإدخال /O و /P وأول معرف مستند، وعلى HotPDF أن يجريها مقابل السلسلة الفارغة قبل أن تنفخ جولة النوع 2 حاوية واحدة. لهذا يستدعي BeginDoc على مستند مشفر محمّل التابع DecryptLoadedDocument بكلمة مرور فارغة قبل أي شيء آخر: يجب توثيق رسم الكائنات وفك تشفيره قبل أن تبدأ إعادة كتابة، أياً كان قصد المستدعي في حماية المخرج. تشفير المخرج قرار منفصل تقوده إعدادات الحماية للمستدعي، ويعيد BeginDoc تلك الإعدادات بعد جولة فك التشفير حتى لا يتحول مدخل مشفر بصمت إلى مخرج مشفر
تُقرأ سياسة الحاويات من قاموس /Encrypt قبل تجربة أي كلمة مرور. عند /V بقيمة 1 و2 كل تدفق مشفر بمفتاح الملف. ومع مرشحات التشفير يحل HotPDF /StmF عبر /CF: مرشح Identity أو /CFM بقيمة None يعني حاويات نص صريح، بينما V2 و AESV2 يعني مشفرة. يهبط الجواب في FReloadObjectStreamsEncrypted، وهو مهم لحالة واحدة بعينها. عندما تكون الحاويات نصاً صريحاً والسلاسل ليست كذلك، يحمل الأعضاء سلاسل مشفرة يجب فك تشفيرها فردياً، فيوسع MaterializeMembersOfPlaintextObjectStreams كل عضو مضغوط قبل جولة فك التشفير لكل كائن. ولا تفعل شيئاً حين تكون السياسة مجهولة بعد ولا شيئاً حين كانت الحاويات نفسها مشفرة، لأن أعضاء حاوية مشفرة فُك تشفيرها معها ويجب ألا يُفك تشفيرهم مرتين
ماذا يحدث حين لا يمكن فك تشفير حاوية؟
الحاوية التي يفشل فك تشفيرها تُوضع في الحجر، لا تكون قاتلة. تسجل جولة النوع 2 إدخال THPDFObjStmQuarantineInfo في FObjStmQuarantine يضم رقم كائن الحاوية وسبباً من نوع THPDFObjStmQuarantineReason وسلسلة تشخيصية وقائمة أرقام كائنات الأعضاء التي وجه إليها المرجع المتقاطع. يُرفع osqrDecryptFailed لأربع حالات متميزة: لم يمكن حل أي مرشح تشفير، أو رمى فك AES-256 أو AES-GCM استثناءً، أو رمى فك RC4 أو AES-128 القديم استثناءً، أو لا يوجد مفتاح ملف صالح أصلاً. تبقى الحاويات المستقلة تحمّل، فيستمر مستند بحاوية واحدة تالفة في الفتح والرندرة لكل صفحة لا تعتمد عليها
قائمة الحجر تنجو من رجوع المحلل. إن فشل تحميل المرجع المتقاطع الرئيسي وأعاد HotPDF بناء جدول الكائنات بمسح الملف، قد لا ينجو العلم المشفر من المحاولة الأولى عبر ذلك البناء، لكن سجلات الحجر تنجو. لهذا يفحص BeginDoc قائمة الحجر لا العلم المشفر: على مستند محمّل تجول FObjStmQuarantine وترفع على أول إدخال osqrDecryptFailed، مسمّية الحاوية وطالبة إعادة تحميل بكلمة مرور صالحة. إعادة كتابة تجاوزت تلك النقطة كانت ستكتب الأعضاء التي كان يفترض أن تحملها الحاوية ككائنات فارغة وتبلّغ نجاحاً. تستطيع أن تشغّل الفحص نفسه بنفسك، أبكر وبرسالتك السياسية أنت، عبر واجهات الوصول العامة:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // كلمة مرور مستخدم فارغة
for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
(Info.Reason = osqrDecryptFailed) then
raise Exception.CreateFmt(
'Object stream %d is unreadable (%s); %d members unresolved',
[Info.ContainerObjNum, String(Info.Diagnostic),
Length(Info.MemberObjNums)]);
// من هنا إعادة الكتابة آمنة
end;
أسباب الحجر الأخرى تغطي الإخفاقات غير التشفيرية: حاوية ليست تدفقاً، وقاموس مفقود، و /N أو /First غير صالح، وحجم تدفق خارج المدى المقبول، وفشل إزالة الضغط، و /First يشير إلى ما بعد البيانات، وجسم عضو فُكّ ولم يحلل. تلك تستحق التسجيل عند الاستيعاب، لأن كل واحد منها يسمي الأعضاء بالضبط الذين ستفتقدهم هابطاً في المسار
لماذا تحتاج إعادة الكتابة إلى الرمز العددي الأصلي؟
يخزن HotPDF كل كائن عددي كـ Single، و Single لا يستطيع إعادة إنتاج النص المصدر لعدد حقيقي. يتيح ISO 32000-1 §7.3.3 لمنتج أن يصدر 0.750000 أو .75 أو 0.75 للقيمة نفسها، ولا واحدة من أولئك تنجو من جولة عبر ثنائي بـ 24 بتاً ومنسّق عام دون تغيير. وأسوأ من ذلك أن قيمة مثل 0.7 غير قابلة للتمثيل في Single أصلاً؛ تحلل إلى أقرب float، وإعادة تنسيق ذلك الـ float يمكن أن تنتج 0.69999999 أو جاراً مقرباً تبعاً لحلقة الأرقام. على لون تعبئة أو ثابت شفافية /CA، ذلك فرق عدّ واحد في قناة من 8 بتات، وهو ما يكفي ليرسب مقارنة بكسل مع المصدر، وعند حدود التدرج ما يكفي ليُرى بالعين
THPDFNumericObject.RememberSourceToken تحل هذا لحالة غير المعدَّل. تستدعيها المحللة بالرمز الخام مباشرة بعد إسناد Value؛ وتقبل الطريقة فقط الرموز المصنوعة من أرقام وبنقطة عشرية واحدة على الأكثر وإشارة ابتدائية اختيارية، وتخزن الرمز مع القيمة التي كان يقابلها في FSourceValue. وتعيد خاصية SourceToken النص المخزن فقط بينما لا تزال Value تساوي FSourceValue. غيّر العدد فيتبخر الرمز، فالقيمة المعدَّلة تمر دائماً بمسار التنسيق القائم ولا تصدر نصاً قديماً أبداً. يفحص SaveNumericObject أولاً SourceToken ويكتبها حرفياً عند وجودها، ثم يهبط إلى فروع العدد الصحيح ومرجع فضاء اللون والكسري فقط للأعداد المنشأة أو المعدلة في الذاكرة
الثابتة صغيرة وتستحق قولاً صريحاً: عدد لم تلمسه يُكتب بالبايتات التي قُرئ بها، وعدد لمسته يكتبه منسّق HotPDF الخاص. ويستفيد الأعضاء المضغوطون من هذا بالطريقة نفسها التي يستفيد بها كائنات الجسم، لأن EnsureCompressedObjectLoaded تجري المحللة نفسها على شريحة العضو. أما تنسيق الأعداد ذاته، واستقلاله عن الإعداد المحلي للأمر، فمغطى في مقالة تنسيق أعداد PDF الثابت مع الإعدادات المحلية في HotPDF
اختبار مسار إعادة الكتابة مقابل تدفقات الكائنات
ثلاثة فحوص تمسك كل إخفاق موصوف أعلاه، ولا يتطلب أي منها Acrobat. أولاً قارن IndexedObjectCount مع MaterializedObjectCount بعد الحفظ؛ في إعادة كتابة كاملة يجب أن يتساويان، وأي فجوة عضو أسقط. ثانياً استخرج النص وعدّد شجرة البنية في الملفين معاً، لا تعرضهما فقط، حتى يظهر ActualText مفقود أو عنصر بنية مفقود كفرق. ثالثاً حمّل المخرج بنسخة جديدة وأكد أن GetLoadedQuarantinedObjStmCount صفر، وهو ما يثبت أيضاً أن الكاتب لم ينتج حاوية لا يستطيع القارئ فتحها. وتركيبات مرشحات التشفير التي تحسم FReloadObjectStreamsEncrypted مبسوطة في مقالة سياسات StmF و StrF و EFF. وجهة الكاتب من هذه الحكاية، كيف تصدر تدفقات كائنات ومتى تفضل تحديثاً تزايدياً على إعادة كتابة، في دليل تدفقات الكائنات والتحديثات التزايدية
التحميل الكسول للأعضاء، وجولة التوسيع قبل الكاتب، وحجر فك التشفير، وحفظ رمز المصدر، كلها تشحن في HotPDF Delphi Component لـ Delphi و C++Builder. وتربط صفحة المنتج مرجع API إن أردت أن تتتبع GetLoadedObjectStreamCacheInfo وواجهات الوصول للحجر مقابل خط أنابيب الاستيعاب الخاص بك