מאמר טכני

הזרמת קבצי 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 מניעה את הקריאות, והבתים שמעולם לא נוגעים בהם לעולם לא נקראים. עבור קובץ בן מספר ג'יגה-בתים הנצפה דף בכל פעם, זהו הפער בין טעינה בלתי שמישה לבין טעינה מיידית

תרשים ארכיטקטורה מנגד טעינה חוצצית שמעתיקה PDF רב-גיגהבייטים אל הזיכרון לפני ניתוח, מול זרימה שבה PDFium מבקש טווחי בייטים מ-TStream של Delphi דרך FPDF_FILEACCESS
פתיחה עולה רק ב-trailer ובקטלוג; עיבוד עמוד 400 מושך את בתי עמוד 400 ולא דבר נוסף דרך ה-callback

מתאם הזרם

המתאם שמגשר בין TStream של Delphi ל-FPDF_FILEACCESS הוא TPdfStreamAdapter. הבנאי שלו מקבל את הזרם ודגל בעלות, לוכד את אורך הזרם פעם אחת, ממלא את רשומת ה-FPDF_FILEACCESS, ומחווט את קריאת החוזרת (callback) של הקריאה. כאשר PDFium חוזרת מאוחר יותר עם הסטה (offset) וגודל, המתאם מחפש בזרם את ההסטה הזו ומעתיק בדיוק את הטווח הזה לתוך החוצץ (buffer) ש-PDFium סיפקה

// מילולית מתוך הרכיב: הגשר מזרם אל FPDF_FILEACCESS
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 הוא unsigned long בן 32 סיביות. סרבו לזרם
  // שהיה נחתך בשקט מעבר ל-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 שאין לו מושג על אובייקטים

תרשים ה-trampoline של ה-cdecl שנושא בקשות בלוקים של PDFium מגבול ה-C אל מופע TPdfStreamAdapter של Delphi ומקפל חריגות אל ערך החזרה אפס
callback סטטי של cdecl מסתיר אף Self מרומז, ולכן ה-m_Param מחזיר את מופע המתאם אל כל הזמנה וכל חריגת Pascal מתקפלת אל החזרת אפס
// מילולית מתוך הרכיב: הטרמפולינה של cdecl בחזרה למופע
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);   // שחזור המופע מתוך 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;  // דיווח כישלון דרך ערך ההחזרה, לעולם לא בהעלאת חריגה
  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

תרשים החלטה המגן על מגבלת 4 GiB של FPDF_FILEACCESS, שבה TStream של Delphi גדול מדי מעלה EPdfError מיד במקום לעטוף בשקט את שדה האורך המוכרז
EPdfError מיידי עדיף על אריתמטיקה שרק מתקמפלת: m_FileLen שנעטף שולח ניפוי באג אחרי שביל cross-reference בדיוני

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

קריאה יכולה להיכשל. הזרם עשוי להיות אובייקט מבוסס רשת שהזמן הקצוב שלו פג (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
    // העברת בעלות הזרם ל-Pdf: הוא משחרר את FileStream כשהמסמך נסגר.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium קרא עד כה רק את ה-trailer והקטלוג.
    // רינדור עמוד מושך רק את הבייטים של אותו עמוד דרך ה-callback.
    // ... רינדור או בדיקת עמודים כאן ...
  finally
    Pdf.Free;  // סוגר את המסמך, שמשחרר את המתאם ואת הזרם
  end;
end;

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

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