ארכיון סרוק יכול להגיע לנפח של כמה גיגה-בייט בקובץ PDF בודד. מציג הפותח קובץ כזה רוצה בדרך כלל להציג עמוד אחד, אולי את תוכן העניינים, או עמוד שהמשתמש קפץ אליו מסימניה. קריאת הקובץ כולו לזיכרון כדי לרנדר שני עמודים היא בזבוז בכל ציר: היא שורפת מרחב כתובות, מעכבת את המשתמש מאחורי קריאה ראשונית ארוכה, ובתהליך Delphi של 32 סיביות היא עלולה להיכשל לחלוטין לפני שיופיע עמוד בודד. PDFium נבנה תוך מחשבה על כך. הוא יכול לטעון מסמך באמצעות callback המבקש את טווחי הבתים הספציפיים שהוא צריך, מתי שהוא צריך אותם, והוא לעולם אינו דורש את הקובץ כולו בבת אחת.
הרכיב חושף נתיב זה באמצעות מתאם זרם (stream adapter). אתה מוסר לו כל TStream, ו-PDFium מושך בלוקים מאותו זרם לפי דרישה. הקובץ יכול לשבת בדיסק, בשדה blob של מסד נתונים, או מאחורי כל צאצא אחר של TStream, ושום דבר ממנו אינו מועתק לזיכרון מראש.
כיצד PDFium מבקש בתים
ממשק ה-C API של PDFium טוען מסמך מאובייקט המסופק על ידי הקורא המתואר על ידי מבנה FPDF_FILEACCESS. למבנה יש שלושה חלקים שחשובים כאן: שדה אורך, callback של קריאה, ופרמטר משתמש אטום. נקודת הכניסה הצורכת אותו היא FPDF_LoadCustomDocument. ברגע ש-PDFium מחזיק במבנה זה הוא מנתח את ה-trailer, מאתר את טבלת ההפניות הצולבות, ומאותו רגע קורא רק את מה שפעולה נתונה דורשת. פתיחת המסמך נוגעת בזנב הקובץ ובקומץ אובייקטי קטלוג. רינדור עמוד 400 קורא את זרמי התוכן והמשאבים של אותו עמוד ותו לא.
זהו ההפרש בין טעינה מאוחסנת (buffered) לטעינה מוזרמת (streaming). טעינה מאוחסנת קוראת את הקובץ מקצה לקצה לפני ש-PDFium רואה את הבית הראשון. טעינה מוזרמת הופכת את הקשר: PDFium מניע את הקריאות, והבתים שמעולם לא נוגעים בהם מעולם לא נקראים. עבור קובץ של כמה גיגה-בייט המוצג עמוד אחד בכל פעם, זהו הפער בין טעינה בלתי שמישה לטעינה מיידית.
מתאם הזרם
המתאם המקשר בין TStream של Delphi ל-FPDF_FILEACCESS הוא TPdfStreamAdapter. הקונסטרקטור שלו מקבל את הזרם ודגל בעלות, לוכד את אורך הזרם פעם אחת, ממלא את רשומת FPDF_FILEACCESS וממחבר את callback הקריאה. כאשר PDFium קורא בחזרה מאוחר יותר עם היסט וגודל, המתאם מנווט את הזרם להיסט זה ומעתיק בדיוק את הטווח הזה לתוך הבאפר ש-PDFium סיפק.
// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
inherited Create;
if AStream = nil then
raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
FStream := AStream;
FOwnsStream := AOwnsStream;
// FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
// that would silently truncate past 4 GiB.
if AStream.Size > High(FPDF_DWORD) then
raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');
FillChar(FFileAccess, SizeOf(FFileAccess), 0);
FFileAccess.m_FileLen := FPDF_DWORD(AStream.Size);
FFileAccess.m_GetBlock := GetBlockCallback;
FFileAccess.m_Param := Self;
end;
דגל הבעלות מחליט מי משחרר את הזרם. העבר False והקורא שומר על הזרם וחייב להשאיר אותו חי לכל אורך חיי המסמך. העבר True והמתאם לוקח פיקוד, ומשחרר את הזרם כאשר המסמך נסגר. בשני המקרים הזרם חייב לשרוד מעבר לכל קריאה ש-PDFium יבצע, מכיוון ש-PDFium מחזיק בפוינטר FPDF_FILEACCESS ויקרא בחזרה בכל שלב שבו המסמך פתוח, ולא רק במהלך הטעינה הראשונית.
מדוע ה-callback הוא פונקציה סטטית
callback הקריאה ש-PDFium שומר ב-m_GetBlock הוא פוינטר פשוט לפונקציית C עם מוסכמת הקריאה cdecl. לא ניתן להשתמש במתודת Delphi ישירות, מכיוון שמתודה נושאת ארגומנט Self נסתר שקורא C אינו יודע עליו דבר ולעולם לא יספק אותו. לכן המתאם מצהיר על ה-callback כ-class function המסומנת כ-cdecl; static, אשר מתקמפלת לפונקציה עצמאית עם פריסת מסגרת ה-C ש-PDFium מצפה לה וללא Self מרומז.
זה פותר את מוסכמת הקריאה אך מעלה שאלה שנייה: ללא Self, כיצד ה-callback מגיע לזרם הספציפי שממנו הוא אמור לקרוא? התשובה היא פרמטר המשתמש אטום. כאשר המתאם בונה את הרשומה הוא שומר את פוינטר המופע (instance pointer) שלו ב-m_Param. PDFium מוסר את אותו פוינטר בחזרה כארגומנט הראשון של כל callback. הפונקציה הסטטית ממיקה אותו בחזרה ל-TPdfStreamAdapter ומנתבת את הקריאה מול הזרם של אותו מופע. זהו הטרמפולינה הסטנדרטית להעברת הקשר אובייקט (object context) מעבר לגבול C שאין לו מושג לגבי אובייקטים.
תקרת ה-4 GiB ומדוע היא דורשת הגנה
שדה האורך m_FileLen ב-FPDF_FILEACCESS הוא ערך בלתי חתום של 32 סיביות. האורך הגדול ביותר שניתן לייצוג בו הוא בית אחד פחות מ-4 GiB. משתנה TStream מדווח על גודלו כ-Int64, כך שזרם יכול לתאר הרבה יותר בתים ממה שהשדה יכול להחזיק. ברגע שגודל הזרם חורג מתקרה זו, אין דרך ישרה לומר ל-PDFium מה אורך הקובץ.
// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
param : Pointer;
position: FPDF_DWORD;
pBuf : PByte;
size : FPDF_DWORD): Integer; cdecl;
var
Adapter: TPdfStreamAdapter;
begin
Result := 0;
if (param = nil) or (pBuf = nil) or (size = 0) then
Exit;
Adapter := TPdfStreamAdapter(param); // recover the instance from m_Param
if Adapter.FStream = nil then
Exit;
try
Adapter.FStream.Position := Int64(position);
Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
Result := 1;
except
Result := 0; // report failure by return value, never by raising
end;
end;
התגובה השגויה היא להקצות את הגודל ולאפשר לו להתקפל (wrap). קטיעת אורך של 5 GiB לשדה של 32 סיביות מייצרת מספר קטן ונראה סביר, ו-PDFium ינתח אז את הקובץ מתוך אמונה שהוא מסתיים בערך בגיגה-בייט הראשון. ה-trailer וטבלת ההפניות הצולבות חיים בקצה האמיתי של הקובץ, הרבה מעבר לאורך הקטוע, כך שהניתוח נכשל בצורה שאין לה שום קשר לגורם האמיתי. היית מנסה לדבג שגיאת הפניה צולבת בקובץ תקף לחלוטין, ללא שום רמז לכך ששלם התקפל שתי שכבות מעל.
המתאם מסרב לקלט במקום זאת. הקונסטרקטור משווה את גודל הזרם מול High(FPDF_DWORD) ומעלה EPdfError ברגע שהזרם גדול מדי לתיאור. שגיאה מפורשת ומיידית מצביעה על הבעיה האמיתית בנקודת הבנייה. קטיעה שקטה מסתירה אותה מאחורי סימפטום מטעה שהיית רודף אחריו הרבה יותר מאוחר. מגבלת ה-4 GiB היא מגבלה אמיתית של נתיב טעינה זה, והדבר הישר לעשות הוא להציף אותה בקול רם במקום לטשטש אותה באריתמטיקה שבמקרה מתקמפלת.
כשלים אסור שיחצו את הגבול
קריאה יכולה להיכשל. הזרם עשוי להיות אובייקט מבוסס רשת שמגיע ל-timeout, מזהה blob שנסגר מתחתיך, או קובץ שנקטע לאחר פתיחת המסמך. החוזה של PDFium עבור callback הקריאה הוא ערך חזרה: לא-אפס להצלחה, אפס לכשל. זוהי מסגרת C, ואין לה שום מנגנון ללכוד או להפיץ חריגת Pascal.
זו הסיבה שהטרמפולינה עוטפת את הניווט (seek) והקריאה ב-try/except שבולע את החריגה ומחזיר אפס. אם חריגת Delphi הייתה מורשית להתפשט מחוץ ל-callback, היא הייתה מתפתחת (unwind) דרך מסגרות המחסנית של cdecl של PDFium, שמעולם לא נבנו להתפתח על ידי מנגנון החריגות של Pascal. התוצאה היא התנהגות לא מוגדרת במקרה הטוב וקריסה קשה במקרה הרע, עמוק בתוך מנתח ה-PDF ללא מחסנית שמישה. החזרת אפס שומרת על הכשל בתוך החוזה. PDFium רואה קריאת בלוק שנכשלה, מבטל את הפעולה בצורה נקייה, ו-FPDF_LoadCustomDocument מדווח שלא ניתן לטעון את המסמך, מה שהרכיב מציף כ-EPdfError בצד ה-Pascal שבו הוא שייך.
פתיחת מסמך בדרך זו
מתודת הרכיב המניעה את נתיב ההזרמה היא LoadCustomDocument, המוצהרת כמתודה נפרדת ולא כהעמסה (overload) נוספת של LoadDocument כדי שמשלוח TMemoryStream לא ינחת בטעות בנתיב המאוחסן. היא בונה את המתאם, קוראת ל-FPDF_LoadCustomDocument, ושומרת על המתאם חי למשך חיי המסמך הטעון.
var
Pdf: TPdf;
FileStream: TFileStream;
begin
Pdf := TPdf.Create(nil);
FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
try
// Hand stream ownership to Pdf: it frees FileStream when the document closes.
Pdf.LoadCustomDocument(FileStream, True);
// PDFium has read only the trailer and catalog so far.
// Rendering a page pulls just that page's bytes through the callback.
// ... render or inspect pages here ...
finally
Pdf.Free; // closes the document, which frees the adapter and the stream
end;
end;
אותה קריאה עובדת עבור TMemoryStream, זרם blob מסט נתונים של מסד נתונים, או צאצא מותאם אישית של TStream. טעינה לפי דרישה מצדיקה את עצמה כאשר הקובץ גדול ורק חלק ממנו ייקרא: מציג ארכיון, מחולל תמונות ממוזערות הדוגם כמה עמודים, או אינדקס חיפוש המושך עמוד אחד בכל פעם. כאשר הקובץ קטן או שאתה הולך לקרוא את כולו בכל מקרה, טעינה מאוחסנת היא פשוטה יותר ומנגנון ההזרמה אינו מועיל בדבר. הגורם המכריע הוא היחס בין הבתים שבהם תיגע בפועל לבין הבתים שהקובץ מכיל.
ברגע שעמודים מוזרמים לפי דרישה, הדאגה הבאה היא שמירה על תגובתיות העמודים המרונדרים כאשר המשתמש מבצע זום וגלילה, נושא המכוסה ב-הערה שלנו על ביצועי זיכרון מטמון לרינדור וזום. כאשר המסמך המוזרם הוא כזה שמציג צריך להראות אך לא לאפשר למשתמש לייצא או לשנות, הטכניקות ב-המדריך לתצוגה מקדימה מאובטחת של PDF משתלבות באופן טבעי עם נתיב טעינה זה. שניהם בונים על הטעינה המוזרמת המתוארת כאן, אשר נשלחת כחלק מ-PDFium Component עבור Delphi ו-C++Builder לצד ממשקי ה-API לרינדור, חילוץ טקסט והערות המכוסים במקומות אחרים בבלוג זה.