מאמר טכני

תצורת ספריית PDFium: מתי Brotli מחליף את Skia בשקט

ב-PDFium Component ל-Delphi, הפעלת BrotliEnabled או IsolatePerDocument ב-TPdfLibraryConfiguration הייתה מחליפה את build ה-Skia המצורף אל מנוע ה-AGG בלי שום שגיאה, כי שתי האפשרויות מעלות את FPDF_LIBRARY_CONFIG לגרסה שבה PDFium קורא את m_RendererType באופן מילולי. מאז v3.123.0 מנוע ברירת המחדל נשאר ברירת המחדל של ה-DLL עצמה, ומאז v3.125.0 בקשת Skia או Fontations שה-DLL לא יכולה לכבד מעלה EPdfError שאפשר לתפוס במקום להפיל את התהליך

אף אחד מהבאגים לא הודיע על עצמו. הראשון הפיק עמודים שנראו תקינים, פשוט רונדרו על ידי rasterizer אחר, עם anti-aliasing וקצוות טקסט שונים במעט מה-build ששילחתם ובדקתם. השני כן הודיע על עצמו, בקול רם, בכך שהפיל את תהליך המארח מתוך אתחול ילידי. שניהם מגיעים מאותו מקום: מבנה C מגורס שהשדות שלו נספרים רק אחרי שמספר הגרסה אומר שהם נספרים, ושערכי האפס שלו אינם "לא הוגדר" אלא בחירות אמיתיות

איך FPDF_LIBRARY_CONFIG מכריע באיזה מנוע PDFium משתמש?

FPDF_InitLibraryWithConfig מתייעץ עם m_RendererType רק כשהשדה Version של המבנה הוא 4 ומעלה, ומאותה גרסה הוא משתמש בערך בדיוק כפי שנכתב. מתחת לגרסה 4 PDFium מתעלם מהשדה ובוחר את ברירת המחדל של ה-build, שהיא Skia ב-builds שקומפלו עם PDF_USE_SKIA ו-AGG בכל השאר

כל שדה מאוחר יותר הולך באותה תבנית. המבנה גדל יכולת אחת בכל פעם, וכל יכולת הגיעה יחד עם מספר גרסה חדש. PDFium Component בונה את המבנה הילידי ב-LoadLibrary מה-TPdfLibraryConfiguration שלכם ומעלה את הגרסה רק כמרחק שהאפשרויות שהגדרתם דורשות

גרסת המבנההשדה שהיא מוסיפהנקבע על ידי
2m_pIsolate, m_v8EmbedderSlotנכתב תמיד; V8Isolate, V8EmbedderSlot
3m_pPlatformV8Platform שונה מ-nil
4m_RendererTypeRenderer שונה מ-prpDefault
5m_FontLibraryTypeFontBackend שונה מ-pfbpDefault
6m_BrotliEnabledBrotliEnabled = True
7m_IsolatePerDocumentIsolatePerDocument = True

המלכודת היא בשתי השורות האחרונות. גרסאות הן מצטברות: מבנה מגרסה 6 הוא גם מבנה מגרסה 4 וגם מגרסה 5, ולכן PDFium קורא את m_RendererType ואת m_FontLibraryType גם כשביקשתם רק Brotli. מה שיושב בשני השדות האלה באותו רגע נהיה המנוע וה-font backend, בין אם התכוונתם לבחור בהם ובין לא

סולם הגרסאות של FPDF_LIBRARY_CONFIG ב-PDFium Component מגרסה 2 עד גרסה 7 שמציג איזו אפשרות של TPdfLibraryConfiguration מוסיפה את m_RendererType, m_FontLibraryType, m_BrotliEnabled ו-m_IsolatePerDocument, ומדוע גרסאות מצטברות הופכות שדה מנוע מאופס לבחירת AGG מכוונת ולא לערך שלא הוגדר בכל build
כל אפשרות מעלה את גרסת המבנה וכל שדה קודם נשאר חי, ולכן האפס ב-m_RendererType מגיע אל PDFium בתור בקשת AGG מפורשת

מדוע הפעלת Brotli החליפה את המנוע אל AGG?

לפני v3.123.0, PDFium Component כתב FPDF_RENDERERTYPE_AGG אל m_RendererType עבור prpDefault, ולכן כל תצורה שדחפה את המבנה לגרסה 6 או 7 כפתה AGG על build של Skia. ה-runtimes של pdfium.dll ושל pdfium.v8.dll שנשלחים עם הרכיב הם builds של Skia, ולכן זה פגע בפריסת ברירת המחדל, לא באיזו פריסה אקזוטית

המיפוי נראה הרמטי כשנכתב. בגרסה 2 או 3 השדה אף פעם לא נקרא, ולכן prpDefault באמת פירושו היה "מה שה-DLL עושה". ברגע ש-BrotliEnabled (גרסה 6) או IsolatePerDocument (גרסה 7) נכנסו לתמונה, אותו קוד הפך "אין העדפה" לבקשת AGG מפורשת. שום דבר לא נכשל. PDFium התאתחל כרגיל, רינדר כל עמוד, והחזיר אף קוד שגיאה, כי מנקודת מבטו הקורא ביקש AGG וקיבל AGG

hash של פיקסלים הופך את ההחלפה לגלויה במקומות שבהם צילומי מסך לא. רינדור העמוד הראשון של אותו מסמך דגימה תחת שלוש תצורות נתן:

  • תצורת ברירת מחדל: hash‏ 502D77C3711B4ACF
  • BrotliEnabled = True עם Renderer שנשאר prpDefault: hash‏ F75B5EB4728ADE87
  • prpAgg מפורש: hash‏ F75B5EB4728ADE87, זהה להרצת ה-Brotli

התיקון ב-v3.123.0 הוא הפונקציה הציבורית PdfNativeRendererType, שפותרת TPdfRendererPreference אל הערך שנכתב אל m_RendererType. prpAgg ו-prpSkia ממופות אחד לאחד. prpDefault ממופה עכשיו אל Skia כשה-DLL הטעונה מייצאת FPDF_RenderPageSkia ואל AGG אחרת. הייצוא הזה מקומפל תחת אותו תנאי PDF_USE_SKIA של ברירת המחדל של Skia עצמה, מה שהופך אותו לתכונת ה-build היחידה שאפשר לצפות בה מחוץ ל-DLL. אחרי התיקון תצורת ה-Brotli מפיקה את אותו hash כמו תצורת ברירת המחדל

השוואת hash של פיקסלים ב-PDFium Component שמציגה את hash הרינדור של Skia בברירת המחדל 502D77C3711B4ACF, את תצורת ה-BrotliEnabled שלפני v3.123.0 שתאמה הרצת prpAgg מפורשת עם ה-hash‏ F75B5EB4728ADE87, ואת ה-wrapper המתוקן שפותר את prpDefault דרך ייצוא FPDF_RenderPageSkia בחזרה אל ה-hash המקורי של Skia
hash של פיקסלים תופס את מה שצילומי מסך מסתירים: הפעלת Brotli הייתה מרנדרת כל עמוד עם AGG, וברירת המחדל המתוקנת עכשיו תואמת את התצורה הלא נגועה

ל-font backend לא הייתה מעולם אותה בעיה. m_FontLibraryType נקרא מגרסה 5 ומעלה, וערך האפס שלו, FPDF_FONTBACKENDTYPE_FREETYPE, הוא גם ברירת המחדל של PDFium כשהשדה לא נקרא בכלל. כתיבת FreeType עבור pfbpDefault לכן משחזרת את ברירת המחדל הילידית בדיוק. ערכי אפס אינם תמיד שגויים, הם פשוט לעולם לא נכונים אוטומטית

עם v3.123.0 ומעלה, קוד ההפעלה שהייתם כותבים באופן טבעי עושה עכשיו את מה שהוא אומר:

uses
  PDFium;

procedure ConfigurePdfiumAtStartup;
var
  Config: TPdfLibraryConfiguration;
begin
  // חייב לרוץ לפני שמשהו טוען את הספרייה הילידית
  Config := TPdfLibraryConfiguration.Default;
  Config.BrotliEnabled := True;   // מעלה את FPDF_LIBRARY_CONFIG לגרסה 6
  // ה-Renderer נשאר prpDefault: נפתר ל-Skia ב-builds שמייצאים
  // FPDF_RenderPageSkia ול-AGG ב-builds של AGG בלבד
  SetLength(Config.UserFontPaths, 1);
  Config.UserFontPaths[0] := 'C:\ProgramData\MyApp\Fonts';
  ConfigurePdfLibrary(Config);
end;

זכרו ש-BrotliEnabled הופך את streams ה-/BrotliDecode של PDF 2.0 לניתנים לפענוח רק כשה-DLL עצמה נבנתה עם PDF_ENABLE_BROTLI. הדגל הוא בקשה, וב-build בלי תמיכת Brotli אין לו אף השפעה. TPdfLibraryConfiguration.Hardened זהה ל-Default חוץ מזה ש-AllowMachineTime הוא False, מה שחוסם מ-JavaScript של המסמך לקרוא את השעון האמיתי; זו נקודת פתיחה סבירה לעיבוד בצד שרת של קבצים לא מהימנים

מה קורה כשמבקשים backend שה-DLL לא מכילה?

PDFium לא מחזירה שגיאה עבור מנוע או font backend שחסרים מה-build: FPDF_InitLibraryWithConfig מכשילה CHECK ילידי, שב-Windows עולה בתור חריגת breakpoint ו, בלי structured exception handler סביב הקריאה, מסיים את התהליך. ה-header אומר זאת במפורש, באזהרה שערך לא נתמך "יכשיל בדומה עם קריסה מיידית"

שני המקרים המוחשיים הם build של AGG בלבד שמקבל FPDF_RENDERERTYPE_SKIA, ו-build בלי Fontations שמקבל FPDF_FONTBACKENDTYPE_FONTATIONS. ה-runtime של Skia המצורף נמצא בקבוצה השנייה: הוא מרנדר עם Skia אבל משתמש ב-FreeType לגופנים. בקשת prpSkia יחד עם pfbpFontations מולו הפיקה External exception 80000003 בצד Delphi. כשהדיבאגר או handler חריגות במקרה תופסים את זה, המצב עדיין בלתי ניתן לשיקום:

  • PDFium נשארת חצי מאותחלת
  • התצורה ברמת התהליך כבר נאטמה, ולכן ConfigurePdfLibrary מסרב לתצורה מתוקנת
  • ניסיון חוזר עם תצורה אחרת באותו תהליך כבר אינו אפשרי

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

איך PDFium Component מבצע בדיקה מוקדמת ל-Skia ול-Fontations

מאז v3.125.0, LoadLibrary מאמתת את התצורה אחרי קשירת ייצוא ה-DLL ולפני קריאה ל-FPDF_InitLibraryWithConfig, והופכת מנוע או font backend לא נתמך אל EPdfError עם הודעה שקוראת בשם את ההגדרה הפוגענית ואת החלופות. ה-DLL נפרקת והתצורה נפתחת, ולכן הקורא יכול לבחור הגדרות אחרות ולטעון שוב

ההכרעה עצמה חיה בפונקציה הטהורה PdfLibraryConfigurationSupportError, שמקבלת את התצורה בתוספת שני booleans שמתארים את ה-build ומחזירה מחרוזת ריקה כשהשילוב בטוח. מכיוון שהיא לא נוגעת באף מצב ילידי, אפשר לקרוא לה מהבדיקות שלכם עם כל שילוב יכולות. בתוך LoadLibrary שני ה-booleans מגיעים מסוגים שונים של ראיות, והם ראויים לרמות אמון שונות:

  • Skia מזוהה מנוכחות ייצוא ה-FPDF_RenderPageSkia, אותו אות ש-PdfNativeRendererType משתמשת בו. הייצוא ומנוע ה-Skia מקומפלים תחת תנאי אחד, ולכן הבדיקה מדויקת
  • ל-Fontations אין ייצוא משלה. העקבה היחידה שהיא משאירה היא קרייטות הגופנים של Rust שהיא גוררת לתוך הבינארי, ולכן PDFium Component סורקת את קובץ הספרייה הטעונה אחר שמות הקרייטות skrifa ו-read-fonts (גם read_fonts). הסריקה רצה רק כשמבקשים pfbpFontations, וקובץ שאי אפשר לקרוא נספר בתור "אין Fontations"

בדיקת ה-Fontations היא heuristic, והיא יכולה לטעות לכיוון אחד: build של Fontations שנשללו ממנו כל אותן מחרוזות יידחה למרות שהיה יכול לעבוד. הפשרה הזאת נעשתה בכוונה. דחייה שגויה עולה לכם חריגה שאפשר לתפוס ו-fallback אל FreeType. קבלה שגויה עולה לכם את התהליך

הפתיחה חשובה לא פחות מהבדיקה. LoadLibrary אוטמת את התצורה בתחילת הטעינה ממש, ולכן בלי האיפוס דחיית יכולת הייתה משאירה את ConfigurePdfLibrary עונה על כל ניסיון חוזר ב-EPdfError‏ "PDFium library configuration is already sealed". נתיב הדחייה קורא קודם ל-UnloadLibrary; הקריאה שלו ל-FPDF_DestroyLibrary בטוחה באותה נקודה כי PDFium עדיין לא אותחלה ומחזירה מיד. כשלי טעינה אחרים, כמו DLL חסרה או אי-התאמת ארכיטקטורה, שומרים על האטימה, ולכן לולאת ניסיון חוזר חייבת להבחין בין השניים:

uses
  SysUtils, PDFium;

function StartPdfiumPreferringSkia: TPdfRendererPreference;
var
  Config: TPdfLibraryConfiguration;
begin
  Config := TPdfLibraryConfiguration.Default;
  Config.Renderer := prpSkia;
  ConfigurePdfLibrary(Config);
  try
    PDFium.LoadLibrary;   // ממוסגר יחידה: ל-Windows.LoadLibrary יש את אותו שם
    Result := prpSkia;
  except
    on E: EPdfError do
    begin
      // דחיית יכולת מפרקת את ה-DLL ופותחת את התצורה.
      // DLL שנכשלה בטעינה כליל נשארת אטומה: ניסיון חוזר לא יעזור
      if PdfLibraryConfigurationSealed then
        raise;
      Config.Renderer := prpAgg;
      ConfigurePdfLibrary(Config);
      PDFium.LoadLibrary;
      Result := prpAgg;
    end;
  end;
end;

שימו לב ל-PDFium.LoadLibrary המפורש. ביחידה שמשתמשת גם ב-Windows או ב-Winapi.Windows, LoadLibrary לא ממוסגרת נפתרת אל היחידה שמופיעה אחרונה בסעיף ה-uses; כשזו הפונקציה של Win32, הקריאה ללא פרמטרים לא מצליחה להתקמפל עם שגיאת ספירת ארגומנטים שלא אומרת דבר על PDFium

זרימת הבדיקה המוקדמת של LoadLibrary ב-PDFium Component שבה ConfigurePdfLibrary אוטמת את התצורה, בדיקת היכולות בוחנת את ייצוא FPDF_RenderPageSkia ואת ראיות המחרוזת של skrifa, בקשה לא נתמכת מעלה EPdfError שאפשר לתפוס ופותחת לניסיון חוזר, בזמן ש-DLL שלעולם לא נטענה משאירה את PdfLibraryConfigurationSealed‏ true
האימות רץ אחרי שהייצוא נקשר ולפני האתחול, ולכן backend חסר נכשל בתור EPdfError שאפשר לתפוס במקום CHECK ילידי שמפיל את התהליך

אימות שקורה אף מוקדם יותר

ConfigurePdfLibrary דוחה כמה שילובים לפני ש-DLL כלשהי מעורבת, הכול עם EPdfError. FontBackend מפורש, כולל pfbpFreeType, דורש Renderer = prpSkia, כי PDFium מתייעצת עם ה-font backend רק עבור המנוע של Skia. IsolatePerDocument דורש ש-V8Isolate יהיה nil, מכיוון ש-PDFium יוצרת isolate משלה לכל מסמך ומכשילה CHECK ילידי אם גם מוסרים לה אחד. מחרוזות ריקות ב-UserFontPaths נדחות. וכל קריאה אחרי ניסיון הטעינה הראשון נכשלת עם "PDFium library configuration is already sealed"

לכלל האחרון הזה יש השלכה מעשית: אי אפשר לבחון קודם את ה-DLL ולתצר אותה אחר כך. GetSkiaRenderCapabilities, V8FeaturesAvailable, פתיחת מסמך ורוב שאר נקודות הכניסה קוראות ל-LoadLibrary פנימית, מה שאוטם את התצורה במקום. קריאה מאוחרת ל-UnloadLibrary גם לא פותחת אותה. מתצרים קודם, אחר כך טוענים, ואז שואלים שאלות, מה שהוא בדיוק הסדר שרוטינת אבחון צריכה ללכת אחריו:

uses
  SysUtils, PDFium, FPdfView;

function DescribePdfiumState: string;
var
  Config: TPdfLibraryConfiguration;
  Renderer: string;
begin
  Config := GetPdfLibraryConfiguration;   // עותק, בטוח לבחון
  if not PDFium.Loaded then
  begin
    if PdfLibraryConfigurationSealed then
      Exit('PDFium failed to load; configuration is sealed');
    Exit('PDFium not loaded; configuration can still change');
  end;
  // אותו פתרון ש-LoadLibrary יישמה כשבנתה את FPDF_LIBRARY_CONFIG
  if PdfNativeRendererType(Config.Renderer,
    GetSkiaRenderCapabilities.PageRender) = FPDF_RENDERERTYPE_SKIA then
    Renderer := 'Skia'
  else
    Renderer := 'AGG';
  Result := Format('Renderer=%s Brotli=%s IsolatePerDocument=%s',
    [Renderer, BoolToStr(Config.BrotliEnabled, True),
     BoolToStr(Config.IsolatePerDocument, True)]);
end;

רישום השורה הזאת פעם אחת בהפעלה זול, והוא הדבר הראשון שרוצים בכרטיס תמיכה שאומר "הטקסט נראה שונה על השרת". PDFium.Loaded ממוסגר מאותה סיבה כמו LoadLibrary: בתוך מתודה של טופס או רכיב, Loaded חשוף נקשר אל TComponent.Loaded

שתי דרכים שבהן מבנה תצורה מגורס משתבש

כל מבנה תצורה מגורס, בין אם זה FPDF_LIBRARY_CONFIG, רשומת cbSize של Win32, או ABI של פלאגין, נכשל בשתי דרכים סימטריות, ו-wrapper חייב להתגונן מול שתיהן. הראשונה היא מילוי שדה תוך השארת הגרסה נמוכה מדי; השנייה היא העלאת הגרסה תוך השארת שדה בערך אפס שהספרייה קוראת בתור בחירה מכוונת

  1. שדה מוגדר, גרסה נמוכה מדי. כותבים m_BrotliEnabled = 1 אל מבנה מגרסה 2 ו-PDFium לעולם לא מביטה בו. הקריאה מצליחה ו-streams של Brotli נשארים בלתי ניתנים לפענוח. ההגנה היא לגזור את הגרסה מהשדות שבשימוש בפועל, מה ש-LoadLibrary עושה, ולא לקודד אחת באופן קבוע
  2. גרסה גבוהה מספיק, שדה אפס פירושו משהו. מעלים את הגרסה אל 6 וכל שדה עד גרסה 6 חי עכשיו. FillChar מאפס את m_RendererType אל FPDF_RENDERERTYPE_AGG, שהוא מנוע אמיתי, לא "לא הוגדר". ההגנה היא לכתוב כל שדה שהגרסה הנבחרת מכסה בערך מכוון, ולפתור "ברירת מחדל" מול ה-build בפועל במקום להניח אותה

כלל שלישי נובע עבור ערכים שיכולים להפיל את הנקרא: מאמתים אותם מול מה שהבינארי יודע לעשות לפני הקריאה, בעזרת הראיות החזקות ביותר הזמינות, והוגנים בקוד ובתיעוד כשהראיה היא heuristic. סמל מיוצא הוא הוכחה. שם קרייטה בטבלת מחרוזות הוא ניחוש טוב

עזר זריז: תצורת הספרייה של PDFium Component

  • קוראים ל-ConfigurePdfLibrary פעם אחת, לפני שמשהו טוען את ה-DLL; כל שאילתת יכולות או טעינת מסמך אוטמת אותה
  • משדרגים ל-v3.123.0 ומעלה אם מגדירים BrotliEnabled או IsolatePerDocument ומצפים לפלט Skia מה-runtimes המצורפים
  • משאירים את Renderer ב-prpDefault אלא אם צריך rasterizer ספציפי; הוא עכשיו נפתר אל ברירת המחדל של ה-build בכל גרסת מבנה
  • משתמשים ב-PdfNativeRendererType עם GetSkiaRenderCapabilities.PageRender כדי לרשום איזה מנוע פעיל בפועל
  • מצפים ל-EPdfError, לא לקריסה, עבור prpSkia על DLL של AGG בלבד או pfbpFontations על DLL ללא Fontations ב-v3.125.0 ומעלה
  • אחרי דחיית יכולות, PdfLibraryConfigurationSealed הוא False ומותר לתצר מחדש; אחרי טעינת DLL שנכשלה הוא נשאר True
  • מתייחסים לזיהוי Fontations בתור heuristic ושומרים fallback של FreeType
  • כותבים PDFium.LoadLibrary ו-PDFium.Loaded עם שם היחידה כדי להימנע מהתנגשויות שמות של Win32 ושל TComponent

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

PDFium Component עוטף את מנוע ה-PDFium עבור Delphi ו-C++Builder עם בדיקות תצורה כאלה, כך שאתחול ילידי נכשל בתור חריגת Pascal שאפשר לטפל בה במקום יציאת תהליך. פרטי המוצר וההורדות ב-דף המוצר של PDFium Component ל-Delphi