מאמר טכני

רינדור PDF פרוגרסיבי ניתן לביטול ב-Delphi (PDFium)

רוב עמודי ה-PDF עוברים רסטריזציה בתוך מספר אלפיות שנייה ולעולם אינך חושב על כך. ואז משתמש פותח שרטוט הנדסי בגודל A1, עמוד העמוס בעשרות אלפי משיכות וקטוריות, או פוסטר הגדוש בקבוצות שקיפות ומסכות רכות, והקריאה הבודדת שמציירת אותו לוקחת שתיים או שלוש שניות. אם הקריאה הזו רצה על חוט ה-UI, החלון מפסיק להצטייר מחדש, שורת הכותרת מאפירה, ומערכת ההפעלה מציעה להרוג את היישום. העבודה היא לגיטימית. העמוד באמת צריך כל כך הרבה זמן. הפגם הוא שהרינדור הוא קריאה חוסמת בודדת ובלתי ניתנת לחלוקה, ללא כל דרך לעלות לשאוף אוויר וללא כל דרך לעצור

מאמר זה עוסק בדיוק באחת משתי הבעיות הללו: ביטול של רינדור ארוך לעמוד בודד ללא הקפאת ה-UI. המשתמש לחץ על העמוד הבא, ביצע התקרבות (zoom), או סגר את המסמך, והרינדור שבאוויר (in flight) הוא כעת עבודה מבוזבזת שאמורה להסתיים בהזדמנות הראשונה במקום לרוץ עד הסוף. החלקת גלילה והתקרבות על ידי שמירה במטמון (caching) של מה שכבר עבר רסטריזציה היא נושא נפרד בעל תכנון משלו, המכוסה במאמר המלווה המקושר בסוף. כאן השאלה היחידה היא כיצד לגרום לרינדור פרוגרסיבי אחד לענות לבקשת ביטול במהירות ובצורה נקייה

ה-API לרינדור פרוגרסיבי ש-PDFium כבר מספקת

PDFium חזתה מראש את החצי של הבעיה שעוסק בהקפאה. לצד הפונקציה החד-פעמית FPDF_RenderPageBitmap, היא חושפת גרסה פרוגרסיבית (progressive) המחלקת עמוד לנתחי עבודה (chunks). אתה קורא ל-FPDF_RenderPageBitmap_Start פעם אחת כדי להגדיר את הרינדור כנגד מפת סיביות של היעד, ולאחר מכן קורא ל-FPDF_RenderPage_Continue שוב ושוב. כל Continue מבצע רסטריזציה לפרוסה תחומה ומחזיר סטטוס. FPDF_RENDER_TOBECONTINUED משמעותו שיש עוד מה לעשות, FPDF_RENDER_DONE משמעותו שהעמוד הסתיים, ו-FPDF_RENDER_FAILED משמעותו שזה נעצר עקב שגיאה. כאשר הלולאה מסתיימת אתה קורא ל-FPDF_RenderPage_Close כדי לשחרר את המצב הפרוגרסיבי של העמוד. מכיוון שהשליטה חוזרת אל הקוד שלך בין פרוסות, אתה יכול לשאוב הודעות (pump messages), לעדכן מחוון התקדמות, או לבדוק האם העבודה עדיין רצויה

המנגנון ש-PDFium מספקת להחלטה מתי לוותר (yield) הוא מבנה (struct) קריאה חוזרת הנקרא IFSDK_PAUSE. אתה מוסר אותו ל-Start ולכל Continue. לאחר כל נתח, PDFium קוראת למצביע הפונקציה NeedToPauseNow שלו, ואם זה מחזיר ערך שאינו אפס, ה-Continue הנוכחי נעצר מוקדם ומחזיר את השליטה יחד עם FPDF_RENDER_TOBECONTINUED. המבנה נושא גם שדה version, אשר חייב להיות מוגדר כ-1, ומצביע user בעל צורה חופשית ש-PDFium לעולם אינה נוגעת בו ומעבירה אותו ללא שינוי. המצביע הזה שלא נגעו בו הוא כל הציר של התכנון שיוצג להלן

הסבת מטרה מהשהייה (pause) לביטול (cancel)

הכוונה המקורית של NeedToPauseNow היא חיתוך זמן (time-slicing). החזר ערך שאינו אפס כאשר תקציב המסגרת (frame) שלך נוצל, החזר אפס כדי להמשיך לרנדר, ו-PDFium מושהית כך שתוכל לעשות משהו אחר לפני שתחדש את אותו הרינדור. הרכיב PDFium Component עושה שימוש חוזר באותו אות עבור פועל אחר. במקום לענות על השאלה "האם עלי להשהות ולתת לך לחדש", הקריאה החוזרת עונה "האם העבודה הזו בוטלה". השניים ממפים זה לזה באופן נקי בגלל מה שהלולאה עושה כאשר היא רואה את הדגל. השהייה אמיתית מצפה ל-Continue מאוחר יותר; ביטול אינו מצפה לכך. ברגע שהלולאה הקוראת מבחינה שהאסימון בוטל, היא סוגרת את הקשר הרינדור ולעולם אינה קוראת שוב ל-Continue, כך שאותה החזרה שאינה-אפס ש-PDFium קוראת כ-"עצור את הנתח הזה" הופכת, למעשה, ל-"עצור לתמיד"

ביטול מבוטא באמצעות ממשק (interface), כגון IPdfCancellationToken, אשר מאפיין ה-IsCancelled שלו מתהפך משקר לאמת כאשר חלק אחר בתוכנית מבקש מהרינדור לעצור. הגשר בין ממשק Pascal ההוא לבין הקריאה החוזרת ב-C של PDFium הוא מצביע יחיד. הפניית הממשק של האסימון נכתבת אל תוך IFSDK_PAUSE.user, וקריאה חוזרת סטטית מסוג cdecl קוראת אותו בחזרה ומתשאלת אותו. זוהי הבעיה הקלאסית של מתן אפשרות לספריית C לקרוא בחזרה לתוך Pascal: הקריאה החוזרת חייבת להיות פונקציה פשוטה עם מוסכמת קריאה (calling convention) של C, ולא מתודה, משום ש-PDFium שומרת ומפעילה מצביע פונקציה חשוף שאינו יודע דבר על אובייקטים של Pascal או על Self

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // non-zero: PDFium stops this chunk
end;

הקריאה החוזרת משחזרת את האסימון על ידי המרת pThis^.user בחזרה לטיפוס הממשק וקוראת את IsCancelled. שום דבר בה לא מקצה (allocates), נועל, או חוסם, וזה משמעותי משום ש-PDFium קוראת לה בחוט הרינדור לאחר כל נתח וכל עבודה שנעשית כאן מתווספת לעלות של הרינדור עצמו. ההגנה מפני מבנה nil או שדה user nil משמעותה שפונקציה זו בטוחה להתקנה אפילו על רינדור שלעולם לא קיבל אסימון אמיתי

שמירת האסימון (token) בחיים לאורך הלולאה

המרת מצביע ממשק דרך Pointer גולמי ובחזרה היא המקום שבו נולדים באגים של זמן חיים (lifetime). ל-IInterface ב-Delphi יש ספירת הפניות (reference counted), והספירה זזה רק כאשר המהדר יכול לראות משתנה מטיפוס ממשק מוקצה. אחסון האסימון אך ורק כמצביע חשוף בתוך IFSDK_PAUSE.user יסתיר אותו לחלוטין ממונה ההפניות. אם ההפניה היחידה האחרת לאותו אסימון תצא מההיקף (out of scope) בזמן שלולאת ה-Continue עדיין רצה, האובייקט ישוחרר מתחת לקריאה החוזרת, והנתח הבא יבטל הפניה (dereference) של מצביע משתלשל (dangling pointer)

זו הסיבה שהמתאר (descriptor) הוא רשומה המחזיקה שני דברים, לא אחד. השדה Pause הוא המבנה ש-PDFium קוראת. השדה Token הוא הפניה אמיתית מטיפוס ממשק שהמהדר סופר, והוא קיים אך ורק כדי לנעוץ (pin) את האסימון בזיכרון כל עוד הרשומה חיה. הרשומה היא משתנה מקומי על מחסנית שגרת הרינדור, כך שהיא נשארת תקפה לכל אורכה של הלולאה ומפורקת רק כאשר השגרה יוצאת. המצביע החשוף ב-user וההפניה הנספרת ב-Token מצביעים על אותו האובייקט; אחד הוא מה ש-PDFium יכולה לקרוא, והשני הוא מה שמונע מאותו אובייקט להיאסף על ידי איסוף הזבל

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

סגירת הקשר הרינדור לא משנה כיצד הלולאה מסתיימת

כל קריאה ל-FPDF_RenderPageBitmap_Start מקצה מצב פרוגרסיבי ש-PDFium מקשרת עם העמוד, ומצב זה משוחרר רק על ידי FPDF_RenderPage_Close. ישנן שלוש דרכים לצאת מלולאת ההנעה (drive loop). העמוד מסתיים והסטטוס האחרון הוא FPDF_RENDER_DONE. האסימון מופעל (trips) והלולאה יוצאת מוקדם ומדווחת על ביטול. משהו נכשל והסטטוס הוא FPDF_RENDER_FAILED. כל השלושה חייבים לקרוא ל-Close, ודרך הביטול היא הקלה ביותר לטעות בה, משום שהצורה הטבעית של "רואה ביטול, פורץ החוצה" נוטה לדלג על פעולות ניקוי (cleanup) בדרכה ליציאה. השארת Close ללא הגעה דולפת את מצב ה-per-page, וצופה שמאפשר למשתמש לבטל רינדור אחרי רינדור יצבור את הדליפה הזו על כל עמוד שבוטל

הצורה החסונה (robust) שמה את הלולאה ואת סיווג התוצאה בתוך בלוק try, ואת FPDF_RenderPage_Close בבלוק ה-finally התואם. מפת הסיביות של היעד נהרסת באותו בלוק. ביטול יכול לעזוב את הלולאה דרך Exit מוקדם וה-finally עדיין רץ, כך שיש בדיוק מקום אחד שמשחרר את המצב הפרוגרסיבי ואי אפשר לעקוף אותו

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

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

שלוש תוצאות, ומה מפת הסיביות מחזיקה לאחר ביטול

נקודת הכניסה הציבורית היא TPdf.RenderPageProgressive, והיא מחזירה TPdfProgressiveStatus שהוא אחד מ-prsDone, prsCancelled, או prsFailed. הערכים משקפים את הקבועים (constants) FPDF_RENDER_* של PDFium בניב Pascal, אך מקפלים פנימה את מקרה הביטול כתוצאה ממדרגה ראשונה (first-class result) ולא כשגיאה

הנקודה שתופסת אנשים היא מה מפת סיביות היעד מכילה לאחר prsCancelled. היא אינה ריקה. PDFium מרנדרת באופן פרוגרסיבי לתוך אותה מפת סיביות נתח אחר נתח, כך שכאשר ביטול עוצר את הלולאה, מפת הסיביות מחזיקה כל מה שצויר עד לאותו רגע, שזו תמונה חלקית: חלק מהרצועות מוכנות, והשאר עדיין מראות את צבע המילוי. האם תוצאה חלקית זו מועילה תלוי בקורא (caller). צופה שעומד לזרוק את מפת הסיביות משום שהמשתמש ניווט למקום אחר יכול פשוט להתעלם ממנה. צופה שרוצה להציג תצוגה מקדימה (preview) בעלות נמוכה יכול לשמור אותה. מה שאסור לך לעשות הוא להניח ש-prsCancelled מרמז על מפת סיביות ריקה או לא מוגדרת; זה מרמז על צילום מצב אמיתי של רינדור לא גמור

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

אסימון ה-nil ונתיב קריאה חוזרת (callback) ללא התפצלויות (branch-free)

ביטול הוא אופציונלי (opt-in). קורא שרק רוצה רינדור פרוגרסיבי עבור תועלת שאיבת ההודעות (message-pumping), ללא שום כוונה לבטל, אמור להיות מסוגל להעביר nil כעבור האסימון. הדרך התמימה לתמוך בכך היא לפזר בדיקות "אם סופק אסימון" לאורך הקריאה החוזרת והלולאה, מה שאומר התפצלות (branch) על כל נתח וקריאה חוזרת שחייבת לטפל גם באסימון אמיתי וגם בהיעדרו

המימוש נמנע מכך על ידי החלפה (substituting) ליחידון כאשר הקורא לא מעביר דבר. אסימון nil מוחלף ב-PdfNoCancellationToken, ממשק שה-IsCancelled שלו הוא תמיד שקר (false). מנקודה זו ואילך לקריאה החוזרת וללולאה יש אסימון לתשאל בכל מקרה, כך שאף אחת מהן אינה צריכה בדיקת nil ואף אחת אינה צריכה נתיב מיוחד. אסימון הלעולם-אל-תבטל פשוט עונה תמיד שקר, הקריאה החוזרת תמיד מחזירה אפס, והרינדור רץ עד הסוף בדיוק כפי שרינדור שאינו-ניתן-לביטול היה עושה. התנהגות אופציונלית ממודלת כאסימון שלעולם אינו נורה ולא כהיעדר אסימון, מה ששומר את הנתיב החם (hot path) אחיד

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

הצורה שמופיעה היא קטנה וראויה לניסוח מחדש, משום שהיא החלק שניתן לעשות בו שימוש חוזר. ספריית C שתומכת בקריאה חוזרת נותנת לך בדיוק ערוץ אחד להעביר מצב לתוך הקריאה החוזרת ההיא, מצביע ה-user האטום. שים הפניית ממשק נספרת של Pascal מאחורי המצביע הזה, שמור הפניה אמיתית שנייה בחיים ליד המבנה כדי שהאובייקט לא יוכל להיאסף באמצע הקריאה, וקרא את הממשק בחזרה מתוך פונקציית cdecl סטטית. עטוף את כל לולאת ההנעה (drive loop) ב-try ושחרר את ההקשר הטבעי (native context) ב-finally. אותה תבנית ממשיכה לכל פעולת PDFium פרוגרסיבית או מונחית קריאות חוזרות שבה קוד Pascal צריך להישאר בשליטה על זמן החיים בזמן ש-C מחזיק מצביע

ביטול הוא רק חצי אחד של צופה שמגיב (responsive viewer). החצי השני הוא לא לרנדר מחדש עמודים שכבר ציירת, ולשמור על זום וגלילה חלקים על ידי הגשת מפות סיביות מהמטמון, נושא המכוסה במאמר שלנו על שמירת רינדור במטמון וביצועי זום. כדי לראות כיצד הרינדור הניתן לביטול משתלב בתוך צופה שלם לצד ניווט, בחירה וחיפוש, ראה בניית צופה PDF עשיר בתכונות עם PDFium VCL component. הרינדור הפרוגרסיבי המתואר כאן מסופק כחלק מ-PDFium Component עבור Delphi ו-Lazarus, לצד ממשקי ה-API של טעינה, רינדור וטפסים הנסקרים במקומות אחרים בבלוג זה