مقال تقني

تحرير شجرات الأسماء والأرقام في PDF عبر دلفي

عدّل مصفوفة ورقة بإضافة اسم واحد وما يزال الإدخال يُقرأ كمفقود. يرد مكوّن PDFium على ذلك بـ TPdfTreeEditor، محرر عام لشجرات الأسماء والأرقام وفق ISO 32000: يحمّل الشجرة بتحقق صارم، ويعدّل الإدخالات في الذاكرة، ويعيد بناء مجموعة عقد متوازنة بـ /Limits صحيحة، ويكتب مراجعة تراكمية متحققًا منها

الفشل الذي دفع لكتابة هذه الوحدة هو النوع الذي لا تشخّصه إلا بقراءة البايتات. أداة تضيف مفتاحًا وقيمة إلى مصفوفة /Names لورقة، تكتب مراجعة جديدة، وكل تفريغ hex يؤكد أن المفتاح موجود في الملف. ومع ذلك ما يزال Acrobat يقول إن المرفق غير موجود. لا شيء تالف بالمعنى الساذج. الشجرة ببساطة لم تعد شجرة يستطيع خوارزمية البحث النزول فيها

لماذا يكسر تعديل مصفوفة الورقة عملية البحث؟

لأن البحث نزول ثنائي عبر /Limits، وليس مسحًا للأوراق. يعرّف ISO 32000-1 §7.9.6 شجرة الأسماء كجذر تقود /Kids منه نزولًا إلى أوراق تحمل /Names، وهي مصفوفة من مفتاح وقيمة بالتناوب، بمفاتيح مرتبة بمقارنة بايتية عبر الشجرة كلها، وكل عقدة غير الجذر تعلن /Limits كأصغر وأكبر مفتاح لديها. القارئ المطابق يقارن المفتاح المطلوب بنطاق كل فرع ويفتح طفلًا واحدًا بالضبط. أضف مفتاحًا إلى ورقة واترك /Limits القديمة دون مساس، فيمضي النزول مباشرة متجاوزًا العقدة التي تحمله فعلًا

مخطط لبحث شجرة الأسماء في PDF عبر مكوّن PDFium: ينزل القارئ ثنائيًا عبر نطاقات /Limits للأطفال بمقارنة بايتية، فمفتاح مثل invoice-2026-09 يُضاف إلى ورقة لا تزال /Limits القديمة فيها تنتهي عند (f) لا يُفتح أبدًا، ويأخذ النزول فرع (g) (z) بدلًا منها، ويُقرأ المرفق كمفقود رغم وجوده في الملف
يفتح البحث طفلًا واحدًا بالضبط بمقارنة المفتاح المطلوب بنطاق كل فرع، لذا يجتاز النزول مباشرة الورقة التي كسبت مفتاحًا دون تحديث /Limits

شجرات الأرقام (§7.9.7) بالشكل نفسه مع /Nums ومفاتيح صحيحة، وهي تحمل حركة حقيقية: /PageLabels في §12.4.2 شجرة أرقام، وفئات /Names في الفهرس §7.7.4 تمنحك /Dests و/EmbeddedFiles و/JavaScript كشجرات أسماء. إذن أربع ثوابت يجب أن تصمد معًا، لا واحدة تلو الأخرى. المفاتيح يجب أن تكون متزايدة بصرامة في ترتيب الشجرة، وكل مستوى يجب أن ينشر حدودًا تطابق نطاق مفاتيحه الفعلي، ونطاقات الأشقاء يجب ألا تتداخل، ورقم كائن الجذر وجيله يجب أن يبقيا حيث كانا بالضبط، لأن الفهرس وأي جهة مُحيلة أخرى تشير إلى ذلك الجذر بمرجع غير مباشر. ملاحظتنا حول العمل مع مرفقات PDF في دلفي تعرض جانب المستهلك من البنية نفسها

ما الذي يرفض TPdfTreeEditor تحميله؟

أكثر مما تتوقع، وعن قصد. يجتاز TPdfTreeEditor.TryLoad مخطط العقد عبر TPdfSparseDictionaryReader، محللًا كل طفل بـ ReadDictionary عند الجيل المحدد بالضبط في المرجع بدلًا من قبول أي مراجعة نشطة مصادفة. الشجرة التي لا يمكن إثبات سلامة تشكيلها تُرفض قبل السماح بأي تعديل، وهو ما يمنع مدخلًا معطوبًا من أن يصبح مخرجًا معطوبًا بمظهر مقبول

  • الحلقات والمراجع المتكررة، وكلاهما يُبلَّغ كـ ptviCycle، فإدخال /Kids يشير عائدًا إلى أعلى السلسلة يُنهي التنفيذ بدلًا من التكرار
  • أشكال العقد المختلطة: قاموس يحمل /Kids مع /Names أو /Nums هو ptviInvalidNodeShape، وكذلك الورقة غير الجذرية الفارغة
  • مفاتيح غير مرتبة أو مكررة، تُبلَّغ كـ ptviUnsortedKey وptviDuplicateKey، ويُفحص ذلك عبر الاجتياز كله لا داخل ورقة واحدة
  • حدود تخالف المفاتيح الموجودة فعلًا، تُبلَّغ كـ ptviInvalidLimits
  • قيمة خام ليست كائن PDF كاملًا واحدًا بالضبط، تُبلَّغ كـ ptviInvalidValue
  • تجاوز الميزانيات: ptviDepthLimit وptviNodeLimit وptviEntryLimit وptviDeadlineExceeded، بقيود من TPdfTreeEditOptions ومن ميزانية المحلل المركزية معًا

يضبط TPdfTreeEditOptions.Default تلك الحدود المحلية للشجرة على 1,000,000 إدخال و100,000 عقدة وعمق 64 و64 إدخالًا لكل ورقة و32 طفلًا لكل عقدة. رقما الورقة والطفل ليسا حارسين فقط؛ بل هما التفرّع المستخدم لاحقًا عند إعادة بناء الشجرة، ولهذا تتحقق السجلّة من أن كليهما اثنان على الأقل

uses
  FPdfCompress;

var
  Options: TPdfTreeEditOptions;
  RootRef: TPdfTreeObjectReference;
  Editor: TPdfTreeEditor;
  Report: TPdfTreeValidationReport;
begin
  Options := TPdfTreeEditOptions.Default;
  RootRef.ObjectNumber := EmbeddedFilesRootObject;   { فهرس /Names /EmbeddedFiles }
  RootRef.Generation := EmbeddedFilesRootGeneration;
  if not TPdfTreeEditor.TryLoad(Source, ptkName, RootRef, Options,
    Editor, Report) then
  begin
    Writeln('tree rejected: ', Report.ErrorMessage);
    Exit;
  end;
  try
    { القيمة هي البايتات الخام لكائن PDF كامل واحد }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { Editor.Free بعد الحفظ أدناه }
  end;
end;

قيم الإدخالات تبقى بايتات PDF خام

تُخزَّن قيمة الإدخال حرفيًا، وهذا هو القرار التصميمي الوحيد الذي يجعل المحرر آمنًا لتوجيهه إلى ملفات لم تنتجها. يحمل TPdfTreeEntry الحقول NameKey وNumberKey وRawValue، وRawValue هو شريط البايتات الخام لكائن PDF كامل واحد: قاموس، أو مصفوفة، أو سلسلة، أو مرجع تدفق، أو مرجع غير مباشر مجرد. لا يُفكّك شيء إلى variant دلفي أو TStrings أو سجلّة منمطة. المكسب هو مرور ذهاب وإياب بلا خسارة للقيم التي لا تعرف عنها الوحدة شيئًا. /Filespec يحمل امتداد مورد، أو مصفوفة وجهة بنوع fit غير مألوف، أو قاموس إجراء /JavaScript بمفاتيح خاصة، كلها تنجو من إعادة البناء بايتًا ببايت، لأن المحرر لم يكن له رأي في محتوياتها أصلًا. كما يقلص واجهة التعديل إلى شيء قابل للإثبات: على UpsertName وUpsertNumber فقط التحقق من أن القيمة المقدمة تُحلل ككائن كامل واحد بالضبط، وRemoveName وRemoveNumber وClear لا تمس إلا قائمة الإدخالات

كيف تُعاد بناء شجرة متوازنة دون إعادة ترقيم الجذر؟

بمرحلتين مع نقطة ثابتة واحدة. يقطع BuildBalancedObjects أولًا قائمة الإدخالات المرتبة إلى أوراق لا تتجاوز MaxLeafEntries، ثم يطوي كل مستوى في آباء لا يتجاوزون MaxKidsPerNode ويكرر حتى تبقى عقدة واحدة. تُحسب الحدود من الأسفل إلى الأعلى، فالورقة تأخذ أول وآخر مفتاح لديها، والعقدة الداخلية تأخذ الحد الأدنى لأول طفل والحد الأعلى لآخر طفل، فلا يمكن لنطاقات الأشقاء أن تتداخل بالبناء

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

مخطط لإعادة البناء عبر TPdfTreeEditor في مكوّن PDFium: يقطع BuildBalancedObjects قائمة الإدخالات المرتبة إلى أوراق لا تتجاوز MaxLeafEntries مفتاحًا، ويطوي كل مستوى في آباء لا يتجاوزون MaxKidsPerNode، ويحسب /Limits من الأسفل للأعلى من أول وآخر مفتاح، ويعطي الجذر المكتمل رقم الكائن والجيل اللذين حُمّلت بهما الشجرة بالضبط
تأخذ الأوراق حدودها من أول وآخر مفتاح لديها، والعقد الداخلية من أقصى أطفالها، ويحتفظ الجذر النهائي برقم الكائن والجيل اللذين حُمّلت بهما الشجرة
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber يأتي من مراجعة المصدر }
  if Editor.BuildBalancedObjects(MaximumObjectNumber + 1,
    Serialized, Report) then
  begin
    Writeln('nodes ', Report.NodeCount, ' depth ', Report.MaximumDepth);
    for I := 0 to High(Serialized) do
      Writeln(Serialized[I].Reference.ObjectNumber, ' ',
        Serialized[I].Reference.Generation, ' obj');
  end
  else
    for I := 0 to High(Report.Issues) do
      Writeln('issue ', Ord(Report.Issues[I].Kind), ' at obj ',
        Report.Issues[I].ObjectNumber, ': ', Report.Issues[I].Message);
end;
>

اكتب المراجعة، ثم أعد قراءة الناتج

لا يُعتبر الحفظ منتهيًا إلا بعد إعادة فتح المخرج ومقارنته. يقرأ SaveIncremental معطيات مراجعة المصدر، ويرفض جذرًا يكون فهرس المستند نفسه، ويُسلسل مجموعة العقد المتوازنة مع كائن الفهرس، ويسلّم كل شيء إلى AppendPdfSparseUpdate، الذي يلحق مراجعة جديدة وفق §7.5.6 مع الحفاظ على مرجع /Info القائم ومصفوفة /ID للمستند

ثم يأتي الجزء الذي يستحق نسخه في كتاباتك أنت. يستدعي المحرر TPdfTreeEditor.TryLoad مجددًا، هذه المرة على تدفق الوجهة، بنفس مرجع الجذر ونفس الخيارات، ويقارن عدد الإدخالات المعاد تحميلها وكل مفتاح وRawValue بما كان ينوي كتابته. إذا فشل إعادة التحميل أو اختلفت أي مقارنة، يُعاد ضبط تدفق الوجهة بدلًا من إعادته، فلا يُسلَّم أبدًا مراجعة مكتوبة جزئيًا كنجاح. يقرأ التحقق المخرج عبر المسار المتفرق المحدود نفسه الموصوف في مقالنا عن فهرس كائنات PDF المتفرق الكسول، وهو ما يُبقي الفحص في متناول اليد على الملفات الكبيرة

مخطط للحفظ التراكمي المتحقق في مكوّن PDFium: يسلسل SaveIncremental مجموعة العقد المتوازنة، ويلحق AppendPdfSparseUpdate مراجعة جديدة محافظًا على /Info ومصفوفة /ID للمستند، ثم يعيد TPdfTreeEditor.TryLoad قراءة تدفق الوجهة ويقارن عدد الإدخالات وكل مفتاح وRawValue، مع إعادة ضبط التدفق بدلًا من إعادة مراجعة مكتوبة نصفها
لا يُعتبر الحفظ منتهيًا إلا بعد إعادة فتح المخرج عبر المسار المحدود نفسه ومقارنته إدخالًا بإدخال، ومقارنة فاشلة تعيد ضبط تدفق الوجهة
var
  Dest: TFileStream;
begin
  Dest := TFileStream.Create(OutputName, fmCreate);
  try
    if Editor.SaveIncremental(Source, Dest, Report) then
      Writeln('wrote ', Report.NodeCount, ' nodes, ',
        Report.EntryCount, ' entries')
    else
      { الوجهة أُعيد ضبطها مسبقًا، لم يُحفظ شيء مكتوب نصفه }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;
e>

المصادر المشفرة مرفوضة، وهذا صدق

يرفض التنفيذ الحالي المصدر المشفر رفضًا صريحًا. السبب محدد لا دفاعي: إعادة بناء الشجرة تُدخل كائنات سلاسل جديدة وقد تُدخل مراجع تدفق جديدة، والبند §7.6 يفرض تشفيرها لكل كائن بمفتاح المستند ورقم كائن المالك وجيله. نسخ تذييل /Encrypt إلى الأمام مع إلحاق سلاسل نص صريح سينتج ملفًا يُفتح ثم يُقرأ كقمامة في المكان نفسه الذي توجد فيه مفاتيحك الجديدة، وهو أسوأ من رفض واضح. حدّان آخران يستحقان الذكر بوضوح. يعمل المحرر على لقطة من تدفق المصدر، فلا يجوز تعديل المصدر بينما محرر حي عليه، ويرفض معاملة فهرس المستند كجذر شجرة، لأن الفهرس قاموس يصادف أنه يحوي أشجارًا لا عقدة شجرة بذاتها. إذا كنت تحافظ على كود Delphi أو C++Builder أو Lazarus يكتب /EmbeddedFiles أو /Dests أو /JavaScript أو /PageLabels وكنت ترقع مصفوفات الأوراق يدويًا، فإن محرر الشجرة وحفظه التراكمي المتحقق يُشحنان في مكوّن PDFiumPas PDFium لدلفي