Technical Article

تقسيم مستندات PDF باستخدام PDFium VCL في Delphi

يوفر لك PDFium VCL طريقة واحدة لتقسيم PDF: ImportPages. أما كل ما عدا ذلك، سواء كنت تعزل صفحة واحدة، أو تقطع المستند عند حدود تختارها أنت، أو تتبع بنية الإشارات المرجعية الخاصة بالمستند، فهو مجرد طرق مختلفة لتحديد أرقام الصفحات التي ستدخل في كل ملف إخراج. تبقى الآلية نفسها، وفهم ذلك مبكرا يوفر كثيرا من المحاولات الخاطئة.

كيف تعمل حلقة التقسيم

النمط واحد مهما كانت طريقة تقسيم المستند المصدر. أنشئ مثيلا جديدا من TPdf، ثم استدعِ CreateDocument عليه لتهيئة ملف PDF فارغ في الذاكرة، وبعدها استورد الصفحات التي تريدها باستخدام ImportPages، واحفظ النتيجة، ثم أعد Active إلى False قبل التكرار التالي. هذه الخطوة الأخيرة هي التي يغفل عنها كثيرون: من دون إعادة الضبط، فإن استدعاء CreateDocument التالي يضيف إلى المستند الموجود في الذاكرة بدلا من البدء من جديد. يعاد استخدام مثيل TPdf الخارجي عبر كل التكرارات، وهذا يبقي ضغط التخصيص منخفضا في المهام الكبيرة.

إليك كيف يبدو التقسيم صفحة بصفحة بعد اختزاله إلى جوهره:

procedure SplitIntoPages(Source: TPdf; const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;

      // Range is a 1-based page number string; insertion point 1 = first position
      PdfOut.ImportPages(Source, IntToStr(I), 1);

      OutFile := OutputDir + '\page_' + Format('%.4d', [I]) + '.pdf';
      PdfOut.SaveAs(OutFile);

      PdfOut.Active := False;   // reset before next CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

المعامل Range في ImportPages يستخدم نفس صيغة السلسلة التي يستعملها PDFium داخليا: قائمة مفصولة بفواصل من أرقام الصفحات أو نطاقات مفصولة بشرطة، وكلها بأساس 1. '3' يستورد الصفحة 3. '1-5' يستورد الصفحات من 1 إلى 5 بالترتيب. '2,5,8' يستورد هذه الصفحات الثلاث. أما المعامل الثالث فهو موضع الإدراج بأساس 1 في المستند الوجهة؛ وتمرير 1 يضع الصفحات المستوردة دائما في بداية ملف فارغ، وهذا ما تريده هنا.

التقسيم حسب نطاقات الصفحات

عندما يمرر المستدعي قائمة مثل 1-12,13-24,25-36، فإنك تحللها إلى أزواج بداية ونهاية وتشغل الحلقة نفسها، مع بناء سلسلة النطاق من كل زوج:

procedure SplitByRanges(Source: TPdf; const RangeList: array of string;
  const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(RangeList) do
    begin
      PdfOut.CreateDocument;
      PdfOut.ImportPages(Source, RangeList[I], 1);
      OutFile := Format('%s\section_%d.pdf', [OutputDir, I + 1]);
      PdfOut.SaveAs(OutFile);
      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

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

التقسيم عند حدود الإشارات المرجعية

يستخدم الأسلوب الثالث بنية المستند نفسها بدلا من قائمة خارجية. تحمل كل إشارة مرجعية من المستوى الأعلى رقم الصفحة الهدف؛ ويبدأ القسم الذي تحدده من تلك الصفحة وينتهي عند الصفحة السابقة للإشارة المرجعية التالية، أو عند نهاية المستند للإدخال الأخير.

procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  I, StartPage, EndPage: Integer;
  PdfOut: TPdf;
  RangeStr, OutFile, SafeTitle: string;
begin
  Bm := Source.Bookmarks;
  if Length(Bm) = 0 then
    Exit;

  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(Bm) do
    begin
      StartPage := Bm[I].PageNumber;
      if I < High(Bm) then
        EndPage := Bm[I + 1].PageNumber - 1
      else
        EndPage := Source.PageCount;

      if (StartPage < 1) or (EndPage < StartPage) then
        Continue;

      RangeStr := Format('%d-%d', [StartPage, EndPage]);

      PdfOut.CreateDocument;
      PdfOut.ImportPages(Source, RangeStr, 1);

      SafeTitle := StringReplace(Bm[I].Title, '/', '_', [rfReplaceAll]);
      SafeTitle := StringReplace(SafeTitle, ':', '_', [rfReplaceAll]);
      OutFile := Format('%s\%02d_%s.pdf', [OutputDir, I + 1, SafeTitle]);
      PdfOut.SaveAs(OutFile);

      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

المستند الذي لا يحتوي على إشارات مرجعية ليس حالة خطأ تستحق عرضها للمستخدم على أنها خطأ؛ إنه فقط يعني أن وضع التقسيم هذا لا يملك ما يعتمد عليه. يعالج الشرط Length(Bm) = 0 هذا بصمت. وما يستحق الإبلاغ هو عندما يكون رقم صفحة إشارة مرجعية خارج نطاق المستند، وهذا يحدث في الملفات المشوهة التي لم تُحدّث مخططتها بعد حذف الصفحات. يتجاوز فحص الحدود على StartPage وEndPage تلك الإدخالات بدلا من تمرير نطاق غير صالح إلى ImportPages.

تسمية ملفات الإخراج وإعادة ضبط Active

تحتاج سلامة أسماء الملفات المشتقة من الإشارات المرجعية إلى انتباه صريح. يمكن أن تحتوي عناوين الإشارات المرجعية على أحرف صالحة داخل سلسلة PDF لكنها غير صالحة في مسار نظام الملفات. على الأقل، استبدل الشرطة المائلة الأمامية والشرطة المائلة العكسية والنقطتين قبل بناء مسار الإخراج. في Windows، تكون * و? و" و< و> و| محظورة أيضا؛ وتكفي حلقة بسيطة على مجموعة ثابتة لمعالجتها من دون الحاجة إلى تعبير نمطي.

يستحق السطر Active := False في نهاية كل تكرار التأكيد لأنه الشرط الوحيد غير البديهي في هذا النمط. لا يغلق CreateDocument أي مستند مفتوح تلقائيا. إذا بقي Active بقيمة True عندما يعمل CreateDocument مرة أخرى، فإن PDFium يتخلص من المستند الحالي ويبدأ مستندا جديدا من دون خطأ، لكن السلوك هنا يعتمد على التنفيذ في الحالات الطرفية، وتكون النية أوضح عندما تعيد الضبط صراحة. اعتبره النظير المقابل لـ try/finally: كتلة finally تتحرر فيها الكائنات الخارجية، بينما يعيد Active := False ضبط حالة المستند الداخلي بين تكرارات الحلقة.

تبقى الذاكرة المستخدمة أثناء مهمة تقسيم كبيرة ثابتة مع هذا الأسلوب لأنك لا تحتفظ أبدا بأكثر من مستند إخراج واحد في الذاكرة في الوقت نفسه. يظل المستند المصدر مفتوحا وبالقراءة فقط طوال الوقت؛ ينسخ ImportPages بيانات الصفحات إلى المستند الجديد من دون تعديل المصدر. وإذا كان المصدر مشفرا، فافتحه بكلمة المرور الخاصة به قبل الحلقة، وستكون الصفحات المنسوخة في كل ملف إخراج غير مشفرة، وهذا هو السلوك الصحيح عادة عند توزيع مخرجات التقسيم على مستلمين مختلفين.

وأمر أخير بشأن SaveAs: فهو يعيد قيمة من النوع Boolean. فمجلد إخراج غير موجود، أو مسار يحتوي على أحرف يرفضها النظام، أو نفاد المساحة على القرص كلها أسباب تجعل SaveAs يعيد False من دون رفع استثناء. في مهمة دفعة تقسم مستندا من 200 صفحة إلى 200 ملف من صفحة واحدة، يسهل إغفال فشل صامت في الصفحة 147. تحقّق من قيمة الإرجاع في كل استدعاء، ثم قارن عدد النجاحات بالعدد المتوقع عند انتهاء الحلقة.

الطرائق ImportPages وCreateDocument المعروضة هنا هي جزء من PDFium VCL لـ Delphi وC++Builder.