מאמר טכני

פיצול מסמכי PDF עם PDFium Component ב-Delphi

PDFium Component נותן לך מתודה אחת לפיצול PDF: ImportPages. כל השאר, בין שאתה מבודד עמוד יחיד, חותך בגבולות שרירותיים, או הולך לפי מבנה הסימניות של המסמך עצמו, הוא רק דרכים שונות להחליט אילו מספרי עמודים ייכנסו לכל קובץ פלט. המכניקה נשארת זהה. הבנת זה מוקדם חוסכת הרבה פניות שגויות

איך לולאת הפיצול עובדת

התבנית זהה ללא קשר לאופן שבו אתה מחלק את מסמך המקור. צור מופע TPdf חדש, קרא עליו ל-CreateDocument כדי לאתחל PDF ריק בזיכרון, ייבא את העמודים שאתה רוצה עם ImportPages, שמור את התוצאה, ואז אפס את Active ל-False לפני האיטרציה הבאה. השלב האחרון הזה הוא זה שאנשים מפספסים: CreateDocument אינה סוגרת במשתמע את המסמך שעדיין בזיכרון, ולכן אתה חייב לשמור את הפלט שלך ולאפס Active := False במפורש לפני קריאה נוספת לה; איפוס תחילה שומר על מצב נקי ומוגדר היטב. מופע ה-TPdf החיצוני נמצא בשימוש חוזר על פני כל האיטרציות, וזה שומר על לחץ הקצאה נמוך בעבודות גדולות

תרשים של לולאת הפיצול של PDFium Component ב-Delphi: CreateDocument, ImportPages מהמקור לקריאה בלבד, SaveAs עם בדיקת תוצאה, ואיפוס Active לפני כל איטרציה חדשה
מה שלא יקבע את הקבוצות, הלולאה נשארת זהה: ייבא את העמודים, שמור עם בדיקת התוצאה, ואז אפס את Active כך שה-CreateDocument הבא יתחיל ממצב נקי

הנה איך פיצול עמוד אחר עמוד נראה כשהוא מופשט לעיקרו:

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 הוא מחרוזת מספרי עמודים מבוססת אחד; נקודת הכנסה 1 = המיקום הראשון
      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;   // אפס לפני ה-CreateDocument הבא
    end;
  finally
    PdfOut.Free;
  end;
end;

הפרמטר Range של ImportPages הוא אותו פורמט מחרוזת ש-PDFium משתמש בו פנימית: רשימה מופרדת בפסיקים של מספרי עמודים או טווחים מופרדים במקף, כולם מבוססי אחד. '3' מייבא את עמוד 3. '1-5' מייבא את עמודים 1 עד 5 לפי הסדר. '2,5,8' מייבא את שלושת העמודים האלה. הפרמטר השלישי הוא מיקום ההכנסה מבוסס אחד במסמך היעד; העברת 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;
      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 חשוב כאן. ImportPages מחזירה False כשמספר עמוד במחרוזת הטווח חורג מ-Source.PageCount, אבל היא אינה מעלה חריגה והיא אינה מייצרת קובץ פלט חלקי שתוכל לזהות לפי שם בלבד. בדוק את ערך ההחזרה של SaveAs ותעד כשלים בנפרד; טווח שמייצר קובץ פלט ריק אינו שגוי בעליל עד שמישהו פותח אותו

פיצול בגבולות סימניות

הגישה השלישית משתמשת במבנה של המסמך עצמו ולא ברשימה שסופקה מבחוץ. כל סימנייה ברמה העליונה נושאת מספר עמוד יעד; המקטע שהיא מגדירה רץ מהעמוד הזה עד עמוד אחד לפני העמוד של הסימנייה הבאה, או עד סוף המסמך עבור הרשומה האחרונה

תרשים הממפה סימניות PDF ברמה העליונה לטווחי עמודים מחושבים ולקובצי פלט בעת פיצול עם PDFium Component ב-Delphi, כולל סימנייה מחוץ לתחום שמדולגת
מקטע רץ מהעמוד של כל סימנייה ברמה העליונה עד עמוד אחד לפני הסימנייה הבאה, ורשומות שמצביעות מעבר לסוף מדולגות במקום לייצר קבצים ריקים
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;   // דלג על מקטע פגום במקום לכתוב קובץ ריק
      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;

מסמך שאין בו סימניות אינו מצב שגיאה שראוי להציג למשתמש ככזה; זה רק אומר שלמצב הפיצול הזה אין ממה לעבוד. השומר Length(Bm) = 0 מטפל בזה בשקט. מה שכן ראוי להצגה הוא כשמספר העמוד של סימנייה נמצא מחוץ לתחום המסמך, וזה קורה בקבצים פגומים שבהם תוכן העניינים מעולם לא עודכן אחרי מחיקת עמודים. בדיקת הגבולות על StartPage ו-EndPage מדלגת על הרשומות האלה במקום להעביר טווח זבל אל ImportPages

שמות קובצי הפלט ואיפוס Active

בטיחות שמות קבצים עבור שמות שנגזרו מסימניות דורשת תשומת לב מפורשת. כותרות סימניות יכולות להכיל תווים שתקפים במחרוזת PDF אך לא בנתיב של מערכת קבצים. לכל הפחות, החלף קו נטוי, קו נטוי הפוך ונקודתיים לפני בניית נתיב הפלט. ב-Windows גם *, ?, ", <, > ו-| אסורים; לולאה פשוטה על קבוצה קבועה מכסה אותם בלי לגרור ביטויים רגולריים

השורה Active := False בסוף כל איטרציה ראויה להדגשה משום שהיא הדרישה היחידה בתבנית שאינה מובנת מאליה. CreateDocument אינה סוגרת במשתמע את מה שפתוח. אם Active עדיין True כש-CreateDocument רצה שוב, המסמך שעדיין בזיכרון מעולם לא נסגר או נשמר כראוי, ואינך יכול לסמוך על התנהגות מוגדרת היטב במצב הזה, ולכן שמור ואפס במפורש לפני שאתה מתחיל את המסמך הבא. חשוב על זה כעל בן הזוג של try/finally: בלוק ה-finally משחרר את האובייקט החיצוני; ה-Active := False מאפס את מצב המסמך הפנימי בין איטרציות הלולאה

השימוש בזיכרון על פני עבודת פיצול גדולה נשאר שטוח בגישה הזו משום שלעולם אינך מחזיק יותר ממסמך פלט אחד בזיכרון בו זמנית. מסמך המקור נשאר פתוח ולקריאה בלבד לאורך כל הדרך; ImportPages מעתיקה נתוני עמוד אל המסמך החדש בלי לשנות את המקור. אם המקור מוצפן, פתח אותו עם הסיסמה שלו לפני הלולאה והעמודים המועתקים בכל קובץ פלט יהיו לא מוצפנים, וזו בדרך כלל ההתנהגות הנכונה עבור פלט מפוצל שמופץ לנמענים שונים

עוד דבר אחד בנוגע ל-SaveAs: היא מחזירה Boolean. תיקיית פלט שאינה קיימת, נתיב עם תווים שמערכת ההפעלה דוחה, או מצב של דיסק מלא, כולם יגרמו ל-SaveAs להחזיר False בלי להעלות חריגה. בעבודת אצווה שמפצלת מסמך בן 200 עמודים ל-200 קבצים בני עמוד אחד, קל לפספס כשל שקט בעמוד 147. בדוק את ערך ההחזרה בכל קריאה וספור הצלחות מול הסך הצפוי כשהלולאה מסתיימת

המתודות ImportPages ו-CreateDocument שמוצגות כאן הן חלק מ-PDFium Component עבור Delphi ו-C++Builder