مقال تقني

إبقاء /ParentTree و /StructParents متزامنين داخل Delphi

يوسم PDFium Component محتوى PDF القائم من Delphi عبر EditPdfDocumentStructure في FPdfDocumentStructure.pas. عمليتا psekAddMarkedContent و psekAddObjectReference تفعلان أكثر من إلحاق MCR أو OBJR بـ /K لعنصر بنية: تخصصان أيضاً مفتاح /StructParents أو /StructParent وتكتبان مدخل /ParentTree المطابق، وهو الفهرس العكسي الذي تشترطه ISO 32000-1 §14.7.4.4

ذلك النصف الثاني هو حيث يخطئ ترميز الوسوم لاحقاً عادة. إضافة غلاف BDC و MCR سهلة، والعارضة التي تمشي شجرة البنية من الأعلى ستجد المحتوى الجديد بسرور. لكن التقنيات المساعدة كثيراً ما تسلك الاتجاه المعاكس: تبدأ من قطعة محتوى على الصفحة وتسأل أي عنصر بنية يملكها. إن لم يكن للصفحة مفتاح /StructParents، أو لم يكن لـ /ParentTree مدخل للـ MCID الجديد، فشل ذلك البحث، وتُبلّغ المدققات عنه شجرة أب غائبة أو غير متسقة. يبدو الملف موسوماً وما يزال لا يعمل

ماذا يربط /ParentTree فعلاً؟

/ParentTree شجرة أعداد تربط مفاتيح صحيحة بعناصر بنية، وتأتي المفاتيح من المحتوى لا من الشجرة. يمنح ISO 32000-1 §14.7.4.4 كل صفحة أو Form XObject يحوي محتوى معلماً عدداً صحيحاً /StructParents، وقيمة شجرة الأب تحت ذلك المفتاح مصفوفة مفهرسة بالـ MCID: المدخل 3 من المصفوفة هو عنصر البنية الذي يملك <</MCID 3>>. وتنال تعليقة أو XObject تُستخدم عنصر محتوى كاملاً مفتاح /StructParent مفرداً بدلاً من ذلك، وقيمة شجرة الأب لها عنصر البنية نفسه لا مصفوفة

الفهرس العكسي لشجرة الأب في PDF/UA الذي يكتبه PDFium Component داخل Delphi: يحل علامة محتوى تحمل MCID 3 عبر مفتاح /StructParents للصفحة 17 إلى شجرة الأعداد /ParentTree، ومدخل مصفوفتها 3 هو عنصر البنية الذي يملك المحتوى المعلّم
ترتيب القراءة مهم لـ PDF الموسومة: تمشي التقنية المساعدة من الـ MCID على الصفحة عبر /StructParents إلى مصفوفة /ParentTree، ومفتاح أو مدخل مفقود يكسر ذلك البحث وإن بدت شجرة البنية نفسها سليمة

صيغة الحاوي هي شجرة الأعداد العامة من §7.9.7. تحمل الورقة /Nums، مصفوفة مسطحة من أزواج مفتاح وقيمة مرتبة تصاعدياً بالمفتاح. وتحمل العقد الوسيطة والأوراق تحت الجذر /Limits بأصغر مفتاح تغطيه وأكبره، بينما يجب ألا يحمل الجذر نفسه /Limits. وفوق ذلك قد يحمل جذر شجرة البنية /ParentTreeNextKey‏ (§14.7.2)، المفتاح التالي الذي ينبغي أن يمنحه الكاتب. وكل تحرير يوسم محتوى جديداً يمس إذن حتى أربعة كائنات يجب أن توافق: تدفق المحتوى، وقاموس الصفحة أو النموذج، وورقة شجرة الأب، وجذر شجرة البنية

للاتجاه الأمامي قواعده الخاصة، مفصلة في §14.7.4.2 و §14.7.4.3. مرجع محتوى معلّم (MCR) لمحتوى في تدفق محتوى الصفحة نفسه يحتاج /Pg و /MCID فقط. وعندما يسكن المحتوى المعلّم داخل Form XObject يحتاج الـ MCR أيضاً /Stm لتسمية ذلك التدفق، لأن MCIDs تُرقَّم لكل تدفق محتوى. ومرجع كائن (OBJR) يسمي التعليقة أو XObject بـ /Obj وصفحته بـ /Pg

وسم تدفق محتوى بواسطة EditPdfDocumentStructure

EditPdfDocumentStructure محرر دفعي محروس بلقطة. تستدعي أولاً ReadPdfDocumentStructure لتنال TPdfDocumentStructureSnapshot، ثم تمرر SourceSha256 الخاص بها عائداً مع مصفوفة من سجلات TPdfStructureEditOperation. إن تغيرت بايتات المصدر في الأثناء رُفض التحرير دون أي مخرج. وتُخاطَب العناصر القائمة بأرقام كائناتها في NodeID، ويدل الصفر على جذر شجرة البنية

uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;

function TagContentStream(const FileName, OutName: string;
  ElementID, StreamObj, StreamGen: Integer): Boolean;
var
  Source: TMemoryStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  Output: TBytes;
begin
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(FileName);
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);

    SetLength(Ops, 1);
    Ops[0] := Default(TPdfStructureEditOperation);
    Ops[0].Kind := psekAddMarkedContent;
    Ops[0].NodeID := ElementID;            // عنصر قائم، برقم الكائن
    Ops[0].TargetObjectNumber := StreamObj; // تدفق محتوى الصفحة أو Form XObject للصفحة
    Ops[0].TargetGeneration := StreamGen;

    Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
      Ops, Options, Output, Report);
    if not Result then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
  finally
    Source.Free;
  end;
end;

في تلك العملية الواحدة يلف EditPdfDocumentStructure التدفق الهدف في /P <</MCID n>> BDC ... EMC، ويلحق الـ MCR بـ /K للعنصر، ويمنح الصفحة‏ (أو النموذج) مفتاح /StructParents إن لم يكن لها، ويكتب العنصر n في مصفوفة شجرة الأب لذلك المفتاح. وتُكتب النتيجة تحديثاً تدريجياً بعد البايتات الأصلية، ويخبرك Report.CreatedMarkedContents كم MCR أُنشئ. وعندما يحوي التدفق أزواج BDC/EMC متوازنة أصلاً لا يُلف سوى الذيل غير الموسوم بعد آخر EMC عالي المستوى؛ وإلا لُفّ التدفق كله

كيف تُنتقى MCIDs ومفاتيح شجرة الأب الجديدة؟

الـ MCID الجديد هو الحد الأقصى الحالي مضافاً إليه واحد، ومفتاح شجرة الأب الجديد هو Max(MaxKey + 1, ParentTreeNextKey). ولأجل الـ MCID تأخذ EditPdfDocumentStructure الحد الأقصى عبر MCRs المسجلة أصلاً لتلك الصفحة‏ (أو ذلك Form XObject) وكل /MCID تجده في قوائم خصائص التدفق نفسه. وعدّ الاثنين مهم: ربما فقدت شجرة بنية MCR بينما ما يزال المحتوى يحمل العلامة، وإعادة استخدام ذلك الرقم ستجعل عنصرين يطالبان بالمحتوى نفسه

يتبع منطق المفاتيح الفكرة الدفاعية نفسها. إن كان للمالك قيمة /StructParents أصلاً امتدت المصفوفة القائمة عند فهرس MCID، بـ null يملأ أي فجوة، ولم يُخصص مفتاح جديد. وإلا مسح المكوّن كل ورقة /Nums بحثاً عن أعلى مفتاح، وقارنه بـ /ParentTreeNextKey، وأخذ الأكبر. يُعالَج /ParentTreeNextKey قديم أدنى من المفاتيح المستعملة أصلاً وواحد يحجز مفاتيح بعد آخر مدخل كلاهما، لأن الأكبر من القيمتين حر دوماً. وبعد التخصيص يتقدم /ParentTreeNextKey إلى المفتاح الجديد مضافاً إليه واحد

تخصيص MCID ومفتاح شجرة الأب في محرر البنية لـ PDFium Component داخل Delphi: يكون الـ MCID الجديد واحداً فوق الحد الأقصى المسجل أصلاً في MCRs وقوائم خصائص التدفق، ويكون المفتاح أكبر أعلى مفتاح /Nums مضافاً إليه واحد و /ParentTreeNextKey، مع null يملأ فجوات المصفوفة
تشتق EditPdfDocumentStructure الـ MCID الجديد من MCRs ومن العلامات داخل التدفق كليهما وتخصص مفتاح شجرة الأب بأسلوب دفاعي، فلا يستطيع /ParentTreeNextKey قديم أو MCID معاد استخدامه أن يجعل عنصرين يطالبان بالمحتوى نفسه

يُدرج الزوج الجديد في /Nums بترتيب تصاعدي بدل إلحاقه، لأن بحث شجرة الأعداد يعتمد على ذلك الترتيب. وإن حملت الورقة /Limits أُعيد حساب الحدين من المصفوفة المعاد كتابتها. وعندما لا يكون لجذر شجرة البنية /ParentTree إطلاقاً، وهو شائع في ملفات وُسمت بأدوات كتبت الشجرة الأمامية فقط، يستحدث EditPdfDocumentStructure كائن << /Nums [] >> غير مباشر جديداً ويربطه من /StructTreeRoot قبل إدراج أول مدخل

Form XObjects و /StmOwn ومراجع الكائنات

المحتوى المعلّم داخل Form XObject مدعوم فقط عندما تستخدم ذلك النموذج صفحة واحدة بالضبط. يحمل الـ MCR الذي يكتبه EditPdfDocumentStructure لنموذج ‏/Pg و /Stm تسمي تدفق النموذج و /StmOwn تسمي الصفحة، ويذهب مفتاح /StructParents إلى قاموس النموذج لا إلى الصفحة. نموذج ترسمه ثلاث صفحات له ثلاثة مالكين معقولاً، فأي /StmOwn مفرد سيكون خاطئاً لاثنين منها. يرفض المكوّن التحرير بخطأ بدل التخمين

قاعدة المالك الواحد لوسم محتوى Form XObject بواسطة PDFium Component داخل Delphi: يحمل MCR على نموذج تستخدمه صفحة واحدة ‏/Pg و /Stm و /StmOwn مع /StructParents على قاموس النموذج، بينما يجعل النموذج الذي ترسمه ثلاث صفحات /StmOwn غامضاً فيرفض التحرير
مفتاح /StmOwn لا معنى له إلا عندما تملك الصفحةُ النموذجَ وحدها، ولهذا تُرفض النماذج المشتركة والتعليقات المشتركة بدل وسمها بمالك قابل للتخمين

تنطبق قاعدة التفرد نفسها على psekAddObjectReference. يجب أن يكون الهدف تعليقة تظهر في /Annots لصفحة واحدة بالضبط، أو Form XObject أو Image XObject يظهر في موارد صفحة واحدة بالضبط؛ وأي شيء آخر يُرفض. ينال الـ OBJR ‏/Obj و /Pg، وينال الهدف مفتاح /StructParent‏ (معاد استخدامه إن وُجد)، وقيمة شجرة الأب مرجع عادي إلى العنصر. هذا هو الإصلاح المعتاد لتعليقة رابط أو صورة figure وُسمت بصرياً ولم توصل إلى الشجرة قط

// لف صورة غير موسومة في عنصر Figure جديد، ثم اربطها بطريقتين:
// بوصفها OBJR لـ XObject الصورة وبوصفها محتوى معلماً على الصفحة
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // معرف مؤقت، يجب أن يكون سالباً وفريداً
Ops[0].ParentNodeID := SectID;    // عنصر Sect قائم
Ops[0].Value := 'Figure';         // دور قياسي أو مدخل RoleMap

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // يشير إلى الحاوي المنشأ أعلاه
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;

Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;

if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
  Output, Report) then
  Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
    Report.InsertedNodes[0].ObjectNumber,
    Report.CreatedObjectReferences, Report.CreatedMarkedContents]));

قيم NodeID السالبة تسمي حاويات أُنشئت سلفاً في الدفعة نفسها، ويطابق Report.InsertedNodes كل معرف مطلوب برقم الكائن الذي ناله. يقبل psekInsertContainer الأدوار القياسية في PDF 1.7 و PDF 2.0 و PDF/UA مثل P و H1 إلى H6 و Figure و Table و LI، مع أدوار مخصصة مربوطة أصلاً في /RoleMap. وضبط BeforeNodeID على عمليتي المحتوى يضع الـ MCR أو OBJR الجديد قبل ابن قائم بدل النهاية

لماذا يحتاج تحرير التدفق نفسه مرتين في دفعة واحدة عناية؟

قد تصيب الدفعة الكائن نفسه مراراً، وعلى كل عملية لاحقة أن ترى نتيجة السابقات. عمليتا psekAddMarkedContent على الصفحة نفسها كلتاهما تعيدان كتابة تدفق محتواها، وكلتاهما تحدّثان ورقة شجرة الأب نفسها. تنفيذ يرصّ كل تحرير كائناً ملحقاً منفصلاً يكتب نسختين من الكائن 12 في التحديث. وبحسب أيهما يبقي القارئ تخسر غلاف BDC الأول أو زوج /Nums الأول، وقد يتصادم الـ MCID الثاني مع الأول لأنه حُسب من بايتات قديمة

يفهرس EditPdfDocumentStructure التحديثات المرصوصة بـ (ObjectNumber, Generation) ويستبدل مدخلاً قائماً في مكانه، ويحدّث في الوقت نفسه النسخة في الذاكرة من الكائن. لذلك تحلل العملية الثانية التدفق الملفوف أصلاً، فترى MCID 0، وتخصص MCID 1، وتمد مصفوفة شجرة الأب نفسها. كائن واحد، نسخة نهائية واحدة. وقبل إعادة أي شيء يُقرأ المخرج عائداً ويُقارن بالنموذج في الذاكرة: يجب أن يطابق عدد العناصر وعدّ عناصر المحتوى وكل MCR أو OBJR محتفظ به، وإلا فشل الاستدعاء دون مخرج

تسلك التدفقات المضغوطة المسار نفسه. تدفق محتوى بـ /Filter /FlateDecode‏ (أو مصفوفة [/FlateDecode] من عنصر واحد) وبلا منبئ /DecodeParms يُفك ضغطه بفاكّك محدود بسقف 32 MiB أو MaxOutputBytes أيهما أصغر، ويُلف، ويعاد ضغطه إلى /FlateDecode بـ /Length جديدة. تدفق كان سيفك ضغطه فوق السقف يُرفض بدل بتره، وهذا مهم للملفات من مصادر غير موثوقة

حدود تستحق المعرفة قبل تشغيل إصلاح PDF/UA

متحفظ EditPdfDocumentStructure بحسب التصميم، ومعظم حدوده تظهر رسالة Report.ErrorMessage واضحة لا ملفاً مكسوراً بصمت. ضع هذه في حسبانك عندما تخطط لإصلاح دفعي:

  • لا تُلفّ إلا التدفقات غير المفلترة و FlateDecode العادية؛ وتُرفض المرشحات الأخرى أو Flate بمنبئ
  • يجب أن ينتمي تدفق المحتوى إلى /Contents لصفحة واحدة بالضبط، وأن تُستخدم Form XObjects والتعليقات و XObjects بصفحة واحدة بالضبط
  • يستخدم الغلاف وسم /P دوماً؛ والدور الدلالي يأتي من /S لعنصر البنية لا من اسم وسم BDC
  • يقرأ مزامنة شجرة الأب جذراً /Nums مسطحاً أو مستوى واحداً من أوراق /Kids؛ ولا يُنزل إلى عقد وسيطة أعمق، فأعد التحقق من تلك الملفات بعد التحرير
  • تُرفض الملفات التي تحوي تواقيع أو حقول تواقيع جملة، فوقّع بعد إصلاح البنية لا قبله

خطوة التحقق ليست اختيارية لأنبوب إصلاح. شغّل التحقق من شجرة بنية PDF/UA في PDFium Component قبل الدفعة وبعدها، واستخدم واجهة المحتوى المعلّم لكائنات الصفحة لقراءة وسوم BDC و MCIDs عندما تحتاج تأكيد أي كائنات صفحة وقعت داخل العلامات الجديدة. شجرة أب نظيفة تخبرك أن البحوث تُحل؛ ولا تخبرك أن ترتيب القراءة أو النص البديل صحيح

FAQ

هل يعيد EditPdfDocumentStructure بناء /ParentTree كاملاً؟

لا. يحدّث EditPdfDocumentStructure مدخلات المحتوى الذي يوسمه في الدفعة الحالية فقط، معيداً استخدام مفاتيح /StructParents القائمة ويخصصاً جديدة عند الحاجة، فتبقى مدخلات شجرة الأب القائمة لم تُمس

ماذا يحدث إذا كان للـ PDF شجرة بنية دون /ParentTree؟

ينشئ المكوّن شجرة أعداد << /Nums [] >> غير مباشرة، ويربطها من /StructTreeRoot، ويدرج أول مفتاح للمحتوى الذي يوسم

هل يمكنني وسم محتوى في Form XObject مشترك بين عدة صفحات؟

ليس بهذه العملية. يجعل النموذج المشترك /StmOwn غامضاً، فيشترط psekAddMarkedContent أن تشير إلى النموذج صفحة واحدة بالضبط ويبلّغ عن خطأ خلاف ذلك

قراءة شجرة البنية وتحريرها والتحقق من PDF/UA والوصول للمحتوى المعلّم كلها من PDFium Component لـ Delphi و C++Builder، الذي يغلّف PDFium لمشاريع Delphi و C++Builder و Lazarus