مقال تقني

أداء استخراج صفحات HotPDF في Delphi

دقيقتان لنسخ ثلاث صفحات من ملف PDF من 40 صفحة ليست مشكلة ضبط أداء. إنها إشارة إلى أن مسار API الخاطئ يُستخدم. عندما رأيت هذا الزمن لأول مرة في نموذج نسخ الصفحات الخاص بـ HotPDF Delphi Component، كان حدسي أن أفحص بنية المستند أولاً ثم الشفرة. اتضح أن هذا الترتيب مهم

ما كان بطيئًا فعليًا

كان ملف PDF المعني مستندًا مرجعيًا من 40 صفحة مع شجرة صفحات غير بسيطة، أي عدة عقد /Pages وسيطة بدل مصفوفة مسطحة واحدة. كانت الشفرة النموذجية الأصلية تستدعي LoadFromFile، ثم تبني مستندًا جديدًا بـ BeginDoc، ثم تدور على أرقام الصفحات المحددة، وفي كل تكرار تعيد تحميل المستند المصدر من القرص لاستخراج صفحة. هذا يعني كلفة التحليل الكاملة مضروبة في عدد الصفحات المطلوبة. ملف بحجم 12 ميغابايت اصطدم بالقرص ست مرات لاستخراج ثلاث صفحات لأن أحدًا لم يسأل هل يجب أن يبقى الملف مفتوحًا عبر التكرارات

العامل الثاني لم يكن ظاهرًا في الشفرة: إن LoadFromFile في HotPDF يحل جدول المراجع المتقاطعة بالكامل ويفك ضغط كل تدفق كائن عند التحميل. هذا سلوك صحيح لمستند أنت على وشك تعديله، لكنه عمل أكثر مما تحتاجه إذا كنت تريد فقط عدد الصفحات ومجموعة فرعية منها. وللوصول للهيكل بوضع القراءة فقط، فإن DAOpenFileReadOnly يتجنب تسلسل شجرة الكائنات كاملة، وهذا مهم مع الملفات المضغوطة التي تحتوي على موارد صور كبيرة

ولا واحد من هذين خطأ في المكتبة. كلاهما نتيجة اختيار استدعاء API صمم لمهمة واحدة ثم استخدامه لمهمة أخرى

مخطط يقابل استدعاءات LoadFromFile المتكررة داخل حلقة استخراج بتحليل واحد يليه InsertPagesFromDocument لاستخراج صفحات PDF سريع في Delphi
إعادة تحميل المصدر في كل تكرار تضاعف كلفة التحليل الكامل محوّلةً نسخة من ثلاث صفحات إلى عمل لدقيقتين. وتُدخل عملية تحليل واحدة يتبعها إدراج جماعي العمل نفسه دون ثانيتين

استخدام InsertPagesFromDocument لاستخراج الصفحات

المسار الصحيح لنسخ نطاق من الصفحات من مستند HotPDF إلى آخر هو InsertPagesFromDocument بعد LoadFromFile على المستند المصدر. تحمل المصدر مرة واحدة، ثم تحمل الوجهة أو تنشئها مرة واحدة، ثم تنقل الصفحات وتحفظ النتيجة. ويبقى المصدر في الذاكرة طوال عمليات إدراج الصفحات:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // حمّل المصدر مرة واحدة: يحدث التحليل الكامل هنا وفقط هنا
    Source.LoadFromFile(SourceFile);

    // ابنِ مستندًا وجهة أدنى حد
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // انسخ النطاق المطلوب؛ '1-3' يُدرج الصفحات من 1 إلى 3
    // بدءًا من الموضع 1 في المستند الوجهة
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

يقبل المعامل PageRange الصيغة نفسها التي يستخدمها المثال سطر الأوامر: قائمة مفصولة بفواصل لأرقام الصفحات أو للنطاقات مثل '1-3' أو '1,5,7-9'. الصفحات هنا تبدأ من 1. ينسخ InsertPagesFromDocument تدفقات المحتوى وقواميس الموارد وهندسة الصفحة من دون لمس البيانات الوصفية أو الإشارات المرجعية أو مرفقات الملفات المضمنة ما لم تكن تلك العناصر مشارًا إليها من الصفحات المنسوخة. لاستخراج ثلاث صفحات من مستند من 40 صفحة، هذه مجموعة عمل صغيرة

الزمن على الملف نفسه بحجم 12 ميغابايت الذي كان يستغرق دقيقتين سابقًا أصبح أقل من 1.5 ثانية بهذا النمط. ومعظم هذا الزمن هو استدعاء LoadFromFile الوحيد. بنية المستند تصبح غير مهمة بعد حل جدول الكائنات للمرة الأولى

عندما يكون LoadFromFile أكثر مما تحتاجه: واجهة Direct File API

إذا كنت تحتاج فقط إلى عد الصفحات أو فحص معلومات المستند أو نسخ ملف من دون لمس محتواه، فإن Direct File API تتجنب التحليل الكامل تمامًا. يربط DAOpenFileReadOnly جدول المراجع المتقاطعة من دون فك ضغط تدفقات الكائنات، لذلك يصبح عدد الصفحات O(xref size) بدلًا من O(file size):

procedure InspectPDF(const FileName: string);
var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly(FileName, '');
    if Handle <= 0 then
      Exit;
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      Writeln('Pages: ', PageCount);

      // DACopyFile نسخة حافظة بالبايت، بلا إعادة تسلسل
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

الاستثناء هنا أن DAOpenFileReadOnly يقبل معامل كلمة مرور لكنه يعود إلى تحليل كامل مع المدخلات المشفرة، لأن فك التشفير يتطلب أن تحل شجرة الكائنات قاموس التشفير. إذا كانت ملفاتك المصدرية مشفرة، ففكها أولًا باستخدام DecryptFile للحصول على نسخة غير مشفرة، ثم افتح تلك النسخة عبر Direct File API. وظيفة DecryptFile على مستوى الملف تأخذ مسار إعادة كتابة مباشرًا بـ AES-256 للتشفير القياسي، وهي أسرع من LoadFromFile متبوعًا بـ SaveLoadedDocument للملفات الكبيرة لأنها لا تبني نموذج الكائنات الكامل في الذاكرة

مخطط قرار يختار بين مسار التحرير الكامل في HotPDF و Direct File API للقراءة فقط، مع معالجة فك التشفير أولًا للمدخلات المشفرة
تنتمي المهام للقراءة فقط إلى مسار الوصول المباشر للملف، حيث يتدرج عدّ الصفحات مع جدول الإسناد المتقاطع بدل حجم الملف. وتراجع المدخلات المشفرة إلى تحليل كامل ما لم تفكّ ترميزها أولاً

الذاكرة أثناء المعالجة على دفعات كبيرة

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

والحل ليس غريبًا. كل THotPDF وكل TStream أو TBitmap وسيط يشارك في عمل PDF يجب أن يكون داخل كتلة try/finally حيث يكون Free آخر تعليمة. اضبط المؤشرات المحلية على nil قبل try حتى تستطيع كتلة finally استخدام if Assigned(x) then x.Free بأمان إذا فشلت التهيئة في منتصف الطريق. هذه هي ملكية Delphi القياسية، وهي القصة الكاملة لهذه الفئة من المشكلات

وهناك نقطة أخرى يجب فحصها في سياقات الدُفعات: إن AddImage يسجل الصور في قائمة داخلية تبقى طوال عمر مثيل THotPDF. إذا أعدت استخدام مثيل واحد عبر مستندات كثيرة باستدعاء LoadFromFile مرارًا، فإن تسجيلات الصور من المستندات السابقة تبقى في القائمة. إما أن تنشئ مثيلًا جديدًا لكل مستند أو تستدعي مسار تنظيف قائمة الصور بين المستندات

القياس قبل تغيير أي شيء

قبل اللجوء إلى أي من هذه الأنماط، قس الزمن. يغلّف TStopwatch في Delphi من System.Diagnostics دالة QueryPerformanceCounter وهو دقيق بما يكفي لقياس زمن تشغيل عمليات I/O للملفات. لفّ LoadFromFile وحده وانظر كم يستحوذ من الزمن. إذا كان 90% من الزمن الكلي، فالحل هو Direct File API أو تقليل عدد مرات تحليل الملف نفسه. وإذا كان أقل من 20% فالعنق موجود في مكان آخر وأنت تطارد الشيء الخطأ

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

واجهة معالجة الصفحات المعروضة هنا جزء من HotPDF Delphi Component لـ Delphi و C++Builder