מאמר טכני

תור עיבוד PDF ברקע ב-Delphi עם HotPDF

מחלקת THPDFBackgroundRenderer של HotPDF היא נצר של TThread שמעבדת עמודי PDF טעונים לביטמאפים על תהליכון עבודה (worker thread), כך שמציג ב-Delphi יכול להמשיך לגלול ולצייר מחדש בעוד עמוד עדיין עובר רסטריזציה ברקע. THPDFBackgroundRenderer.RequestPage מכניסה אינדקס עמוד לתור עבור תהליכון העבודה הזה, CancelAll מפילה כל מה שעדיין ממתין, ו-GetCachedBitmap מוסרת בחזרה ביטמאפ מוגמר שהקוד הקורא הוא הבעלים שלו וחייב לשחרר אותו. גלילה בחוזה סרוק בן מאתיים עמודים ברזולוציית הדפסה על תהליכון ה-UI בלבד גורמת לכך שכל הפיכת עמוד משהה את החלון עד ש-GDI מסיים לצייר אותו, וזה בדיוק הגמגום ש-THPDFBackgroundRenderer קיימת כדי להסיר

למה בכלל לעבד עמודי PDF על תהליכון רקע?

תהליכון רקע מרוויח את המורכבות שלו משום שמנוע העיבוד של HotPDF הוא מפרש זרם-תוכן אמיתי, לא העתקת ביטמאפ זולה שחוזרת לפני שמישהו שם לב: הוא עובר על אופרטורי PDF, שומר מחסנית מצב-גרפיקה, ומבצע רסטריזציה לנתיבים, תמונות וגליפים דרך GDI, אותו מנוע המתואר בעיבוד עמודי PDF טעונים ל-TBitmap. הרצת העבודה הזו באופן סינכרוני בתוך handler של גלילה או ציור עוצרת את לולאת ההודעות מלשאוב עד שהקריאה חוזרת, וזה בדיוק מה שחלון קפוא הוא בפועל. הכנסת Application.ProcessMessages בתוך קריאת העיבוד לא פותרת את זה: היא מאפשרת לתור ההודעות להתרוקן, אבל העיבוד עצמו עדיין מחזיק את התהליכון הקורא, כך שהחלון מצייר מחדש תוכן מיושן מהר יותר בעוד העבודה האמיתית לא זזה לשום מקום. הדרך היחידה לשמור על מציג רספונסיבי במהלך עיבוד באמת איטי היא להריץ את העיבוד הזה במקום אחר, וזו הסיבה ש-THPDFBackgroundRenderer קיימת כתת-מחלקה של TThread במקום callback או טיימר

הקמת תור בקשות עבור מציג גולל

THPDFBackgroundRenderer.Create מקבלת את מופע ה-THotPDF הטעון ו-DPI שנשאר קבוע לאורך כל חיי מעבד העמודים הזה, כך שכל עמוד שמוכנס לתור דרך מופע אחד מעובד ברזולוציה אחת; מציג שתומך בזום זקוק למעבד עמודים חדש, לא למאפיין DPI חדש, בכל פעם שרמת הזום משתנה. RequestPage מוסיפה אינדקס עמוד לתור פנימי וחוזרת מיד: היא לא מבצעת שום עיבוד בעצמה ואף פעם לא נוגעת בתהליכון ה-UI. Execute, נקודת הכניסה היורשת של TThread ש-HotPDF מריצה ברגע שקוראים ל-Start, שולפת אינדקס אחד מקדמת התור בכל פעם, מעבדת אותו דרך מטמון העמודים של המסמך, ושומרת עותק ממופתח לפי עמוד כך ש-GetCachedBitmap יכולה למסור אותו בחזרה מאוחר יותר

type
  TViewerForm = class(TForm)
    RenderPollTimer: TTimer;
    procedure RenderPollTimerTimer(Sender: TObject);
  private
    FDoc: THotPDF;
    FRenderer: THPDFBackgroundRenderer;
    FPendingPage: Integer;
    procedure RequestPageWindow(CenterPage: Integer);
  end;

procedure TViewerForm.RequestPageWindow(CenterPage: Integer);
var
  I: Integer;
begin
  if FRenderer <> nil then
  begin
    FRenderer.CancelAll;
    FRenderer.Free;
  end;
  FRenderer := THPDFBackgroundRenderer.Create(FDoc, 150);
  for I := CenterPage - 1 to CenterPage + 1 do
    if (I >= 0) and (I < FDoc.LoadedPageCount) then
      FRenderer.RequestPage(I);
  FPendingPage := CenterPage;
  FRenderer.Start;
end;

procedure TViewerForm.RenderPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  if FRenderer = nil then Exit;
  Bmp := FRenderer.GetCachedBitmap(FPendingPage);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

GetCachedBitmap מחזירה nil עד שהעותק של אותו עמוד מוכן, כך שתבנית poll-על-טיימר כמו זו שלמעלה מספיקה; אין אירוע נפרד של "מוכן" לחווט, HotPDF פותרת זאת עם בדיקת nil פשוטה במקום API הודעה גדול יותר. הסעיף הבא מתאר מה CancelAll וקריאת ה-Free הזו בפועל עושות, משום ששתיהן חשובות ברגע שעמודים מתחילים להתעבד שלא בסדר או שגלילה קורית מהר יותר משהתור מסוגל להתרוקן

קיצור הדרך בקריאה בודדת עבור עמוד יחיד

THotPDF.RenderLoadedPageToBitmapAsync קיימת עבור המקרה הנפוץ של הפעלת עמוד בודד בדיוק בלי לגעת ישירות ב-THPDFBackgroundRenderer: היא בונה את מעבד העמודים פנימית, קוראת ל-RequestPage פעם אחת, מפעילה את התהליכון, ומחזירה את הפניית ה-TThread לקוד הקורא, שהוא הבעלים שלה ואחראי לשחרר אותה. אחזור התוצאה עובר דרך THotPDF.GetLoadedCachedRenderedBitmap ולא דרך ה-GetCachedBitmap של מעבד העמודים עצמו, משום ש-GetLoadedCachedRenderedBitmap קוראת את המטמון המשותף של המסמך שממופתח לפי אינדקס עמוד ו-DPI, אותו מטמון ש-RenderLoadedPageToBitmapCached ומנגנון ה-prefetch המובנה כבר ממלאים — עמוד שחלק אחר של המציג כבר עיבד ב-DPI הזה יכול לחזור מיד, עוד לפני שתהליכון הרקע שרק התחיל אפילו תוזמן על ידי מערכת ההפעלה

// A simpler alternative to the queue above, for one page at a time.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // waits if a prior page is still rendering
  FAsyncWorker := Pdf.RenderLoadedPageToBitmapAsync(PageIndex, 150);
  FPendingPage := PageIndex;
end;

procedure TViewerForm.AsyncPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  Bmp := Pdf.GetLoadedCachedRenderedBitmap(FPendingPage, 150);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

אפשר לבטל עמוד שכבר נמצא בתור?

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

ל-Execute יש התנהגות שנייה, קלה-לפספוס: הלולאה יוצאת ברגע שהיא מוצאת את התור ריק, היא לא ממתינה בחוסר-מעש (idle) לעבודה נוספת שתגיע. מופע THPDFBackgroundRenderer הוא, אם כן, worker של אצווה חד-פעמית, לא שירות רקע קבוע — הכנס לתור כמה עמודים, קרא ל-Start, וברגע שהעמוד האחרון בתור עובד, תהליכון מערכת ההפעלה שמתחת מסתיים בעצמו. קריאה חוזרת ל-RequestPage על אותו מופע אחרי ש-Execute כבר ריקנה את התור לא מפעילה אותה מחדש, וזו בדיוק הסיבה ש-RequestPageWindow למעלה מחליפה את מופע מעבד העמודים בכל קריאה במקום לנסות להמשיך להזין אובייקט אחד ארוך-חיים

האם בטוח לגעת ב-TBitmap מתהליכון רקע ב-Delphi?

מגע ב-TBitmap מתהליכון רקע בטוח בעיצוב של HotPDF כל עוד רק תהליכון אחד אי-פעם פועל על מופע ביטמאפ נתון בכל רגע, ו-THPDFBackgroundRenderer אוכפת את הגבול הזה במקום להשאיר אותו לקוד הקורא. Execute מעבדת כל עמוד בתוך מנעול-העיבוד (render lock) של המסמך עצמו, אותה section קריטי שכל קריאת RenderLoadedPageToBitmapCached ומנגנון ה-PrefetchLoadedPages המובנה כבר חולקים, כך שהציור בפועל של GDI עבור עמוד נתון קורה בדיוק על תהליכון אחד בכל פעם ואף פעם לא חופף עיבוד אחר של אותו מסמך. הביטמאפ שנוצר הוא אובייקט בבעלות תהליכון-העבודה שה-THPDFBackgroundRenderer אף פעם לא מפרסמת ישירות לקוד קורא

במקום זאת GetCachedBitmap מקצה TBitmap חדש לגמרי וקוראת ל-Assign עליו תחת המנעול הנפרד של מעבד העמודים עצמו, כך שההעתקה תמיד קורית בעוד Execute חסומה מלהחליף את משבצת המטמון ההיא מתחתיה — התהליכון הקורא מקבל נתוני פיקסלים, לעולם לא את הידית המקורית. ההפרדה הזו היא גם הסיבה להימנע מבניית תהליכון עיבוד מותאם אישית משלך שקורא לפונקציות העיבוד של HotPDF ישירות בלי לעבור דרך THPDFBackgroundRenderer או PrefetchLoadedPages: שני עיבודים שמתחרים על אותם מטמונים משותפים וגרף אובייקטים של אותו מסמך טעון הוא בדיוק התרחיש שהנעילה הפנימית של HotPDF קיימת כדי למנוע, ומחלקת מעבד הרקע נותנת לך את הנעילה הזו בחינם במקום לממש אותה מחדש

איך זה שונה מ-prefetch העמודים המובנה של HotPDF?

PrefetchLoadedPages ו-THPDFBackgroundRenderer פותרים בעיות קשורות אך שונות: PrefetchLoadedPages, בהינתן טווח עמודים, מעבדת את כל השכונה הזו למטמון המסמך המשותף אוטומטית על תהליכון העבודה שלה, ללא אובייקט תור שהקוד הקורא צריך ליצור או לנהל. THPDFBackgroundRenderer מחליפה את האוטומציה הזו בשליטה — הקוד הקורא מחליט בדיוק אילו אינדקסי עמוד חשובים ובאיזה סדר, ויכול לבטל את אלה שעדיין בתור בלי לגעת בטווח שמנגנון ה-prefetch המובנה מחמם במקום אחר. שניהם מוזרמים דרך אותו מנעול-עיבוד, כך שמציג יכול להריץ PrefetchLoadedPages עבור המקרה הרגיל של כמה-העמודים-הבאים ולפנות ל-THPDFBackgroundRenderer רק כשמשהו מחוץ לתבנית הזו עולה, כמו פס תמונות ממוזערות שקופץ ישר לעמוד שהמשתמש הרגע לחץ עליו

begin
  // PrefetchLoadedPages takes a 1-based "start-end" range string, while
  // RequestPage below stays 0-based like every other loaded-page index.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Reach for THPDFBackgroundRenderer only for a page outside that
  // window, such as a thumbnail the user just clicked.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

שני פרטי מחזור-חיים שווים להכניס לקוד ייצור. המטמון כלל-המסמך שמאחורי RenderLoadedPageToBitmapCached מוגבל על ידי RenderCacheCapacity, שמונה עמודים כברירת מחדל, ומפנה את הרשומה הפחות-שנעשה-בה-שימוש-לאחרונה ברגע שהוא מתמלא, אבל רשימת התוצאות של מופע THPDFBackgroundRenderer עצמו אין לה מגבלה כזו — היא שומרת ביטמאפ אחד לכל אינדקס עמוד ייחודי שאי-פעם התבקש דרך המופע ההוא עד שהמופע עצמו משוחרר, כך שמעבד עמודים שנשמר בחיים לאורך סשן גלילה שלם ב-DPI גבוה יצבור בשמחה ביטמאפ ברזולוציה מלאה אחד לכל עמוד שנגלל מעברו. HotPDF גם לא מבטלת מעבד עמודים שנוצר על ידי הקוד הקורא באופן אוטומטי כמו שהיא מבטלת את ה-prefetcher שלה עצמה לפני שמסמך נטען או משמיד את עצמו, משום שמופע THPDFBackgroundRenderer אף פעם לא רשום על אובייקט ה-THotPDF שאליו הוא מצביע — כך שקוד הקריאה חייב לבטל ולשחרר כל מעבד עמודים שנבנה מול מסמך לפני טעינה מחדש או שחרור של המסמך הזה, אותו משמעת סדר ש-HotPDF מיישמת על PrefetchLoadedPages פנימית

THPDFBackgroundRenderer הוא חלק אחד מחזית המסמך-הטעון שמאחורי ארכיטקטורת המציג MVC של HotPDF, והוא משתלב באופן טבעי עם זרימות העבודה ברמת-הקובץ בDirect File API עבור קובצי PDF גדולים כאשר המסמך שנגלל גדול מדי מכדי להיטען בקלילות מלכתחילה. עיבוד רקע, תורי בקשות, ומטמון העיבוד המתוארים כאן כולם חלק מרכיב HotPDF הסטנדרטי עבור Delphi ו-C++Builder