מאמר טכני

מטמון דיסק של מציג PDF ו-DPI לכל מסך ב-Delphi

שתי תלונות מלוות כמעט כל מציג PDF מוטמע: מסמכים גדולים מרונדרים מחדש מאפס בכל הפעלה, והטקסט הופך למטושטש ברגע שהחלון נגרר למסך בעל DPI גבוה. ספריית losLab PDF Library עונה על שני האתגרים הללו בפקד ה-TPDFlibViewer שלה: המאפיין DiskCacheFolder מאפשר מטמון דפי דיסק בין סשנים המבוסס על טביעת האצבע של המסמך, והמאפיין ScreenDPI, הנתמך על ידי GetDpiForWindow וטיפול בהודעת WM_DPICHANGED, מקנה לפקד מודעות DPI אמיתית לכל מסך בנפרד

ראוי להתייחס ברצינות לשתי הבעיות הללו מכיוון שהמשתמשים מבחינים בהן מיד. חוזה סרוק בן 400 עמודים שלקח אתמול שמונה שניות להפוך לנגיש לגלילה, לוקח שוב שמונה שניות היום, למרות ששום דבר בקובץ לא השתנה. ובסביבת עבודה בעלת ערכי DPI מעורבים, מציג שמרנדר ב-96 DPI של המסך הראשי ייראה שם טוב אך ייראה מטושטש באופן ניכר בפאנל ה-4K שלידו. מאמר זה עובר על האופן שבו TPDFlibViewer פותר כל אחת מהבעיות הללו, וחשוב מכך, מדוע תכנון המערכת נחת היכן שנחת. אם אתם חדשים בפקד עצמו, התחילו בסקירה של פקד מציג PDF האינטראקטיבי וחזרו לכאן עבור שכבת הביצועים

כיצד שומרים דפי PDF מרונדרים בין סשנים ב-Delphi?

הקצו תיקייה ל-TPDFlibViewer.DiskCacheFolder והפקד ישמור את מפות הסיביות של הדפים המרונדרים לדיסק, כך שמסמך שנצפה לאחרונה ייפתח מחדש באופן מיידי במקום להתרנדר מחדש. המבנה על הדיסק הוא <Folder>\PDFlibPas-PageCache\<document fingerprint>\p<page>_z<zoomkey>.bin: תיקיית משנה אחת לכל מסמך, וקובץ אחד לכל שילוב של דף ותקריב. כל היתר קורה אוטומטית; אין API של מטמון שיש לקרוא לו ואין שום דבר שצריך לנקות באופן ידני

uses
  PDFlibViewer;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FViewer := TPDFlibViewer.Create(Self);
  FViewer.Parent := Self;
  FViewer.Align := alClient;
  // One line turns on the cross-session page cache
  FViewer.DiskCacheFolder := 'C:\ProgramData\MyApp\PageCache';
  FViewer.LoadFromFile('C:\Contracts\master-agreement.pdf', '');
end;

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

מדוע מפתח המטמון הוא טביעת אצבע ולא מספר גרסה?

טביעת האצבע של המסמך היא האש (hash) FNV-1a של 64-ביט מעל המחרוזת path|size|last write time, המיוצגת כשישה עשר תווים הקסדצימליים. שלשה זו היא כל אסטרטגיית הפסילה (invalidation): כאשר הקובץ נערך, הגודל שלו או זמן הכתיבה שלו משתנים, ההאש משתנה והמציג פשוט פותח תיקיית משנה אחרת במטמון. התיקייה המיושנת לעולם אינה נבדקת שוב ובסופו של דבר נמחקת על פי אלגוריתם ה-LRU. אין פרוטוקול תגיות גרסה לניהול, אין לוגיקת השוואת זמנים שיכולה לסטות ואין דרך שבה מפת סיביות ישנה תוגש עבור מסמך שעבר שינוי

זהו אותו רעיון של "זהות תוכן כמפתח מטמון" שבו משתמשות מערכות בנייה (build systems), והוא מוכיח את עצמו במניעת מצבי כשל. מטמון השומר קבצים תחת שמו הפשוט של המסמך נדרש לזהות עריכות באופן מפורש, וכל בדיקה מפורשת היא פתח לבעיות במקרי קצה: קובץ ששוחזר מגיבוי עם חותמת זמן ישנה, שמירה השומרת על הגודל ללא שינוי, או נתיב שנבדק תלוי-רישיות במחשב אחד ושלא תלוי במחשב אחר. האש של שלושת הסיגנלים לתוך שם התיקייה הופך את התיישנות המידע לבלתי אפשרית מבחינה מבנית במקום לבדוק אותה תהליכית

מדוע סיבוב התצוגה אינו נכלל במפתח הדיסק?

שם קובץ המטמון מקודד את מספר הדף והתקריב בלבד — זווית סיבוב התצוגה אינה מיוצגת בו במכוון. מטמון הדיסק שומר תמיד את בייטי הרינדור הלא מסובבים; כאשר דף שמור נטען בזמן שהתצוגה מסובבת, הפיקסלים מסובבים בזיכרון לאחר הפיענוח. אם סיבוב היה חלק ממפתח הדיסק, משתמש שעובר בין ארבע זוויות התצוגה היה כותב את אותו דף לדיסק ארבע פעמים, מה שהיה מכפיל פי ארבעה את נפח המטמון ללא שום מידע נוסף, שכן סיבוב הוא ערבול פיקסלים זול בזיכרון בהשוואה לרינדור מלא

מטמון מפות הסיביות בזיכרון אכן משתמש במפתח מורכב, Round(Zoom * 1000) * 4 + Rotation div 90, מכיוון ששם מפת הסיביות המסובבת היא בדיוק מה שצייר הממשק צריך. חוט ה-prefetch ברקע עוקב אחר אותה מתודולוגיה: הוא מרנדר תמיד דפים לא מסובבים ומדווח על מפתחות תקריב פשוטים, וחוט הריצה הראשי מחיל סיבוב בעת קבלת התוצאות — אותו כלל של "החוט הראשי הוא הבעלים של המטמון" שנדון ברינדור דפים מקבילי ובטיחות חוטים. שמירת הסיבוב מחוץ לכל דבר שמתחת לשכבת הזיכרון פירושה רינדור קנוני יחיד לכל דף ותקריב, בכל מקום שבו יקר לאחסן מידע

כיצד גיזום LRU שומר על גבולות תיקיית המטמון?

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

כל פעולת דיסק במטמון — יצירת טביעת אצבע, יצירת תיקייה, עדכון סימון, קריאת וכתיבת דפים, וגיזום — בולעת את החריגות (exceptions) של עצמה. זוהי גישה מכוונת ולא רשלנות: המטמון הוא מאיץ שעושה מאמץ מרבי (best-effort), ודיסק מלא, תיקייה מוגנת לכתיבה או נעילת אנטי-וירוס חייבים להוריד את המציג לרמת "מרנדר כמו קודם", ולעולם לא להביא למצב של "נכשל להציג את הדף". התוצאה הגרועה ביותר ששגיאת IO במטמון יכולה לייצר היא פספוס של מטמון

procedure TMainForm.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  // How many render passes did the disk cache save this session?
  Log(Format('Disk cache hits: %d', [FViewer.DiskCacheHits]));
end;

כיצד הופכים מציג PDF למודע ל-DPI ב-Delphi?

המחלקה TPDFlibViewer מנתבת כל חישוב התלוי ב-DPI דרך שדה יחיד, ותכנון זה של נקודת-שינוי-יחידה הוא שהופך את התמיכה בכל מסך לקלה לניהול. קוד הפריסה, ה-prefetch והרינדור קוראים כולם ערך FScreenDPI יחיד; בזום של 100%, נקודת PDF אחת ממופה ל-ScreenDPI / 72 פיקסלים. כותב המאפיין הציבורי ScreenDPI מנקה את מטמון מפות הסיביות, בונה מחדש את הפריסה, מעדכן את טווחי הגלילה ומרענן את החלון, כך ששינוי DPI הוא הקצאה אחת שכל הצרכנים עוקבים אחריה אוטומטית. אותה מתמטיקת פיקסלים חשובה כאשר אתם מרנדרים עבור מדפסת במקום מסך, נושא המכוסה בתצוגה מקדימה להדפסה ורינדור הקשר התקן

הזיהוי קורה בדריסה של CreateWnd ולא בבנאי (constructor), מכיוון ש-GetDpiForWindow דורש מזהה חלון תקף ובזמן ריצת הבנאי חלון זה עדיין אינו קיים. עד ש-CreateWnd מופעל, הפקד מחזיק בערך ברירת המחדל הישן של 96 כדי שמתמטיקת הפריסה תישאר תקינה. הפונקציה GetDpiForWindow עצמה היא API של Windows 10 1607+, ולכן הפקד מקשר אותה דינמית באמצעות GetProcAddress ונסוג ל-GetDeviceCaps(LOGPIXELSY) במערכות ישנות יותר — היחידה עדיין נטענת בכל מקום, ו-Windows ישן יותר פשוט ידווח על ה-DPI של המערכת

מה קורה כאשר החלון עובר למסך 4K?

מערכת ההפעלה Windows שולחת הודעת WM_DPICHANGED כאשר חלון המוגדר כמודע ל-DPI לכל מסך עובר למסך בעל מקדם קנה מידה שונה, ו-TPDFlibViewer מטפל בה ישירות. המטפל קורא את ה-DPI החדש מהמילה הנמוכה (low word) של wParam, מקצה אותו באמצעות SetScreenDPI — מה שמשליך את מטמון מפות הסיביות שכבר אינו נכון ומסדר מחדש את הדפים — ואז מקבל את מלבן החלון המוצע ש-Windows מעבירה ב-lParam באמצעות SetWindowPos, מה ששומר על גודל לקוח לוגי יציב במהלך המעבר. המשתמש רואה את המסמך מרונדר מחדש בחדות בקנה המידה החדש במקום מפת סיביות שנמתחה על ידי מערכת ההפעלה

// Normally you never touch ScreenDPI: CreateWnd detects the host monitor
// and WM_DPICHANGED tracks moves. Override it only for special targets,
// e.g. rendering the layout as if for a 150% display:
FViewer.ScreenDPI := 144;  // clears the bitmap cache and rebuilds layout

מגבלות שכדאי לדעת לפני שאתם מפיצים

שלוש מגבלות מעשיות ראויות למקום בסקירת התכנון שלכם. ראשית, WM_DPICHANGED מגיע רק אם התהליך מוגדר לכך: המניפסט של האפליקציה שלכם חייב להצהיר על מודעות DPI לכל מסך (per-monitor v2 ב-Windows הנוכחי), הגדרה שבסביבת Delphi IDE נמצאת תחת אפשרויות האפליקציה (Application options). תהליך המודע ל-DPI של המערכת בלבד לעולם אינו מקבל הודעה זו, והפקד מרנדר אז ב-DPI שזיהה בעת יצירת החלון. שנית, מטמון הדיסק מחליף שטח דיסק במהירות — עשרה מסמכים של מפות סיביות של דפים על פני מספר רמות זום הם נפח אחסון אמיתי, ולכן כוונו את DiskCacheFolder למיקום שבו נוח לכם שיגדל, ולא לפרופיל נודד (roaming profile). שלישית, תיקיית המטמון זקוקה להרשאת כתיבה עבור המשתמש המריץ; ללא הרשאה זו המציג עדיין יעבוד בשקט עם אפס פגיעות במטמון — בדקו את DiskCacheHits במהלך הבדיקות שלכם אם אתם חושדים שהמטמון אינו נכנס לפעולה

יחד, שתי התכונות הללו משפרות את האיכות הנתפסת של אפליקציה עשירת מסמכים יותר מרוב אופטימיזציות הרינדור: מסמכים שהמשתמשים שלכם חוזרים אליהם נפתחים באופן מיידי, והתצוגה נשארת חדה בכל מסך שהיא נוחתת עליו. שניהם מגיעים כמאפיינים רגילים של TPDFlibViewer ב-losLab PDF Library, כך שאימוץ שלהם הוא אחר צהריים של חיבורים ולא פרויקט לפיתוח מנוע רינדור