مقال تقني

نسخ كائنات PDF عبر المستندات في Delphi: تعطل الحلقات

ادمج ملفَي PDF يدويا، وانقل كائن صفحة واحدا إلى المستند الهدف، وستجد النسخة تمشي مباشرة إلى انتهاك وصول۔ يعالج PDFlibPas هذا في CopyForeignObject: ينسخ نسخا عميقا كائنا غير مباشر واحدا مع كامل إغلاق مراجعه، ويحل المراجع الخلفية الدائرية مثل /Parent إلى null بدلا من التعاقب

لماذا يتعطل نسخ صفحة واحدة عبر المستندات؟

لأن شجرة صفحات PDF لا تكون شجرة إلا إذا قرأتها من أعلى إلى أسفل۔ اسلكها كما يسلكها الناسخ التعاقبي، متتبعا كل قيمة في كل قاموس، وستجد قاموس الصفحة يعطيك /Parent الذي يشير عائدا إلى عقدة /Pages التي قدمت منها، وتلك العقدة تعطيك /Kids التي تشير عائدة إلى الصفحة۔ تجعل ISO 32000-1 §7.7.3 /Parent إلزاميا في كل عقدة من شجرة الصفحات عدا الجذر، لذا فهذا ليس ملفا تالف الشكل يمكنك رفضه — بل هو الشكل الطبيعي لكل مستند سيُسلَّم إليك في أي وقت

الشطر الثاني من المشكلة هو الترقيم۔ فالكائنات غير المباشرة تُعرَّف برقم كائن محلي لملف واحد (ISO 32000-1 §7.3.10)، لذا فإن كائنا يُسحب من المستند A إلى المستند B يجب إعادة ترقيمه، وكل مرجع إليه داخل الإغلاق المنسوخ يجب إعادة ترقيمه بالطريقة نفسها، وإلا فإن مرجعين كانا يشيران إلى خط مشترك واحد صارا يشيران إلى شيئين لا علاقة بينهما۔ إعادة الترقيم هذه هي العملية نفسها التي يؤديها الدمج السريع على مستوى البايت، ويستحق أن تُقرآ جنبا إلى جنب: إزاحة المراجع على مستوى البايت لدمج PDF السريع تحلها بترجمة الملفات كاملة، بينما النسخ على مستوى الكائنات يجب أن يحلها حافة حافة

لماذا يحتاج نسخ PDF عبر المستندات في Delphi إلى عناية: قاموس الصفحة وعقدة /Pages الخاصة به يغلقان حلقة عبر /Parent و/Kids، وإغلاق الخط يسير نحو الأسفل وينتهي، وPDFlibPas يعيد تخطيط كل رقم كائن محلي للملف
تغلق شجرة الصفحات حلقة عبر /Parent و/Kids بينما تنتهي إغلاقات المحتوى، وكل رقم كائن منسوخ يجب إعادة تخطيطه في الطريق

ماذا تنسخ CopyForeignObject في PDFlibPas فعلا

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) تستنسخ كائنا غير مباشر واحدا وكل ما يمكن بلوغه منه — قواميس متداخلة ومصفوفات ونصوص وأسماء وأرقاما وتدفقات بقواميسها سليمة — إلى المستند المحدد حاليا، وتعيد مقبضا غير صفري للمرجع غير المباشر الجديد۔ تُعاد تخطيط أرقام كائنات المصدر عبر خريطة حية تُحتفظ بها طوال مدة النداء، بحيث يُستنسخ الكائن الذي يُبلغ مرتين في الإغلاق مرة واحدة ويُشارك مرتين۔ وتُرجع صفرا، دون إثارة استثناء، عندما يكون معرّف مستند المصدر مجهولا، أو عندما يكون المصدر هو المستند المحدد نفسه، أو عندما يكون ObjectNumber أقل من 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile تُرجع 1 عند النجاح
    SourceDoc := Lib.SelectedDocument;   // التحميل يختار المستند الذي حمّله
    Lib.SelectDocument(TargetDoc);       // النسخ يستهدف المستند المحدد
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

تفصيلان يلسعان الناس في التشغيل الأول۔ LoadFromFile تجيب بـ 1 أو 0، لا بمعرّف مستند، لذا فإن المقبض الذي تحتاجه يأتي من SelectedDocument مباشرة بعد التحميل؛ والنسخ يكتب دائما في أي مستند جعله SelectDocument الأخير حاليّا، لا في المستند الذي حمّلت منه۔ وداخليا يحمل التعاقب أيضا سقف عمق صارما قدره 64، وهو سند احتياطي ضد التداخل المرضي، لا الآلية التي تعالج الحلقات — فمعالجة الحلقات منفصلة ومتعمَّدة

لماذا لا يكسر حجز تخطيط Nil الحلقة؟

لأن Nil في جدول التخطيط يعني شيئين مختلفين في آن واحد، والكود لا يستطيع التمييز بينهما۔ الدفاع الجلي ضد الحلقة هو إضافة مدخل الخريطة قبل التعاقب داخل الكائن، بحيث يجد أي شيء يلتف عائدا المدخل ويتوقف۔ لكن المدخل لا يستطيع حمل الهدف الحقيقي بعد — فالهدف لا يوجد حتى تُكتب الإغلاق دونه — لذا يحمل Nil، والبحث الذي يفترض أن يمسك الحافة الخلفية يقرأ Nil ويستنتج أن الكائن لم يُخطَّط قط

// معطوب: هدف Nil المحجوز لا يمكن تمييزه عن «لم يُخطَّط بعد»
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // محجوز، لا يزال Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // يُملأ لاحقًا عند العودة فقط
end;

تتبّع ذلك عبر حلقة الصفحة۔ نسخة الصفحة تبلغ /Parent، فتتعاقب داخل عقدة /Pages التي تبلغ /Kids التي تتعاقب عائدة إلى الصفحة — التي لا يزال مدخلها المحجوز يقرأ Nil، فتُستنسخ مرة ثانية ثم ثالثة، وكل مستوى يدفع إطارا جديدا وكائنا جديدا نصف مبني۔ وما ترصده ليس فيضان مكدس نظيفا أيضا: فالأطر الخارجية تجلس على مراجع لم تُسنَد أهدافها قط، لذا فإن أول كتابة عبر أحد تلك المواضع هي انتهاك وصول في مكان لا يشبه البتة نسخ الصفحة الذي تسبب فيه

لماذا لا يوقف حجز هدف Nil في الخريطة الحلقة في نسخ PDFlibPas عبر المستندات: البحث لا يميز المدخل المحجوز من غير المخطط، لذا يهبط الناسخ عبر أطر نصف مبنية أعمق فأعمق حتى تتعطل عملية كتابة
لأن هدف Nil يجيب عن سؤالين مختلفين في آن واحد، لا تُعرف الحافة الخلفية أبدا وتُستنسخ الصفحة من جديد في كل مرور

الإصلاح: حالة «قيد التنفيذ» صريحة

الإصلاح هو التوقف عن تحميل Nil أكثر من معنى وطرح السؤال مباشرة۔ مدخل الخريطة الذي لا يزال هدفه غير مُسنَد يعني أن هذا الكائن يُستنسخ الآن، ودالة InProgress تختبر ذلك بالضبط قبل تشغيل البحث العادي۔ وعندما تكون true، تكون الحافة حلقة عائدة إلى سلف النسخة الحالية، فيُصدر PDFlibPas لها كائن null بدلا من اتباعها

// مدخل خريطة بهدف Nil يميز نسخة قيد التنفيذ
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... داخل CloneObject، لمرجع غير مباشر:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // حافة خلفية دائرية، لا تتعاقب
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // مرجع مصدر معلّق
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // احجز ثم تعاقب
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // املأ لاحقًا
end;
Exit(NewRef);

هذا التعميم آمن فقط بسبب حقيقة بنيوية عن PDF: الحلقات في الرسم البياني للكائنات تظهر على الروابط الخلفية، لا على حواف المحتوى۔ فـ /Parent في شجرة الصفحات و/Prev في سلسلة المخططات يشيران إلى أعلى أو إلى الخلف نحو شيء زِرته بالفعل؛ أما إغلاق الخط أو كائن XObject للصورة أو كائن XObject للنموذج فيسير نحو الأسفل وينتهي۔ لذا فإن نسخة واصف الخط أو فضاء اللون أو قاموس التظليل لا تتأثر باستبدال null — فلا شيء في تلك الإغلاقات يبلغ InProgress قط۔ والثمن، بصراحة، هو أن الحافة الدائرية لا تنجو من النسخ۔ وقاموس الصفحة المستنسخ هكذا يصل و/Parent فيه كائن null، وهو ما تجعله ISO 32000-1 §7.3.9 معادلا لمدخل غائب، فالصفحة المنسوخة كائن صالح لا ينتمي إلى أي شجرة صفحات حتى تربطها في عقدة /Pages الهدف وتصلح /Count بنفسك۔ وعنصر المخطط المنسوخ يفقد /Prev بالطريقة نفسها ويحتاج إلى إعادة بناء سلسلة الأشقاء۔ هذه هي المقايضة الصادقة: CopyForeignObject تعطيك إغلاقا صحيحا وتترك إعادة الأبوة البنيوية للمستدعي، وهو الحد نفسه الذي يعمل ضمنه استبدال الصفحات مع الحفاظ على أرقام الكائنات

الإصلاح في CopyForeignObject ضمن PDFlibPas لـ Delphi: اختبار InProgress صريح يعمل قبل البحث في الخريطة، والحافة الخلفية الدائرية تصبح كائن null، ويعيد المستدعي ربط الصفحة المنسوخة في شجرة صفحات الهدف بعد ذلك
حالة «قيد التنفيذ» الصريحة تحل محل Nil المحمَّل أكثر من معنى، فتنتهي الحافة الخلفية إلى null ويُترك للمستدعي إصلاح بنيوي واحد

لماذا يجب حجز مدخل الخريطة قبل NewObjRef

ثمة بديل جلي يمكنه تفادي كل رقصة «قيد التنفيذ»: خصّص كائنا هيكلا فارغا أولا، وسجّل رقمه الحقيقي في الخريطة، ثم املأ الهيكل بعد استنساخ الأبناء۔ هذا لا يعمل هنا، لأن TPDFIndObj.Obj للقراءة فقط ولا يمكن استبدال محتواه بعد البناء — فلا هيكل لملئه۔ الرقم والمحتوى يُقرَّران معا بواسطة NewObjRef، ما يعني أن مدخل الخريطة يجب إنشاؤه قبل النداء التعاقبي وإكماله بعده، والفاصل بين هاتين اللحظتين هو بالضبط ما يجب أن يغطيه InProgress۔ وثمة نتيجة تستحق المعرفة قبل مقارنة الناتج بالبايت: لأن NewObjRef تعمل بعد كتابة إغلاق الأبناء، فإن الترقيم في الهدف يخرج من أسفل إلى أعلى، وأرقام الكائنات لن تعكس ترتيب المصدر۔ لا شيء في صيغة الملف يبالي، لكن مقارنة بايت مع توقّع مبني يدويا ستبالي۔ وإذا تركت عملية تشغيل كائنات قررت ألا تربطها بأي شيء، فهي غير مُشار إليها لا تالفة، وجمع الكائنات غير القابلة للوصول في PDF بالعلامة والمسح هو الأداة التي تنظفها قبل الحفظ

اختبار الانحدار الذي يغطي هذا يحتاج إلى تفصيلة واحدة تفاجئ من يكتبون اختبارات ضد TPDFlib: المُنشئ يحمل مستندا افتراضيا بالفعل، لذا يبدأ DocumentCount من 1 وثيقة المستندين يجب أن تؤكد >= 2 لا = 2۔ وإلى جانب النسخة الناجحة، يثبّت الاختبار حالات الرفض الثلاث — معرّف مصدر مجهول، والمستند المحدد كمصدر لنفسه، ورقم كائن يساوي صفرا — وكلها تُرجع 0 بدلا من إثارة استثناء، لأن حلقة الدمج مكان سيئ لاكتشاف أن جملة حارس تطلق استثناء

أين يقع هذا في خط أنابيب الدمج

النسخ على مستوى الكائنات هو البدائية التي تمد يدها إليها عندما يكون دمج الملف كاملا أخشَن من اللازم: انتشال برنامج خط واحد من قالب، أو سحب كائن XObject نموذج واحد إلى مستند ختم، أو نقل تعليق توضيحي مع تدفقات مظهره عبر الملفات دون جرّ بقية الصفحة معه۔ يكشفه PDFlibPas كنداء واحد ضد المستندات المحمّلة، ويمكنك رؤية كيفية تموضعه مع بقية واجهة الكائنات منخفضة المستوى في مرجع مكتبة PDFlibPas لـ PDF في Delphi