يحرر HotPDF Delphi Component كل كائن PDF يملكه مستند عند إغلاق ذلك المستند أو إعادة تحميله: THotPDF.CloseIndirectObjects تجول في سجل الكائنات، وتجمع كل حافة ملكية في مجموعة إشاري، وتفصل كل تلك الحواف، وفقط حينها تحرر كل عقدة فريدة وكل حمولة تدفق مرة واحدة بالضبط. ذلك الترتيب ثلاثي المراحل هو ما يجعل الأبناء المشتركين ودورات الملكية والتسجيلات المكررة وأسماء الغلاف/الجسم البديلة كلها تسقط دون تحرير مزدوج ودون ترك شيء خلف الظهر. قبل v2.752.4 كان الروتين نفسه يفعل شيئاً أبسط بكثير وأسوأ بكثير: كان يحرر مصادر تدفق الملف الكسولة، ويستدعي Clear على قائمة IndirectObjects، ويحرر وعاء القائمة، ويترك كل كائن PDF فعلي لمخرج الأمر يستعيده. والتعليق في ذلك الكود كان صادقاً بشأنه أيضاً. تحرير الكائنات فردياً كان يسبب مخالفات وصول، فكان "النهج الآمن" هو عدم تحريرها إطلاقاً. هذه المقالة عن سبب انهيار النهج الفردي فعلاً، وعن شكل تفكيك يعمل في لغة بإدارة ذاكرة يدوية
لماذا لا يمكنك مجرد تنفيذ Free على كل كائن مسجل؟
لأن مدمّرات أصناف الكائنات تختلف حول مَن يملك ماذا، والسجل يحوي إدخالات عند مستويات عدة من سلسلة الملكية نفسها. فالتجول في القائمة واستدعاء Free على كل إدخال يحرر بعض الذاكرة مرتين وبعضها أبداً، تبعاً لأي أصناف صادف أن اجتمعت
ثلاث لامتماثلات في HPDFObjs.pas و HPDFDoc.pas تصنع المشكلة. THPDFDictionaryObject.Destroy تجول في Items وتحرر قيمة فقط عندما تكون IsIndirect خطأ، بافتراض أن الأبناء غير المباشرين يخصّون السجل وسيحررون هناك. أما THPDFArrayObject.Destroy فلا تميز إطلاقاً وتحرر كل عنصر تحمله. و THPDFIndirectObject.Destroy، الغلاف الحامل لرقم كائن، تحرر جسم InternalObject خاصتها. خذ الآن سجلاً يحمل قاموساً غير مباشر، ومصفوفة تسرد ذلك القاموس نفسه في إحدى خاناتها، وغلافاً جسمه مسجل أيضاً كجذر منفصل، وهو بالضبط ما ينتجه المحلل على الملفات الحقيقية. حرر المصفوفة أولاً فيغيب القاموس قبل أن يصل إليه السجل. وحرر الغلاف والجسم، بأي ترتيب، تجري الاستدعاء الثاني مدمّراً على إشارة معلقة. وحرر القاموس وحده فيبقى أي ابن غير مباشر تخطته مخصصاً إلى الأبد. لا ترتيب للسجل يصلح هذا، لأن السجل قائمة مسطحة وعلاقة الملكية رسم، والتفكير في الرسم هو المخرج الوحيد
ماذا يُعد حافة ملكية في رسم كائنات PDF؟
الحافة الملكية إشارة يتحمل مصدرها مسؤولية تدمير هدفها؛ والمرجع كل شيء غير ذلك، وعلى التفكيك أن يتبع النوع الأول ويتجاهل الثاني. في HotPDF ينتج عن ذلك أربعة أنواع حواف بالضبط: Items التابع لـ THPDFDictionaryObject، و Items التابع لـ THPDFArrayObject، و InternalObject خلف THPDFIndirectObject، وكلا نصفَي THPDFStreamObject، أي Dictionary وحمولة Stream. وأنواع المراجع لا تقل أهمية، لأن اتباع أحدها يحوّل تجولاً في رسم إلى حلقة لا نهائية أو استخدام بعد تحرير. تحمل THPDFLink رقماً للكائن وجيلاً، وهو ما يعرفه ISO 32000-1 §7.3.10 كمرجع غير مباشر: اسم لكائن يسكن في مكان آخر، لا الكائن ذاته. وحل ذلك الرقم عبر السجل يعطي عقدة تملكها حافة أخرى أصلاً، فـ CloseIndirectObjects لا تفك إشارات الروابط إطلاقاً. وإشارة FParent الخلفية التي يبقيها القواميس والمصفوفات هي الحكاية نفسها بالاتجاه الآخر؛ فالأم تملك الابن أصلاً، فاتباع الإشارة صعوداً لا يعيد إلا زيارة عقدة مرّ بها التجول. يُترك كلاهما على حاله، والتعليق في المصدر يقول ذلك في سطر: الروابط وإشارات الأم مراجع لا حواف ملكية
كيف يعمل التفكيك ثلاثي المراحل؟
المرحلة الأولى جمع بالعرض أولاً. تنثر الروتين قائمة عمل بكل إدخالات IndirectObjects، ثم لكل عقدة تُلحق أهداف حواف الملكية لتلك العقدة، متخطية كل شيء شوهد سلفاً. مجموعة المشاهدة مصفوفة عنونة مفتوحة من إشارات خام تُبخّم بـ HPDFFastCacheHashInt64 على قيمة الإشارة، بالفحص الخطي و GrowSeen مضاعفة الحجم عند بلوغها نصف الامتلاء. لا شيء في تلك البنية يخصص لكل عقدة، وهو ما يهم حين يحمل مستند بضع مئات آلاف الكائنات. أما حمولات التدفق فتذهب إلى قائمة Streams منفصلة لأنها سلالة TStream لا عقد THPDFObject وتحرر في جولة خاصة بها
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
if (SeenCount + 1) * 2 >= Length(Seen) then GrowSeen;
Slot := PointerSlot(Pointer(Value), Length(Seen));
while Seen[Slot] <> nil do
begin
if Seen[Slot] = Pointer(Value) then Exit; // جُمع سلفاً
Slot := (Slot + 1) and (Length(Seen) - 1);
end;
Seen[Slot] := Pointer(Value);
Inc(SeenCount);
if Payload then Streams.Add(Value) else Nodes.Add(Value);
end;
// المرحلة الأولى: انثر بالسجل ثم اتبع حواف الملكية وحدها
for I := 0 to IndirectObjects.Count - 1 do
Collect(TObject(IndirectObjects[I]), False);
I := 0;
while I < Nodes.Count do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
Collect(THPDFIndirectObject(Obj).InternalObject, False)
else if Obj is THPDFStreamObject then
begin
Collect(THPDFStreamObject(Obj).Dictionary, False);
Collect(THPDFStreamObject(Obj).Stream, True);
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
Collect(PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value, False)
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
Collect(TObject(THPDFArrayObject(Obj).Items[J]), False);
Inc(I);
end;
المرحلة الثانية هي الجزء الذي يجعل تشغيل المدمّرات آمناً: كل حافة ملكية تُجعل nil قبل تنفيذ أي مدمّر. يحصل الغلاف على MarkAsFreed، التي تمسح FInternalObject وتضبط العلم الذي يفحصه مدمّرها أولاً. وكائن التدفق يجعل Dictionary و Stream مسندين nil. وكل عنصر قاموس تُمسح قيمته Item^.Value وكل خانة مصفوفة تُكتب فوقها nil. بعد تلك الجولة لم يعد للرسم حواف، فحين تستدعي المرحلة الثالثة Free على كل عقدة في Nodes ثم كل حمولة في Streams، تجد كل مدمّرة لا شيء تتكرر فيه وتدمّر نفسها وحدها
// المرحلة الثانية: افصل كل حافة ملكية قبل تحرير أي شيء
for I := 0 to Nodes.Count - 1 do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
THPDFIndirectObject(Obj).MarkAsFreed
else if Obj is THPDFStreamObject then
begin
THPDFStreamObject(Obj).Dictionary := nil;
THPDFStreamObject(Obj).Stream := nil;
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value := nil
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
THPDFArrayObject(Obj).Items[J] := nil;
end;
// المرحلة الثالثة: كل عقدة فريدة وحمولة تُحرر مرة واحدة بالضبط
IndirectObjects.Clear;
for I := 0 to Nodes.Count - 1 do TObject(Nodes[I]).Free;
for I := 0 to Streams.Count - 1 do TObject(Streams[I]).Free;
FreeAndNil(IndirectObjects);
انظر ماذا يشتري هذا التقسيم. قاموس مشترك بين كائني تدفق يُجمع مرة، ويفصل عن كليهما، ويحرر مرة. ودورة تسرّد فيها مصفوفة قاموسها الأم تنتهي لأن مجموعة المشاهدة ترفض الزيارة الثانية. وغلاف وجسمه كلاهما مسجلان كجذرين هما إشارتان متميزتان في المجموعة، فيحرر كلاهما، ولم تعد مدمّرة الغلاف تحاول تحرير الجسم لأن MarkAsFreed سحبت تلك الحافة سلفاً. و TMemoryStream واحد مسند كحمولة لكائني تدفق يجلس في Streams مرة واحدة بالضبط. ولا شيء من تلك الحالات يحتاج معالجة خاصة، وهي علامة أن النموذج صحيح
كيف تميّز تسرباً عن احتباس من المخصص؟
بفحص هل يتحرك عدّ التخصيصات الحية عند مدير الذاكرة مع عبء العمل، لا بصمته المحجوزة وحدها. مدير ذاكرة Delphi يُبقي الكتل الكبيرة المحررة لإعادة الاستخدام، فالأمر الذي يبقى عند 400 MiB بعد إغلاق مستند لم يتسرب بالضرورة؛ أما الأمر الذي يصعد عدّ كتله الحي واحداً لكل صفحة في كل جولة فقد تسرب. الكاشف الذي قاد هذا الإصلاح كان صغيراً عمداً: كاتب THotPDF واحد ينتج صفحة واحدة، ثم ثلاثة قارئات تحمّله. وبعد تحرير الأربعة كلها أظهر تقرير الذاكرة أربع تخصيصات حية من 512 KiB بالضبط، واحدة لكل نسخة، وهي حمولة تدفق المحتوى التي تملكها كل نسخة ولم تحررها قط. والتكبير جعل النمط نفسه بلا لبس. تشغيل خط أنابيب العرض المتوازي مرتين رفع رقم الكتل الكبيرة المخصصة من 384 MiB إلى 640 MiB، وهو ارتفاع يتناسب مع عدد الصفحات ولا يستطيع احتباس المخصص تفسيره. بعد إعادة الكتابة بلّغ التشخيص أحادي الصفحة صفر بايت كبير مخصص وصفر محجوز متى غابت النسخ. وإن كنت تلاحق نمو النمو نفسه في أمرك الخاص، فـ رسم تبعية الكائنات مع البايتات المحتجزة يخبرك أي كائنات تمسك الذاكرة بينما المستند مفتوح؛ وهذه المقالة عن سلوك تحريرها عندما يُغلق
عتبات الذاكرة تجعل اختبارات الانحدار هشة، فاختبارات الإصدار تعدّ استدعاءات المدمّرات بدلاً منها. تبني عينة الرسم المرضي بيدها: قاموس مشترك تحت تدفقين، ومصفوفة تحوي القاموس المشترك وجذرها الخاص معاً، وحمولة واحدة مسندة لكلا التدفقين، وجذر مسجل مرتين، وغلاف جسمه مسجل منفصلاً، ثم تحرر المستند وتؤكد تدميراً واحداً لكل كائن فريد: حمولة واحدة، وتدفقان، وقاموسان، ومصفوفة واحدة، وغلاف واحد، وعدد واحد. تحت الكود القديم بلّغت اختبارات العمر الثلاثة جميعها صفر تدمير، وهو أوضح صياغة ممكنة لما تعنيه "اتركها لمخرج الأمر"
ماذا يجب أن يحدث قبل أن يسقط الرسم؟
أي عمل خلفي يستعير كائنات من الرسم يجب أن يتوقف أولاً، وأي ذاكرة تخزين مؤقت تحمي قوائم عرض أو صوراً نقطية جمّعت من تلك الكائنات يجب أن تُلقى، وإلا قرأ خيط عامل أو مرجع مخزّن ذاكرة محررة. لذلك تفتح CloseIndirectObjects بـ CancelLoadedPagePrefetch، ثم تبطل ذاكرة الصفحات المصوّرة قبل أن تلمس السجل. ومسار إعادة التحميل في LoadFromFile و LoadFromStream ومدمّرة المكوّن يمرون كلهم عبرها، فيسري الترتيب نفسه سواء كنت تستبدل مستنداً أو تتخلص من النسخة؛ و قواعد إعادة استخدام THotPDF واحد عبر المستندات تتكئ على تلك الضمانة. تفصيلان في ذلك التمهيد برزا فقط من تشغيل الاختبارات. أولهما أن المدمّرة تكون قد تخلصت سلفاً من رسوم التكرار خلف ذاكرة العرض وقوائم العرض وقت إغلاقها للرسم، فالتوطين محروس بكون تلك الحقول غير معدومة بدل استدعائه بلا شرط. وثانيهما أن InvalidateRenderedPageCache هي الروتين الذي يطلق OnLoadedDocumentModified بمؤشر صفحة -1، والمستدعي الذي يعيد تحميل ملف لا يجب أن يستلم إشعار تعديل عن تفكيك داخلي لمستند قديم. يُحفظ المعالج، ويُجعل nil حول الاستدعاء، ويُعاد في finally، ويؤكد انحدار إعادة التحميل عدّ إشعارات صفرياً بعد ثاني LoadFromStream. إصلاح ذاكرة يغيّر بهدوء عقد حدث هو انحدار بـ PR أفضل، فحصل على تأكيده الخاص. وإن شغّلت خط أنابيب العرض المتوازي على مستند ثم أعقد تحميله، فخطوة الإلغاء هي ما يمنع مجموعة العمال من التسابق مع التفكيك
إعادة استخدام النمط في كود Delphi الخاص بك
التقنية ليست خاصة بـ PDF. أي نموذج كائنات Delphi تختلف فيه المدمّرات حول ملكية الأبناء، أو يمكن فيه بلوغ الابن نفسه من عدة أمهات، أو تتعايش فيه الإشارات الخلفية والأمامية، سينهار أو يتسرب تحت Free ساذج لكل كائن. والإصلاح دائماً بالشكل نفسه: قرر أي حقول إشارات هي مالكة وأيها مراجع، واجمع إغلاق حواف الملكية عبر مجموعة إشاري تتسامح مع الزيارات المكررة، واقطع كل حافة، ثم دمّر القائمة المسطحة. خطوة القص هي التي يتخطاها الناس، وهي التي تجعل المدمّرات القائمة آمنة لإعادة الاستخدام بدل إجبارك على إعادة كتابة كل صنف في النموذج. لكن الحدود تستحق قولاً صريحاً. مجموعة الإشارات تستخدم عنوان الكائن كهوية، فكائن حُرر سلفاً وأُعيد استخدام عنوانه لتخصيص جديد سيصعب تمييزه؛ وضمانة الترتيب هي أن لا مدمّرة تجري أثناء الجمع، وهي ما يستبعد ذلك. ولا يرى التجول إلا أنواع الحواف الأربعة التي يعرفها، فصنف جديد يملك ابناً عبر حقل لا يفحصه التجول سيُسرّب ذلك الابن حتى يُعلَّم التجول عنه. ولأن الروابط تحل عبر السجل ولا تتبع، فكائن لا يشير إليه سوى رابط ولم يُسجل قط غير قابل للبلوغ بهذا التفكيك إطلاقاً؛ في HotPDF يضمن المحلل التسجيل، لكن رسماً مبنياً باليد يجب أن يحترم القاعدة نفسها
كل هذا داخل المكوّن، فالأثر المرئي على تطبيق هو ببساطة أن إغلاق مستند أو إعادة تحميله تعيد ذاكرته، من دون أي تغيير في API. HotPDF مكتبة PDF أصلية لـ VCL لـ Delphi و C++Builder بالمصدر الكامل؛ ومرجع API وبناء تجريبي على صفحة مكوّن HotPDF Delphi PDF