مقال تقني

فهرس كائنات PDF المتفرق الكسول في Delphi عبر PDFiumPas

تريد قاموسًا واحدًا من ملف PDF حجمه 2 غيغابايت فتقوم الأداة أولًا بمدّ جدول المراجع التبادلية كله في مصفوفة بحجم /Size الخاص بالمقطورة. يستبدل PDFiumPas تلك الخطوة بفهرس كائنات متفرق كسول: يحتفظ فقط بواصفات أقسام xref، ويحلّ رقم كائن واحدًا عند الطلب عبر نوافذ محدودة، ويخزّن مؤقتًا المداخل التي لمستها فعلًا فقط

كان الشكل القديم لهذا الكود في FPdfCompress صريحًا لكنه مكلف. قرأت ApplyDefaultOpenAction الملف كاملًا في TBytes واحدة، ثم خصّصت مصفوفة TPdfActiveXrefEntries كثيفة بخانة واحدة لكل رقم كائن حتى /Size. أخطأ شيئان عند الحجم الكبير. كلفة القراءة نمت خطيًا مع حجم المستند حتى عندما كان المستدعي يريد أربعة قواميس، والمصفوفة الكثيفة تصادمت مع ميزانية المحلّل: TPdfParserResourceBudget.Default يضبط MaxObjects على 4,000,000، لذا فإن ملفًا صالحًا تمامًا يجلس أعلى رقم كائن فيه فوق ذلك السقف كان يُرفض بحجة ذاكرة لا بحجة صحة

فهرس كائنات PDFiumPas المتفرق الكسول في Delphi مقارنةً بمصفوفة مراجع تبادلية كثيفة: المسار الكثيف يقرأ الملف كله ويخصّص خانة لكل رقم كائن حتى حجم المقطورة، بينما المسار المتفرق يحتفظ بواصفات الأقسام فقط
الواصفات وحدها تبقى في الذاكرة، والمداخل تبقى في الملف، وكل قراءة تمر عبر نافذة محدودة حجمها ميبيبايت واحد

لماذا لا تجيب واجهة API العمومية لـ PDFium عن هذا السؤال؟

لأن المعلومة موجودة داخل PDFium لكنها لا تعبر حدود C أبدًا. يحتفظ CPDF_Parser بجدول المراجع التبادلية وعضوية تيارات الكائنات وأسبقية المراجعات داخليًا، لكن الترويسات المنشورة لا تكشف أي نقطة دخول تأخذ رقم كائن وتُرجع إزاحته الخام، أو رقم جيله، أو أي مراجعة فازت، أو أي ObjStm يعيش فيه. جانب الحفظ مغلق بالقدر نفسه: FPDF_SaveAsCopy وFPDF_SaveWithVersion يسلّمانك رد كتابة تسلسليًا فقط. لذا فإن أي ترقيع على مستوى البايتات للكتالوج بعد حفظ أصيل يجب أن يُبنى في طبقة Pascal، ولهذا يحلّل PDFiumPas هذه البنى بنفسه بدلًا من إعادة استخدام DLL

ماذا يحتفظ الفهرس المتفرق في الذاكرة فعلًا؟

واصفات، لا مداخل. للجدول الكلاسيكي (ISO 32000-1 §7.5.4) يخزّن TPdfSparseXrefSubsection رقم الكائن الأول، وعدد الكائنات، وإزاحة البايت حيث تبدأ صفوف المداخل، وعرض المدخل المقيس. المداخل نفسها تبقى في الملف. العرض يُقاس من الصف الأول بدلًا من افتراضه 20 بايتًا، لأن المنتجين يختلفون حول نهايات الأسطر؛ يقبل PDFiumPas من 18 إلى 64 ويرفض أي شيء خارج ذلك النطاق، مع أي قسم فرعي يتجاوز عده المعلَن نهاية التيار. بالنسبة إلى تيار المراجع التبادلية (§7.5.8) يحمل القسم عروض الحقول الثلاثة /W، كل منها محصور بين 0 و8، وأزواج /Index المفلطحة، وبايتات المداخل المفكوكة، التي يُحسب طولها المتوقع من /W و/Index قبل نفخ بايت واحد

الفهرس كله يُبنى بواسطة Initialize من نافذة ذيل حجمها 1 MiB على الأكثر، حيث يُعثر على startxref، وكل قراءة كائن لاحقة تستخدم نافذة كائن حجمها 1 MiB. سقف التيار الخام هو 64 MiB وقد لا يتجاوز سطر xref واحد 1024 بايت. إذا قرأت ملاحظتنا حول التحقق من كائنات وتيارات المراجع التبادلية باستخدام PDFiumPas، فإن انضباط عرض الحقول نفسه ينطبق هنا، لكنه الآن يُستخدم لعنونة مدخل واحد بدلًا من تدقيق جدول كامل

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { يجتاز startxref وسلسلة /Prev والكتالوج فقط }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

كيف تصل عملية بحث واحدة إلى كائن واحد؟

بالحساب، في التخطيطين كلاهما. القسم الفرعي الكلاسيكي له صفوف بعرض ثابت، لذا فإن عنوان المدخل هو بداية القسم الفرعي مضافًا إليها إزاحة الكائن مضروبة في العرض المقيس؛ يقرأ PDFiumPas بعدها ذلك السطر الواحد، ويحلّل الإزاحة ذات الأرقام العشرة ورقم الجيل ذي الأرقام الخمسة، ويتحقق من رقم الجيل مقابل سقف 65535 من §7.5.4، ويصنّف الكلمة المفتاحية الختامية بوصفها axkDirect أو axkFree. تيار المراجع التبادلية يحتاج خطوة إضافية لأن الأقسام الفرعية لـ /Index متسلسلة في سيل البايتات المفكوكة، لذا يجمع الفهرس عدّادات الأقسام الفرعية السابقة قبل الضرب في عرض /W المجموع. النوع 1 يُسفر عن إزاحة، والنوع 2 يُسفر عن رقم تيار كائنات وفهرس عضو، وأي شيء سواه يصبح axkUnknown بدلًا من تخمين

{ الجدول الكلاسيكي، القسم 7.5.4 من ISO 32000-1 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ تيار المراجع التبادلية، القسم 7.5.8 من ISO 32000-1 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

لا شيء في المسارين يتناسب مع /Size. هذه هي فكرة إعادة الكتابة كاملة: قيمة حجم المقطورة تُنقل إلى الأمام بوصفها بيانات وصفية وتُستخدم عند كتابة المراجعة التزايدية، لكنها لا تقود أي تخصيص أبدًا. تثبّت مجموعة اختبارات الانحدار هذا بملف عيّنة تعيش شجرة صفحاته عند الكائن 1,000,000,000 و1,000,000,001 تحت مقطورة تعلن /Size 1000000002. التنفيذ الكثيف القديم رفض ذلك الملف؛ الفهرس المتفرق يحلّ المرجعين ويحفظ الحجم المعلَن في مقطورة الإخراج

كيف يحلّ PDFiumPas رقم كائن واحدًا في Delphi: جدول مراجع تبادلية كلاسيكي يضرب عرض الصف المقيس، بينما تيار المراجع التبادلية يجمع عدّادات الأقسام الفرعية السابقة قبل ضرب عروض الحقول المجموعة من مصفوفة /W
عمليتا البحث كلتاهما حساب خالص، لذا لا تتناسب أي منهما مع عدد الكائنات المعلَن في المقطورة

المراجعات الهجينة وسلاسل /Prev والحراس حولها

أسبقية المراجعة هي حيث يخطئ الفهرس الكسول الساذج. يجتاز PDFiumPas السلسلة من startxref بترتيب الأحدث أولًا ويوقف البحث عند أول قسم يجيب، وهو ما يُعيد إنتاج قاعدة الأسبقية دون تجسيد جدول مدموج. ملفات المراجع الهجينة (§7.5.8.4) تُعالَج داخل الفرع الكلاسيكي: عندما تحمل المقطورة /XRefStm، يُسجَّل قسم التيار التكميلي قبل القسم الكلاسيكي الذي أشار إليه، بحيث تظل الكائنات المضغوطة غير المرئية للجدول البسيط قابلة للعثور بينما تحتفظ المداخل الكلاسيكية بمكانتها. تُتتبَع المراجعات الأقدم بعدها عبر /Prev

حارسان يحدّان ذلك الاجتياز، وكلاهما مهم على الملفات التالفة. كل إزاحة تُزار تُسجَّل، لذا فإن /Prev الذي يشير عائدًا إلى داخل السلسلة ينتهي بدلًا من الدوران، وعمق الاجتياز محدود بـ MaxRecursionDepth، الذي افتراضيُه 1024. علم التشفير يُتراكم عبر السلسلة كلها بدلًا من قراءته من أحدث مقطورة وحدها، لأن المستند الذي تغفل مقطورته الأحدث عن /Encrypt قد يظل مشفّرًا في موضع أعمق؛ المستدعون الذين يلحقون مراجعات يعتمدون على ذلك العلم لرفض كتابة كائنات بنص صريح في ملف مشفّر

كيف يجتاز PDFiumPas سلسلة مراجعات PDF هجينة في Delphi: تُسجَّل الأقسام الأحدث أولًا انطلاقًا من startxref، وقسم XRefStm التكميلي يتقدّم على الجدول الكلاسيكي الذي سمّاه، واجتياز /Prev محدود بالإزاحات المزارة وبسقف عمق
البحث يتوقف عند أول قسم يجيب، وهو ما يُعيد إنتاج أسبقية المراجعة دون تجسيد جدول مدموج إطلاقًا

مداخل النوع 2: لماذا ينتظر تيار الكائنات

مدخل النوع 2 يسمّي تيار كائنات، ولا يلمس PDFiumPas ذلك التيار حتى يطلب مستدعٍ عضوًا منه. عندما يفعل أخيرًا، يُتحقَّق من /Type /ObjStm، ويُفحَص /N مقابل ميزانية الكائنات و/First مقابل سقف البايتات المفكوكة، ويُفحَص /N منطقيًا مقابل /First بما أن كل زوج ترويسة يحتاج أربعة بايتات على الأقل. عندها فقط يُنفَخ التيار، ويتوقف مسح الترويسة عند العضو المطلوب وخلفه بدلًا من بناء جدول أعضاء كامل. يُحتفظ بتيار كائنات مفكوك واحد في كل مرة، وهو المقايضة الصحيحة عندما تتجمع فرع من شجرة الصفحات في ObjStm واحد؛ تغطي كتابتنا حول فك ترميز تيارات الكائنات والمتنبئات في Delphi ما يحدث داخل خطوة النفخ تلك (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { فهرس واحد محتفَظ به، وقراءات كثيرة مدركة لأرقام الجيل }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { يبقى المصدر ملكك }
  end;
end;

حيث يتوقف التخزين المؤقت عن تقديم الوعود

الفهرس لقطة، ويستحق الأمر الصراحة في ذلك. الأقسام تُحلَّل مرة واحدة في Initialize؛ إذا عُدِّل التيار الأساسي بعدها، فكل مدخل مخزَّن مؤقتًا قديم ولن تلاحظ الفئة ذلك. يحتفظ TPdfSparseDictionaryReader بالفهرس طوال عمر المصدر المملوك للمستدعي، وهو بالضبط ما يريده اجتياز تكراري فوق شجرة صفحات وبالضبط ما يجب ألا تفعله عبر إعادة كتابة. ذاكرة المداخل المؤقتة مصفوفة مسطحة تُبحث خطيًا وتخزّن النتائج السالبة أيضًا، لذا فبضع مئات من عمليات البحث رخيصة وبضع مئات الآلاف ليست كذلك. ReadDictionary تتطلب تطابق جيل تام بينما ReadLatestDictionary تحلّل الجيل النشط، والفرق مقصود: حلّ المراجع يحتاج الأولى، وتفتيش الكتالوج يحتاج الثانية. حيث لا يمكن احترام هذه الحدود، تتراجع الوحدات المحيطة إلى محلّل الملف كامل القديم بدلًا من تضييق مجموعة الملفات التي لا تزال تعمل، وهو نمط نستخدمه أيضًا في بث ملفات PDF الكبيرة عند الطلب

تغطي اختبارات الانحدار عبر المترجمات السلوك نفسه على سلاسل الأدوات الثلاث جميعها، بما في ذلك تأكيد على أن مصدرًا حجمه 2 MiB لا يشهد أبدًا قراءة واحدة أكبر من 1 MiB. إذا كنت تحافظ على كود Delphi أو C++Builder أو Lazarus يلمس بنية PDF مباشرة وسئمت دفع تكاليف تحليل الملف كامل مقابل أربعة قواميس، فإن الفهرس المتفرق والواجهة العمومية المحيطة به يأتيان ضمن مكوّن PDFiumPas Delphi PDFium