רוב דפי ה-PDF עוברים רסטריזציה (rasterisation) תוך כמה מילישניות ואף פעם לא מקדישים לכך מחשבה. ואז משתמש פותח שרטוט הנדסי בגודל A1, דף עמוס בעשרות אלפי קווים וקטוריים, או פוסטר גדוש בקבוצות שקיפות ומסכות רכות, והקריאה הבודדת שמציירת אותו אורכת שתיים או שלוש שניות. אם קריאה זו רצה על חוט ממשק המשתמש (UI thread), החלון מפסיק להתרענן, שורת הכותרת הופכת לאפורה, ומערכת ההפעלה מציעה להרוג את היישום. העבודה היא לגיטימית. הדף באמת צריך את משך הזמן הזה. הפגם הוא שהרינדור הוא קריאה חוסמת (blocking call) אחת שלא ניתנת לחלוקה, בלי שום דרך לעצור לשאוף אוויר ובלי שום דרך להפסיק
מאמר זה עוסק בדיוק באחת משתי הבעיות הללו: ביטול של רינדור ארוך לדף בודד מבלי להקפיא את ממשק המשתמש. המשתמש לחץ על הדף הבא, או התקרב (zoom), או סגר את המסמך, והרינדור שמתבצע כרגע הוא עבודה מבוזבזת שאמורה להסתיים בהזדמנות הראשונה במקום לרוץ עד הסוף. החלקת גלילה והתקרבות על ידי שמירה במטמון של מה שכבר עבר רסטריזציה זו בעיה נפרדת עם תכנון משלה, המכוסה במאמר הנלווה המקושר בסוף. כאן השאלה היחידה היא כיצד לגרום לרינדור מתקדם (progressive) אחד לענות לבקשת ביטול במהירות ובצורה נקייה
ה-API לרינדור מתקדם ש-PDFium כבר מספקת
PDFium צפתה את חצי הבעיה של ההקפאה. לצד הקריאה החד-פעמית (one-shot) של FPDF_RenderPageBitmap, היא חושפת גרסה מתקדמת המפצלת דף לנתחי עבודה. אתם קוראים ל-FPDF_RenderPageBitmap_Start פעם אחת כדי להגדיר את הרינדור מול מפת סיביות (bitmap) כיעד, ואז קוראים ל-FPDF_RenderPage_Continue שוב ושוב. כל Continue מרנדר חתיכה תחומה ומחזיר סטטוס. FPDF_RENDER_TOBECONTINUED אומר שיש עוד עבודה, FPDF_RENDER_DONE אומר שהדף מוכן, ו-FPDF_RENDER_FAILED אומר שהוא נעצר עקב שגיאה. כאשר הלולאה מסתיימת קוראים ל-FPDF_RenderPage_Close כדי לשחרר את המצב המתקדם (progressive state) לכל דף. בגלל שהשליטה חוזרת לקוד שלכם בין החתיכות, אתם יכולים לטפל בהודעות (pump messages), לעדכן מחוון התקדמות, או לבדוק אם העבודה עדיין נדרשת
המנגנון ש-PDFium מספקת כדי להחליט מתי לפנות את השליטה (yield) הוא מבנה (struct) מסוג callback בשם IFSDK_PAUSE. אתם מעבירים אותו ל-Start ולכל Continue. לאחר כל נתח, PDFium קוראת למצביע הפונקציה NeedToPauseNow שלו, ואם זה מחזיר ערך שאינו אפס, ה-Continue הנוכחי נעצר מוקדם ומחזיר את השליטה עם FPDF_RENDER_TOBECONTINUED. המבנה גם נושא שדה version, שחייב להיות מוגדר ל-1, ומצביע user חופשי ש-PDFium לעולם לא נוגעת בו ומעבירה אותו כפי שהוא. המצביע הזה שלא נגעו בו הוא הציר שעליו יושב כל התכנון שיבוא להלן
הפיכת השהיה (pause) לביטול (cancel)
הכוונה המקורית של NeedToPauseNow היא חיתוך זמן (time-slicing). החזירו ערך שאינו אפס כאשר תקציב הפריימים (frame budget) שלכם נוצל, החזירו אפס כדי להמשיך לרנדר, ו-PDFium תושהה כך שתוכלו לעשות משהו אחר לפני שתחדשו את אותו הרינדור. רכיב ה-PDFium עושה שימוש חוזר באותו אות עבור פועל אחר. במקום לענות "האם עליי להשהות ולאפשר לך להמשיך", ה-callback עונה "האם העבודה הזו בוטלה". השניים ממפים זה לזה באופן נקי בגלל מה שהלולאה עושה כאשר היא רואה את הדגל. השהיה אמתית מצפה ל-Continue מאוחר יותר; ביטול לא. ברגע שהלולאה הקוראת (calling loop) מזהה שהאסימון (token) בוטל, היא סוגרת את הקשר הרינדור ולעולם לא קוראת ל-Continue שוב, כך שאותה חזרה של ערך שאינו אפס ש-PDFium קוראת כ-"עצור את הנתח הזה" הופכת, למעשה, ל-"עצור לתמיד"
הביטול מתבטא באמצעות ממשק (interface), IPdfCancellationToken, שהמאפיין IsCancelled שלו מתהפך משקר לאמת כאשר חלק אחר בתוכנית מבקש מהרינדור להפסיק. הגשר בין ממשק פסקל (Pascal) זה לבין ה-C callback של PDFium הוא מצביע בודד. התייחסות הממשק של האסימון נכתבת לתוך IFSDK_PAUSE.user, ו-callback סטטי מסוג cdecl קורא אותו בחזרה ומתשאל אותו. זוהי הבעיה הקלאסית של מתן אפשרות לספריית C לקרוא בחזרה לפסקל: ה-callback חייב להיות פונקציה פשוטה עם מוסכמת קריאה (calling convention) של C, ולא מתודה, משום ש-PDFium מאחסנת ומפעילה מצביע פונקציה חשוף שאינו יודע דבר על אובייקטי פסקל או 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;
ה-callback משחזר את האסימון על ידי המרת (casting) pThis^.user חזרה לסוג הממשק וקורא את IsCancelled. שום דבר בו אינו מקצה זיכרון, נועל או חוסם, וזה חשוב משום ש-PDFium קוראת לו על חוט הרינדור (rendering thread) לאחר כל נתח וכל עבודה שנעשית כאן מתווספת לעלות של הרינדור עצמו. ההגנה מפני מבנה nil או שדה user שהוא nil פירושה שאותה פונקציה בטוחה להתקנה אפילו על רינדור שמעולם לא ניתן לו אסימון אמיתי
שמירה על האסימון בחיים לאורך הלולאה
המרת מצביע לממשק דרך Pointer גולמי ובחזרה היא המקום שבו נולדים באגים של מחזור חיים (lifetime). ל-IInterface ב-Delphi יש ספירת הפניות (reference counting), והספירה זזה רק כשהמהדר רואה הקצאה של משתנה מסוג ממשק. אחסון האסימון רק כמצביע חשוף בתוך IFSDK_PAUSE.user יסתיר אותו לחלוטין ממונה ההפניות. אם ההפניה היחידה האחרת לאותו אסימון תצא מטווח (scope) בזמן שלולאת ה-Continue עדיין רצה, האובייקט ישוחרר מתחת ל-callback, והנתח הבא ינסה לגשת למצביע מתנדנד (dangling pointer)
זו הסיבה שהמתאר (descriptor) הוא רשומה (record) המחזיקה שני דברים, לא אחד. שדה Pause הוא המבנה ש-PDFium קוראת. שדה Token הוא הפניה אמיתית מסוג ממשק שהמהדר סופר, והיא קיימת אך ורק כדי לנעוץ (pin) את האסימון בזיכרון כל עוד הרשומה חיה. הרשומה היא משתנה מקומי במחסנית (stack) של שגרת הרינדור, ולכן היא נשארת בתוקף לאורך כל משך הלולאה ונהרסת רק כאשר השגרה מסתיימת. המצביע החשוף ב-user וההפניה הנספרת ב-Token מצביעים על אותו אובייקט; האחד הוא מה ש-PDFium יכולה לקרוא, והשני הוא מה שמונע מהאובייקט מלהיאסף (collected)
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 מקצה מצב מתקדם (progressive state) ש-PDFium משייכת לדף, ומצב זה משוחרר אך ורק על ידי FPDF_RenderPage_Close. ישנן שלוש דרכים לצאת מלולאת ההרצה. הדף מסתיים והסטטוס האחרון הוא FPDF_RENDER_DONE. האסימון מופעל והלולאה יוצאת מוקדם בדיווח על ביטול. משהו נכשל והסטטוס הוא FPDF_RENDER_FAILED. כל השלוש חייבות לקרוא ל-Close, ונתיב הביטול הוא הקל ביותר לטעות בו, משום שהצורה הטבעית של "רואה ביטול, פורץ החוצה" נוטה לדלג על ניקוי בדרכה ליציאה. השארת Close ללא הגעה תדלוף (leak) את המצב של כל דף, ו-viewer שמאפשר למשתמש לבטל רינדור אחר רינדור יצבור את הדליפה הזו בכל דף שבוטל
הצורה החסונה (robust) שמה את הלולאה וסיווג התוצאה בתוך try ואת FPDF_RenderPage_Close ב-finally התואם. מפת הסיביות (bitmap) של היעד נהרסת באותו בלוק. ביטול יכול לעזוב את הלולאה דרך 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 בנוסף להסתמכות על ה-callback שבתוכו. ה-callback מקצר את הנתח הנוכחי; בדיקת הלולאה עוצרת את התחלת הנתח הבא. יחד הם תוחמים את הזמן שלוקח לביטול להיכנס לתוקף לערך למשך הזמן של נתח אחד
שלוש תוצאות, ומה מפת הסיביות מחזיקה לאחר ביטול
נקודת הכניסה הציבורית היא TPdf.RenderPageProgressive, והיא מחזירה TPdfProgressiveStatus שהוא אחד מ-prsDone, prsCancelled, או prsFailed. הערכים משקפים את קבועי ה-FPDF_RENDER_* של PDFium בניב פסקל אבל מקפלים את מקרה הביטול לתוצאה מסוג ראשון (first-class) ולא לשגיאה
הנקודה שתופסת אנשים היא מה מפת הסיביות של היעד מכילה לאחר prsCancelled. היא אינה ריקה. PDFium מרנדרת בהדרגה לתוך אותה מפת סיביות נתח אחר נתח, כך שכאשר ביטול עוצר את הלולאה, מפת הסיביות מחזיקה את מה שצויר עד לאותו רגע, וזוהי תמונה חלקית: כמה פסים מוכנים, והשאר עדיין מראים את צבע המילוי. האם תוצאה חלקית זו מועילה תלוי במתקשר (caller). מציג (viewer) שעומד לזרוק את מפת הסיביות משום שהמשתמש ניווט למקום אחר פשוט יכול להתעלם ממנה. מציג שרוצה להראות תצוגה מקדימה בעלות נמוכה יכול לשמור אותה. מה שאסור לכם לעשות זה להניח ש-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 ללא פיצולים
הביטול הוא אופציונלי (opt-in). מתקשר שרוצה רק רינדור מתקדם בשביל היתרון של טיפול בהודעות, ללא כל כוונה לבטל, אמור להיות מסוגל להעביר nil עבור האסימון. הדרך הנאיבית לתמוך בזה היא לפזר בדיקות של "אם סופק אסימון" דרך ה-callback והלולאה, מה שאומר פיצול (branch) על כל נתח ו-callback שצריך לטפל גם באסימון אמיתי וגם בהיעדרו
המימוש מונע זאת על ידי החלפת יחידון (singleton) כאשר המתקשר לא מעביר כלום. אסימון nil מוחלף ב-PdfNoCancellationToken, ממשק שה-IsCancelled שלו הוא תמיד שקר (false). מנקודה זו ואילך יש ל-callback וללולאה אסימון לתשאל בכל מקרה, כך שאף אחד מהם אינו זקוק לבדיקת nil ואף אחד מהם אינו זקוק לנתיב מיוחד. האסימון ש"לעולם-לא-מבטל" פשוט תמיד עונה שקר, ה-callback תמיד מחזיר אפס, והרינדור רץ עד הסוף בדיוק כמו שרינדור שאינו ניתן לביטול היה רץ. התנהגות אופציונלית ממודלת כאסימון שלעולם אינו מופעל ולא כהיעדר אסימון, מה ששומר על הנתיב החם (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 שתומכת ב-callback נותנת לכם בדיוק ערוץ אחד להעביר מצב לתוך ה-callback הזה, מצביע המשתמש האטום (opaque user pointer). הציבו הפניית ממשק של פסקל שנספרת (counted) מאחורי המצביע הזה, שמרו הפניה אמיתית שנייה בחיים לצד המבנה כך שהאובייקט לא ייאסף באמצע קריאה, וקראו את הממשק בחזרה מתוך פונקציית cdecl סטטית. עטפו את כל לולאת ההרצה ב-try ושחררו את ההקשר (context) המקומי ב-finally. אותה תבנית ניתנת להעברה לכל פעולת PDFium מתקדמת או מונחית callback שבה קוד פסקל חייב להישאר בשליטה על מחזור החיים בעוד C מחזיקה מצביע
ביטול הוא רק חצי אחד של מציג (viewer) תגובתי. החצי השני הוא לא לרנדר מחדש דפים שכבר ציירתם, ולשמור על התקרבות (zoom) וגלילה חלקים על ידי הגשת מפות סיביות שנשמרו במטמון, המכוסה ב-המאמר שלנו על שמירת רינדור במטמון וביצועי התקרבות. לגבי האופן שבו רינדור ניתן לביטול משתלב בתוך מציג מלא לצד ניווט, בחירה וחיפוש, ראו בניית מציג PDF עשיר בתכונות עם רכיב PDFium. הרינדור המתקדם המתואר כאן מסופק כחלק מ-רכיב PDFium עבור Delphi ו-Lazarus לצד ה-APIs של טעינה, רינדור וטפסים המכוסים במקומות אחרים בבלוג זה