مقاله فنی

تقسیم سندهای PDF به چند فایل با PDFium Component در Delphi

PDFium Component برای تقسیم PDF فقط یک متد در اختیار شما می‌گذارد: ImportPages. باقیِ کار، چه بخواهید یک صفحه را جدا کنید، چه سند را در مرزهای دلخواه ببرید و چه ساختار bookmarkهای خود سند را دنبال کنید، فقط شکل‌های متفاوتی از این تصمیم هستند که کدام شماره‌صفحه‌ها باید وارد هر فایل خروجی شوند. خودِ سازوکار تغییر نمی‌کند. اگر این را زود بفهمید، از خیلی انحراف‌های اشتباه جلوگیری می‌شود

حلقه تقسیم چگونه کار می‌کند

الگو مستقل از نوع تقسیم‌بندی سند منبع یکسان است. یک نمونه تازه از TPdf بسازید، روی آن CreateDocument را صدا بزنید تا یک PDF خالی در حافظه ساخته شود، صفحه‌هایی را که می‌خواهید با ImportPages وارد کنید، نتیجه را ذخیره کنید و پیش از تکرار بعدی Active را دوباره روی False بگذارید. همین مرحله آخر چیزی است که معمولاً از قلم می‌افتد: CreateDocument سندی را که هنوز در حافظه باز است به طور ضمنی نمی‌بندد، پس باید خروجی را ذخیره کنید و پیش از فراخوانی بعدی آن، Active := False را صریحاً انجام دهید؛ این reset وضعیت را تمیز و تعریف‌شده نگه می‌دارد. نمونه بیرونی TPdf در همه تکرارها دوباره استفاده می‌شود و همین فشار تخصیص را در jobهای بزرگ پایین نگه می‌دارد

این هم شکل ساده‌شده تقسیم صفحه‌به‌صفحه در اساسی‌ترین حالت

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
      if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
        raise Exception.CreateFmt('Failed to import page %d', [I]);

      OutFile := OutputDir + '\page_' + Format('%.4d', [I]) + '.pdf';
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + 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 همیشه صفحه‌های واردشده را در ابتدای یک فایلِ در غیر این صورت خالی قرار می‌دهد و اینجا دقیقاً همین را می‌خواهید

تقسیم بر اساس بازه‌های صفحه

وقتی caller فهرستی مثل 1-12,13-24,25-36 می‌دهد، آن را به زوج‌های آغاز/پایان parse می‌کنید و همان حلقه را اجرا می‌کنید، با این تفاوت که این بار رشته بازه از هر زوج ساخته می‌شود

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;
      if not PdfOut.ImportPages(Source, RangeList[I], 1) then
        raise Exception.Create('Invalid page range: ' + RangeList[I]);
      OutFile := Format('%s\section_%d.pdf', [OutputDir, I + 1]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);
      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

در اینجا اعتبارسنجی پیش از رسیدن به ImportPages مهم است. وقتی شماره‌صفحه‌ای در رشته بازه از Source.PageCount عبور کند، ImportPages مقدار False برمی‌گرداند، اما استثنا پرتاب نمی‌کند و فایل خروجی نیمه‌کاره‌ای هم نمی‌سازد که فقط از روی نام آن متوجه شوید. نتیجه SaveAs را هم باید بررسی کنید و شکست‌ها را جداگانه ثبت کنید؛ بازه‌ای که فایل خروجی خالی تولید کرده، لزوماً تا وقتی کسی آن را باز نکند واضح به نظر نمی‌رسد

تقسیم در مرز bookmarkها

روش سوم به جای یک فهرست بیرونی، از ساختار خود سند استفاده می‌کند. هر bookmark سطح بالا یک شماره‌صفحه مقصد دارد؛ بخشی که آن bookmark تعریف می‌کند از همان صفحه شروع می‌شود و تا یک صفحه پیش از bookmark بعدی ادامه می‌یابد، یا اگر آخرین مورد باشد تا انتهای سند کشیده می‌شود

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;
      if not PdfOut.ImportPages(Source, RangeStr, 1) then
      begin
        PdfOut.Active := False;
        Continue;   // skip a malformed section instead of writing an empty file
      end;

      SafeTitle := StringReplace(Bm[I].Title, '/', '_', [rfReplaceAll]);
      SafeTitle := StringReplace(SafeTitle, ':', '_', [rfReplaceAll]);
      OutFile := Format('%s\%02d_%s.pdf', [OutputDir, I + 1, SafeTitle]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

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

سندی که اصلاً bookmark ندارد، وضعیتی نیست که لازم باشد آن را مثل یک خطا به کاربر نشان دهید؛ فقط یعنی این شیوه تقسیم چیزی برای کار کردن ندارد. guard مربوط به Length(Bm) = 0 این حالت را بی‌صدا مدیریت می‌کند. چیزی که ارزش گزارش کردن دارد زمانی است که شماره‌صفحه یک bookmark بیرون از محدوده سند باشد؛ حالتی که در فایل‌های معیوبی رخ می‌دهد که بعد از حذف صفحه‌ها، outline آن‌ها هرگز به‌روز نشده است. بررسی حدود روی StartPage و EndPage این مدخل‌ها را رد می‌کند، به جای اینکه یک بازه بی‌معنا را به ImportPages بدهد

نام‌گذاری فایل خروجی و reset کردن Active

امنیت نام فایل برای نام‌هایی که از bookmark مشتق می‌شوند نیازمند توجه صریح است. عنوان bookmark ممکن است نویسه‌هایی داشته باشد که در یک رشته PDF معتبرند اما در مسیر فایل‌سیستم معتبر نیستند. حداقل باید پیش از ساختن مسیر خروجی، اسلش رو به جلو، بک‌اسلش و دونقطه را جایگزین کنید. در Windows، *، ?، "، <، > و | هم ممنوع هستند؛ یک حلقه ساده روی مجموعه‌ای ثابت از این نویسه‌ها کافی است و نیازی به regex ندارد

خط Active := False در انتهای هر تکرار ارزش تأکید دارد، چون تنها الزام غیربدیهی این الگو است. CreateDocument چیزی را که از قبل باز است به صورت ضمنی نمی‌بندد. اگر هنگام اجرای دوباره آن، Active هنوز True باشد، سندی که در حافظه بوده به شکل درست بسته یا ذخیره نشده است و در چنین وضعیتی نمی‌توانید روی رفتار کاملاً تعریف‌شده حساب کنید، پس پیش از شروع سند بعدی، ذخیره و reset را صریح انجام دهید. به آن مثل جفت try/finally نگاه کنید: finally شیء بیرونی را آزاد می‌کند و Active := False وضعیت سندِ درونی را بین تکرارهای حلقه بازنشانی می‌کند

مصرف حافظه در یک job بزرگِ تقسیم با این رویکرد تقریباً ثابت می‌ماند، چون در هر لحظه فقط یک سند خروجی در حافظه دارید. سند منبع در تمام مدت باز و فقط‌خواندنی می‌ماند؛ ImportPages داده صفحه‌ها را به سند تازه کپی می‌کند، بدون اینکه منبع را تغییر دهد. اگر سند منبع رمزگذاری‌شده باشد، آن را پیش از شروع حلقه با رمز درست باز کنید و صفحه‌هایی که به فایل‌های خروجی کپی می‌شوند بدون رمز خواهند بود که معمولاً برای خروجی‌های تقسیم‌شده‌ای که به گیرنده‌های مختلف می‌فرستید، رفتار درستی است

یک نکته دیگر هم درباره SaveAs وجود دارد: خروجی آن یک Boolean است. مسیر خروجی‌ای که وجود ندارد، نام فایلی که نویسه‌های نامعتبر برای سیستم عامل دارد یا پر بودن دیسک، همگی باعث می‌شوند SaveAs مقدار False برگرداند، بی‌آنکه استثنایی پرتاب شود. در یک job دسته‌ای که سندی 200 صفحه‌ای را به 200 فایل تک‌صفحه‌ای می‌شکند، شکست خاموش در صفحه 147 خیلی راحت از چشم می‌افتد. نتیجه هر فراخوانی را بررسی کنید و وقتی حلقه تمام شد تعداد موفق‌ها را با تعداد مورد انتظار مقایسه کنید

متدهای ImportPages و CreateDocument که اینجا نشان داده شدند بخشی از PDFium Component برای Delphi و C++Builder هستند