מאמר טכני

הזרמת קבצי PDF ענקיים לפי דרישה בעזרת PDFium ב-Delphi

ארכיון סרוק יכול להגיע למספר ג'יגה-בתים בקובץ PDF יחיד. מציג (viewer) שפותח קובץ כזה בדרך כלל רוצה להראות דף אחד, אולי את תוכן העניינים, אולי דף שהמשתמש קפץ אליו מסימנייה. קריאת כל הקובץ לזיכרון כדי לרנדר שני דפים היא בזבזנית בכל ציר: היא שורפת מרחב כתובות, היא מעכבת את המשתמש מאחורי קריאה ראשונית ארוכה, ובתהליך Delphi של 32 סיביות היא עלולה להיכשל לחלוטין לפני שמופיע דף בודד. PDFium נבנתה עם זה בחשבון. היא יכולה לטעון מסמך דרך קריאה חוזרת (callback) שמבקשת את טווחי הבתים הספציפיים שהיא צריכה, כאשר היא צריכה אותם, והיא לעולם אינה דורשת את כל הקובץ בבת אחת. גבול אחד שייך להתחלה: ערוץ הזרמה זה מתאר את הקובץ באורך של 32 סיביות, כך שהוא משרת קובץ יחיד עד 4 GiB, מה שמכסה בפועל כמעט כל ארכיון סרוק. קובץ מעבר לקו הזה אינו הטריטוריה של מאמר זה; הוא רוצה להיות מפוצל לכרכים בזמן הסריקה או לחלופין להיפתח דרך אסטרטגיית גישה ישירה, והשומר שאוכף את התקרה באמת מקבל סעיף משלו למטה

הרכיב חושף את הנתיב הזה דרך מתאם זרם. אתם מוסרים לו כל TStream, ו-PDFium מושכת בלוקים מאותו זרם לפי דרישה. הקובץ יכול לשבת בדיסק, בשדה blob של מסד נתונים, או מאחורי כל צאצא אחר של TStream, ואף חלק ממנו אינו מועתק לזיכרון מראש

כיצד PDFium מבקשת בתים

ממשק ה-C API של PDFium טוען מסמך מאובייקט שסופק על ידי המתקשר (caller-supplied) המתואר על ידי מבנה ה-FPDF_FILEACCESS. למבנה יש שלושה חלקים שחשובים כאן: שדה אורך, פונקציית קריאה חוזרת (callback), ופרמטר משתמש אטום. נקודת הכניסה הצורכת אותו היא FPDF_LoadCustomDocument. ברגע ש-PDFium מחזיקה במבנה זה היא מנתחת את הקדימון (trailer), מאתרת את טבלת ההצלבה (cross-reference), ומאותו רגע קוראת רק את מה שפעולה נתונה דורשת. פתיחת המסמך נוגעת בזנב הקובץ ובקומץ אובייקטי קטלוג. רינדור דף 400 קורא את זרמי התוכן והמשאבים עבור אותו דף ולא שום דבר אחר

זהו ההבדל בין טעינה מאוגרת (buffered) לטעינה מוזרמת (streaming). טעינה מאוגרת קוראת את הקובץ מקצה לקצה לפני ש-PDFium רואה את בית אפס. טעינה מוזרמת הופכת את מערכת היחסים: PDFium מניעה את הקריאות, והבתים שמעולם לא נוגעים בהם לעולם לא נקראים. עבור קובץ בן מספר ג'יגה-בתים הנצפה דף בכל פעם, זהו הפער בין טעינה בלתי שמישה לבין טעינה מיידית

מתאם הזרם

המתאם שמגשר בין TStream של Delphi ל-FPDF_FILEACCESS הוא TPdfStreamAdapter. הבנאי שלו מקבל את הזרם ודגל בעלות, לוכד את אורך הזרם פעם אחת, ממלא את רשומת ה-FPDF_FILEACCESS, ומחווט את קריאת החוזרת (callback) של הקריאה. כאשר PDFium חוזרת מאוחר יותר עם הסטה (offset) וגודל, המתאם מחפש בזרם את ההסטה הזו ומעתיק בדיוק את הטווח הזה לתוך החוצץ (buffer) ש-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, אשר מתהדרת לפונקציה עצמאית עם פריסת המסגרת (frame layout) של C ש-PDFium מצפה לה וללא Self מרומז

זה פותר את מוסכמת הקריאה אך מעלה שאלה שנייה: ללא Self, כיצד ה-callback מגיע לזרם הספציפי ממנו הוא אמור לקרוא? התשובה היא פרמטר המשתמש האטום. כאשר המתאם בונה את הרשומה הוא שומר את מצביע המופע (instance) שלו עצמו ב-m_Param. PDFium מוסרת את אותו מצביע בחזרה כארגומנט הראשון של כל callback. הפונקציה הסטטית ממירה (casts) אותו בחזרה ל-TPdfStreamAdapter ומשגרת את הקריאה אל מול הזרם של אותו מופע. זוהי הטרמפולינה התקנית למסירת הקשר של אובייקט (object context) מעבר לגבול C שאין לו מושג על אובייקטים

// 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;

תקרת 4 GiB ומדוע היא זקוקה לשומר

מכאן מגיע הגבול שצוין בפתיחה. שדה האורך m_FileLen ב-FPDF_FILEACCESS הוא ערך 32-bit לא חתום (unsigned). האורך הגדול ביותר שניתן לייצג בו הוא בית אחד פחות מ-4 GiB. TStream מדווח על גודלו כ-Int64, כך שזרם יכול לתאר הרבה יותר בתים ממה שהשדה יכול להכיל. ברגע שגודל הזרם חורג מתקרה זו, אין דרך כנה לומר ל-PDFium מה אורך הקובץ

התגובה השגויה היא להקצות את הגודל ולתת לו לגלוש (wrap). קיטוע (truncating) אורך של 5 GiB לשדה של 32 סיביות מייצר מספר קטן שנראה סביר, ו-PDFium תנתח אז את הקובץ מתוך אמונה שהוא מסתיים בערך אחרי ג'יגה-בית אחד. הקדימון (trailer) וטבלת ההצלבה (cross-reference) חיים בקצה האמיתי של הקובץ, הרבה מעבר לאורך המקוטע, ולכן הניתוח נכשל בדרך שאין לה שום קשר לסיבה האמיתית. הייתם מדבגים שגיאת הצלבה בקובץ שהוא חוקי לחלוטין, ללא רמז לכך שמספר שלם גלש שתי שכבות למעלה

המתאם מסרב לקלט במקום זאת. הבנאי משווה את גודל הזרם אל מול High(FPDF_DWORD) ומעלה (raises) EPdfError ברגע שהזרם גדול מכדי לתאר אותו. שגיאה מפורשת ומיידית נותנת שם לבעיה האמיתית בנקודת הבנייה. קיטוע שקט מסתיר אותה מאחורי תסמין מטעה שהייתם רודפים אחריו הרבה יותר מאוחר. מגבלת 4 GiB היא אילוץ אמיתי של נתיב טעינה זה, והדבר הכן לעשות הוא להציף אותו בקול רם במקום לטייח אותו באריתמטיקה שבמקרה מתהדרת (compile). כאשר ארכיון באמת חוצה את הקו, הפתרונות שהובטחו למעלה חיים מחוץ ל-API זה: פיצול הסריקה לקבצים נפרדים לפי כרך שכל אחד מהם נשאר מתחת לתקרה, או להשאיר את המסמך בדיסק ולשרת אותו דרך עיצוב גישה ישירה (direct-access) הבנוי על הסטות של 64 סיביות במקום דרך FPDF_FILEACCESS

אסור לכישלונות לחצות את הגבול

קריאה יכולה להיכשל. הזרם עשוי להיות אובייקט מבוסס רשת שהזמן הקצוב שלו פג (times out), מאחיז (handle) blob שנסגר מתחתיכם, או קובץ שנקטע לאחר פתיחת המסמך. החוזה של PDFium עבור ה-callback של הקריאה הוא ערך החזרה: לא אפס להצלחה, אפס לכישלון. זו מסגרת C, ואין לה מנגנון לתפוס או להעביר הלאה (propagate) חריגת (exception) Pascal

זו הסיבה שהטרמפולינה עוטפת את החיפוש (seek) והקריאה בתוך try/except שבולע את החריגה ומחזיר אפס. אם הייתה ניתנת הרשאה לחריגת Delphi לעבור החוצה מה-callback, היא הייתה נפרמת (unwind) דרך מסגרות המחסנית (stack frames) cdecl של PDFium, שמעולם לא נבנו כדי להיפרם על ידי מנגנון החריגות של Pascal. התוצאה היא התנהגות בלתי מוגדרת במקרה הטוב וקריסה קשה במקרה הרע, עמוק בתוך מנתח ה-PDF (parser) ללא מחסנית שמישה. החזרת אפס שומרת את הכישלון בתוך החוזה. PDFium רואה קריאת בלוק שנכשלה, מבטלת את הפעולה בצורה נקייה, ו-FPDF_LoadCustomDocument מדווח שלא ניתן לטעון את המסמך, מה שהרכיב מציף כ-EPdfError בצד של Pascal היכן שהוא שייך

פתיחת מסמך בדרך זו

מתודת הרכיב שמניעה את נתיב ההזרמה היא LoadCustomDocument, המוכרזת כמתודה נפרדת במקום העמסת יתר (overload) נוספת של LoadDocument כדי שהעברת TMemoryStream לעולם לא תנחת בטעות בנתיב המאוגר (buffered). היא בונה את המתאם, קוראת ל-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. טעינה לפי דרישה מצדיקה את קיומה כאשר הקובץ גדול ורק חלק ממנו ייקרא: מציג ארכיון, מחולל תמונות ממוזערות שדוגם כמה עמודים, אינדקס חיפוש שמושך דף אחד בכל פעם. כאשר הקובץ קטן או שאתם הולכים לקרוא את כולו בכל מקרה, טעינה מאוגרת (buffered) היא פשוטה יותר ומנגנון ההזרמה אינו קונה לכם דבר. הגורם המכריע הוא היחס בין בתים שבאמת תיגעו בהם לבין בתים שהקובץ מכיל

ברגע שהדפים מוזרמים לפי דרישה, הדאגה הבאה היא שמירה על דפים מרונדרים מגיבים כאשר המשתמש משנה את קנה המידה (zooms) וגולל, אשר מכוסה בהערתנו על שמירת מטמון של רינדור וביצועי שינוי קנה מידה. כאשר המסמך המוזרם הוא אחד שמציג אמור להראות אך לא לאפשר למשתמש לייצא או לשנות, הטכניקות במדריך לתצוגה מקדימה מאובטחת של PDF משתלבות בטבעיות עם נתיב טעינה זה. שניהם נבנים על טעינת ההזרמה המתוארת כאן, המסופקת כחלק מרכיב PDFium עבור Delphi ו-C++Builder לצד ה-APIs של רינדור, חילוץ טקסט והערות המכוסים במקומות אחרים בבלוג זה