מאמר טכני

ביצועי חילוץ דפים של HotPDF ב-Delphi

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

מה בעצם היה איטי

ה-PDF המדובר היה מסמך עזר בן 40 דפים עם עץ דפים לא טריוויאלי: מספר צומתי /Pages ביניים במקום מערך שטוח יחיד. קוד הדוגמה המקורי קרא ל-LoadFromFile, ואז בנה מסמך חדש עם BeginDoc, עבר בלולאה על מספרי הדפים שנבחרו, ובכל איטרציה טען שוב את מסמך המקור מהדיסק כדי לשלוף דף. זוהי עלות ניתוח מלאה מוכפלת במספר הדפים שאתה רוצה. קובץ של 12 מגה-בייט ניגש לדיסק שש פעמים עבור חילוץ של שלושה דפים מכיוון שאיש לא בדק אם הקובץ צריך להישאר פתוח בין האיטרציות

התורם השני היה בלתי נראה בקוד: ה-LoadFromFile של HotPDF מפענח את כל טבלת ההפניות המקושרות ופורס כל זרם אובייקטים בעת הטעינה. זוהי ההתנהגות הנכונה עבור מסמך שאתה עומד לשנות, אך זו עבודה רבה ממה שאתה צריך אם אתה רוצה רק ספירת דפים ותת-קבוצה של דפים. עבור גישה לקריאה בלבד למבנה, DAOpenFileReadOnly נמנע מביטול סריאליזציה של עץ האובייקטים המלא, דבר שחשוב בקבצים דחוסים עם משאבי תמונה גדולים

אף אחד מאלה אינו באג בספרייה. שניהם הם מקרים של קוראים הבוחרים את ה-API שתוכנן למשימה אחת ומשתמשים בו למשימה אחרת

שימוש ב-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
    // Load source once: full parse happens here and only here
    Source.LoadFromFile(SourceFile);

    // Build a minimal destination document
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Copy the requested range; '1-3' inserts pages 1 through 3
    // starting at position 1 in the destination
    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) במקום O(גודל הקובץ):

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 is a byte-preserving copy, no re-serialization
      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 עבור קבצים גדולים, מכיוון שהיא אינה בונה את מודל האובייקטים המלא בזיכרון

זיכרון במהלך עיבוד אצוות גדולות (large-batch)

משימות אצווה המעבדות עשרות קבצים בלולאה כוללות תבנית שנראית נכונה אך צוברת זיכרון: יצירת 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 ומדויק מספיק לניתוח ביצועים במונחי זמן קיר של קלט/פלט קבצים. עטוף את LoadFromFile לבדו וראה על כמה זמן הוא אחראי. אם מדובר ב-90% מהזמן הכולל, התיקון הוא ה-Direct File API או צמצום מספר הפעמים שאתה מנתח את אותו קובץ. אם זה מתחת ל-20%, צוואר הבקבוק נמצא במקום אחר ואתה רודף אחרי הדבר הלא נכון

החילוץ של שתי דקות שהתחיל פוסט זה התברר ככולו תבנית הטעינה החוזרת. מבנה המסמך לא תרם דבר; עץ דפים שטוח היה רץ באותה צורה. מעבר ל-LoadFromFile יחיד ואחריו קריאה אחת ל-InsertPagesFromDocument הביא אותו ל-1.3 שניות על אותה חומרה מבלי לגעת בשום דבר אחר

ממשק ה-API למניפולציית דפים המוצג כאן הוא חלק מ-HotPDF Component עבור Delphi ו-C++Builder