HotPDF RenderCacheFolder הופך את מטמון העמודים המרונדרים בזיכרון של רכיב ה-HotPDF Delphi component למטמון עמודים מתמשך על דיסק: עמודים מרונדרים נכתבים בתור קבצי PNG תחת תיקייה שתבחרו, ובפתיחה הבאה של אותו מקור PDF, RenderLoadedPageToBitmapCached קורא אותם חזרה במקום לרסטר בשנית. סדר החיפוש הוא זיכרון, אחר כך דיסק, ואז ה-renderer
שכבת הדיסק נמצאת ב-API מאז v2.416.0, אבל עד v2.770.140 היא מעולם לא באמת השיבה עמוד עבור קריאת LoadFromFile או LoadFromStream רגילה. התיקון כפה שאלה שכל מטמון מתמשך חייב לענות עליה: איך יודעים שהקובץ שפתחתם היום הוא המסמך שרינדרתם אתמול, ומה קורה לעמודים השמורים כשזה לא המקרה? להלן התשובות ש-HotPDF התייצב עליהן, כולל המקומות שבהם הוא מסרב בכוונה לשמור במטמון
איך מטמון הרינדור על הדיסק של HotPDF עובד?
מטמון הרינדור על הדיסק של HotPDF הוא שכבה שנייה מאחורי מטמון הרסטר בזיכרון, והוא משתתף רק כש-RenderCacheFolder הוא נתיב לא ריק. קריאה ל-RenderLoadedPageToBitmapCached(PageIndex, DPI) סורקת קודם את הרשומות בזיכרון, מאונדקסות לפי אינדקס עמוד, DPI ווריאנט של הגדרות רינדור. בהחטאה היא פונה אל שכבת הדיסק; פגיעת דיסק מפענחת את ה-PNG, מקדמת אותו חזרה אל הזיכרון ומחזירה עותק בבעלות הקורא. רק כששתי השכבות מחטיאות העמוד עובר דרך מפענח ה-content-stream המתואר ב-רינדור עמוד PDF טעון אל TBitmap, וה-bitmap הטרי נכתב אז גם לדיסק
על הדיסק הפריסה משעממת בכוונה. כל מסמך מקבל תת-תיקייה שנקראת ממפתח מסמך בן 16 תווי hex ובתוספת וריאנט רינדור בן 16 תווי hex, כל עמוד נשמר בתור <page>@<dpi>.png, ו-index.txt בשורש שומר על סדר מסמכים לפי שימוש אחרון מאחורי תג schema. אי-התאמת schema מרוקנת את התיקייה בשימוש הראשון. כתיבות הולכות קודם לקובץ זמני ומוחלפות למקום בהחלפה אטומית, כך שקריסה באמצע כתיבה משאירה או את העמוד הישן או כלום, לעולם לא חצי PNG. PNG שנכשל בפענוח נמחק ונספר בתור החטאה
שלוש מגבלות חוסמות את התיקייה:
RenderCacheMaxDocuments(ברירת מחדל 20) מגביל את מספר תתי-התיקיות של מסמכים; התיקייה שלא הייתה בשימוש זמן רב מפונה ראשונהRenderCacheMaxBytes(ברירת מחדל 524288000, כלומר 500 MB) מגביל את הגודל הכולל של כל קבצי ה-PNG תחת השורש- כל תיקיית מסמך מחזיקה לכל היותר 200 תמונות עמודים; התקרה לכל מסמך הזאת קבועה ב-THotPDF ואינה property מפורסם
RenderCacheCapacity (ברירת מחדל 8) הוא כפתור נפרד: הוא קובע כמה עמודים מרונדרים שכבת הזיכרון מחזיקה, ואין לו שום קשר לטביעת הרגל על הדיסק
uses
SysUtils, Graphics, HPDFDoc;
procedure WarmThumbnails(const FileName: string);
var
Pdf: THotPDF;
Bmp: TBitmap;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
// מגדירים את שכבת הדיסק לפני הרינדור הממוסף הראשון:
// התיקייה ושני המגבלות נקראים כשהשכבה נמצאת בשימוש ראשון
Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
Pdf.RenderCacheMaxDocuments := 50;
Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
Pdf.RenderCacheCapacity := 16; // עמודים בזיכרון
if Pdf.LoadFromFile(FileName) > 0 then
for I := 0 to Pdf.LoadedPageCount - 1 do
begin
Bmp := Pdf.RenderLoadedPageToBitmapCached(I, 96);
if Bmp <> nil then
try
// מוסרים כאן את העותק לפס התמונות הממוזערות
finally
Bmp.Free; // הקריאה הממוספת תמיד מחזירה עותק בבעלות הקורא
end;
end;
finally
Pdf.Free; // מאז v2.770.140 זה כבר לא מוחק את רשומות הדיסק
end;
end;
מריצים את אותה פרוצדורה פעמיים וההרצה השנייה לעולם לא מרסטרת עמוד שנכנס למטמון. אובייקט מטמון הדיסק נוצר בעצלות ברינדור הממוסף הראשון וחי עד שמופע ה-THotPDF משוחרר, ולכן שינוי של RenderCacheFolder, RenderCacheMaxDocuments או RenderCacheMaxBytes אחרי הנקודה הזאת לא מזיז ולא משנה גודל של מטמון שנפתח כבר. עמודים גדולים מדי למדיניות הקבלה בזיכרון (כברירת מחדל רשומה בודדת לא רשאית לחרוג מ-64 MiB של פיקסלים בני 32 ביט) לא נשמרים גם הם, ושכבת הדיסק נשאלת רק כל עוד RenderFallbackPolicy שומר על ברירת המחדל rfpIgnore שלו, כי דיאגנוסטיקות ה-fallback לא נשמרות לצד ה-PNG
למה RenderCacheFolder מעולם לא עבד לפני v2.770.140?
ל-RenderCacheFolder לא הייתה שום השפעה לפני v2.770.140 כי שכבת הדיסק אינדקסה מסמכים לפי hash של בייטי המקור שטעינות רגילות מעולם לא שמרו. מפתח המסמך הגיע מ-SHA-256 מעל עותק פנימי של בייטי ה-PDF הגולמיים, אבל LoadFromFile ו-LoadFromStream מפרסרות את המקור במקומו ולא מחזיקות עותק כזה; השדה מולא רק באופן זמני בנתיב התאוששות מהצפנה ונוקה שוב מיד אחר כך. בלי בייטים, המפתח היה תמיד ריק, ומפתח ריק פירושו ששכבת הדיסק נעקפת. בלי שגיאה, בלי אזהרה, סתם תיקייה שנשארה ריקה
הפיכת המפתח ללא ריק חשפה באג שני שהסתתר מאחורי הראשון. InvalidateRenderedPageCache הישנה מחקה את תיקיית הדיסק של המסמך, ו-InvalidateRenderedPageCache רץ בתחילת כל טעינה, בכל עריכה ובתוך Free. כלומר ברגע שהמפתח היה עובד, כל סשן של צופן היה משמיד את המטמון שלו ביציאה, והסשן הבא היה מתחיל קר בכל מקרה. גרוע מכך, המפתח חושב מחדש מאותו מקור אחרי עריכה, כך שרינדורים של המסמך הערוך היו נשמרים תחת המפתח של הקובץ המקורי ומוגשים לסשן הבא שפתח את ה-PDF הלא משונה. v2.770.140 מתקן את הזהות ואת הפסילה יחד; תיקון של אחד מהם לבדו היה משגר לאוויר מטמון מת או מטמון משקר
איך HotPDF מזהה PDF בלי לקרוא את כל הקובץ
HotPDF מזהה PDF שנטען מקובץ מקומי בעזרת טביעת אצבע של גודלו, מועד הכתיבה האחרון שלו וה-64 KiB הראשונים והאחרונים שלו, ומזהה מקור stream או random-access בעזרת SHA-256 של כל תוכנו. שתיהן נלכדות פעם אחת, כשטעינה מצליחה, ו-16 תווי ה-hex הראשונים של ה-digest של ה-SHA-256 (64 ביט) הופכים למפתח המסמך
| מקור | זהות | עלות | מתי נלכדת |
|---|---|---|---|
LoadFromFile | גודל + LastWriteTime + 64 KiB ראשונים ואחרונים, עם hash של SHA-256 | קריאה של לכל היותר 128 KiB, בלתי תלויה בגודל הקובץ | כל טעינה מוצלחת, גם אם RenderCacheFolder מוגדר רק מאוחר יותר |
LoadFromStream | SHA-256 של ה-stream כולו | מעבר אחד מלא על המקור | רק אם RenderCacheFolder הוגדר לפני הטעינה |
LoadFromRandomAccessSource | SHA-256 של המקור כולו | מעבר אחד מלא על המקור | רק אם התיקייה הוגדרה קודם והטווח כולו זמין |
כל מקור עם רשומת /Encrypt | אף אחת | אף אחת | לעולם לא; שכבת הדיסק נעקפת |
טביעת האצבע של הקובץ היא פשרה מכוונת. hash מלא של ארכיון סרוק בן 400 MB בכל פתיחה יכול לעלות יותר מרינדור של שני העמודים שמשתמש באמת מביט בהם. האזורים הנדגמים אינם שרירותיים: ה-header יושב בראשית הקובץ, וה-trailer וסעיף ה-cross-reference האחרון יושבים בסופו (ISO 32000-1 §7.5). עדכון מצטבר מצרף body, סעיף cross-reference ו-trailer חדשים (§7.5.6), ולכן הוא משנה את הגודל ואת הזנב בבת אחת. שכתוב מלא על ידי כל כלי נורמלי משנה את מועד הכתיבה האחרון. עבור קבצים עד 128 KiB שתי הדגימות מכסות כל בייט, ולכן מסמכים קטנים ממוחשים למעשה במלואם
הסיכון השיורי הוא שינוי באותו גודל, במקום, לאמצעו של קובץ גדול שהכותב שלו משחזר אחר כך את חותמת הזמן המקורית. זה דורש כלי שמשמר בכוונה מועדי שינוי בזמן עריכת תוכן, מה שנדיר אך לא בלתי אפשרי, ובמקרה כזה המטמון מגיש עמודים מיושנים. הצד ההפוך תמים: העתקת קובץ ב-Windows נוטה לשמר את מועד הכתיבה האחרון שלו, ולכן עותק של מסמך שכבר במטמון פוגע באותן רשומות, מה שנכון כי הבייטים זהים
ל-streams אין מועד שינוי בכלל, ולכן הזהות הישרה היחידה היא התוכן. HotPDF משלם על מעבר ה-SHA-256 המלא הזה רק כשביקשתם מטמון דיסק לפני הטעינה; כל קורא אחר של LoadFromStream לא רואה עלות נוספת. זה הופך את סדר ההשמה של ה-properties לקריטי:
procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
const CacheRoot: string);
begin
// סדר שגוי עבור streams: ה-hash של התוכן מחושב רק כשה
// תיקייה כבר מוגדרת, ולכן המסמך הזה היה עוקף את שכבת הדיסק
// Pdf.LoadFromStream(Data);
// Pdf.RenderCacheFolder := CacheRoot;
Pdf.RenderCacheFolder := CacheRoot; // מגדירים ראשון
Data.Position := 0;
if Pdf.LoadFromStream(Data) <= 0 then
raise Exception.Create('The stream is not a loadable PDF');
end;
מקור random-access שעדיין בהורדה (חלק מהטווחים עוד לא זמינים) אינו מקבל זהות במקום hash של תוכן חלקי, ואם חישוב הזהות נכשל מכל סיבה שהיא הטעינה עדיין מצליחה; המסמך פשוט מתרנדר בלי שכבת הדיסק
מה פוסל רשומה במטמון הדיסק של HotPDF?
רשומה במטמון הדיסק של HotPDF לעולם לא נפסלת על ידי מחיקתה בעריכה; במקום זאת, עריכת המסמך הטעון מפילה את זהות המסמך, כך ששכבת הדיסק נעקפת למשך שארית אותה טעינה והעמודים השמורים נשארים תקפים עבור המקור הלא משונה. רשומות עוזבות את הדיסק רק דרך מגבלות ה-LRU והבייטים, PNG פגום, או שינוי schema
המפתח מתאר מקור על דיסק, לא את גרף האובייקטים בזיכרון. ברגע שחותמים עמוד או משנים הערה, המסמך כבר לא תואם למקור הזה, ולכן לא קריאה ולא כתיבה תחת המפתח שלו היו נכונות. מאז v2.770.140, גם פסילה ברמת מסמך וגם ברמת עמוד מנקות את הזהות במקום לגעת בתיקייה, ויש שומר שני עבור עריכות שלא קראו ל-InvalidateRenderedPageCache: לפני השימוש בשכבת הדיסק, THotPDF בודק אם אובייקט טעון כלשהו dirty ומתייחס למסמך dirty בתור חסר זהות
הגדרות הרינדור פועלות הפוך. החלפת PageRenderBackend (או קריאה ל-UseNativeGDIRenderBackend), וקריאה ל-ConfigureRenderICCWorkflow או ל-ClearRenderICCWorkflow, מרוקנות את העמודים בזיכרון אבל משאירות את הזהות, כי המסמך עדיין תואם למקורו. ההגדרות האלה משנות את הפיקסלים בלי להיות חלק מהוריאנט בזיכרון, ולכן מפתח הדיסק מקפל פנימה את שם ה-backend, את דגל פיצוי הנקודה השחורה ואת ה-digests של SHA-256 של פרופילי ה-ICC ל-proof ולפלט. הוריאנט עצמו כבר מכסה את כוונת הצבע, ה-dithering של הפלט, תצוגת ה-overprint, מצב מסכת ה-luminosity, מדיניות ה-fallback ואת נראותה של כל קבוצת תוכן אופציונלית, כך שהדלקה או כיבוי של שכבה מרנדרים לתיקייה אחרת במקום לדרוס את התצוגה המוגדרת
כדי להחזיר מסמך ערוך אל שכבת הדיסק, תנו לו זהות מקור חדשה על ידי שמירתו וטעינת התוצאה:
procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
// אחרי עריכת המסמך הטעון: מרעננים את העמודים בזיכרון.
// זהות המקור כבר נעלמה, ולכן שום דבר לא נקרא מתוך תיקיית הדיסק
// של המסמך המקורי ולא נכתב אליה
Pdf.InvalidateRenderedPageCache;
// קובץ שנשמר יש לו גודל ומועד כתיבה אחרון חדשים, ולכן זהות
// חדשה; רינדורים אחרי טעינה זו נשמרים תחת המפתח החדש
Pdf.SaveLoadedDocument(EditedFile);
if Pdf.LoadFromFile(EditedFile) <= 0 then
raise Exception.Create('Could not reload the edited document');
end;
תיקיית המסמך המקורי מושארת לנפשה ומזדקנת החוצה דרך RenderCacheMaxDocuments ו-RenderCacheMaxBytes כמו כל רשומה אחרת. אם המשתמש פותח מחדש את המקור הלא ערוך, העמודים שלו עדיין שם
גבולות אבטחה: מקורות מוצפנים ותיקיות מקושרות
מטמון הרינדור על הדיסק של HotPDF מסרב בכוונה לשני סוגי קלט: הוא לעולם לא כותב עמודים של PDF מוצפן לדיסק, והוא לעולם לא עוקב אחרי תת-תיקיית מסמך שהיא junction או reparse point אחר. שני הכללים מסחרים בפגיעות מטמון תמורת אי-דליפת נתונים או מחיקת הקבצים הלא נכונים
PDF מוצפנים לעולם לא נשמרים במטמון על הדיסק
עמוד מרונדר הוא תוכן שהוצפן ופוענח. כתיבתו בתור PNG פשוט לתוך תיקיית מטמון הייתה משאירה עותק קריא של מסמך מוגן סיסמה על הדיסק, מחוץ להגנה שבחר המחבר (ISO 32000-1 §7.6). לכן HotPDF לא לוכד זהות עבור שום מקור שה-trailer שלו נושא רשומת /Encrypt, כולל קבצים שנפתחו עם סיסמה או עם סיסמת משתמש ריקה. המסמכים האלה עדיין משתמשים בשכבת הזיכרון, שמתה עם התהליך
תתי-תיקיות junction נדחות מאז v2.770.173
שורש המטמון הוא בחירתכם, והצבעה על junction מותרת. תתי-התיקיות של המסמכים מתחתיה הם עניין אחר: המטמון יוצר, קורא, נוגע ומוחק אותן בעצמו, במהלך התאוששות בעת הפעלה (שמסירה קבצים זמניים שנותרו), חיפוש (שמעדכן חותמות זמן), אגירה, פסילה ושלוש מגבלות הפינוי. אם מישהו עם הרשאת כתיבה לשורש המטמון מחליף תיקיית מסמך ב-junction לספרייה אחרת, כל אחד מהנתיבים האלה היה עוקב אחריה, והפינוי היה מוחק קבצים במקום שהמטמון מעולם לא החזיק בו. מאז v2.770.173 כל אחת מנקודות הכניסה האלה בודקת את תכונת ה-reparse point ומדלגת על תיקיית מסמך מקושרת: חיפוש סופר החטאה, אגירה סופרת כשל כתיבה, והפינוי משאיר אותה לנפשה
נתיבי Unicode ושורשים משותפים
שני תיקונים נלווים משנים אם מפרסים אל פרופילי משתמש. לפני v2.770.135, RenderCacheFolder היה AnsiString, ולכן תיקייה מחוץ ל-code page המערכתי (שם משתמש סיני בהתקנת Windows באנגלית, למשל) הומרה באובדן לפני שהמטמון ראה אותה; ה-property הוא עכשיו string של Unicode, וההחלפה האטומית משתמשת ב-Windows API הרחב. מאז v2.770.52, כמה מופעי THotPDF בתהליך אחד שמצביעים על אותו שורש (אחרי הרחבת נתיב, בהשוואה שאינה רגישה לרישיות) חולקים אינדקס אחד ונעילה אחת עם מונה הפניות. קודם לכן, כל מופע דרס את index.txt בעותק שלו ואכף את המגבלות מול תצוגתו החלקית, כך שהתיקייה יכלה לגדול כמה פעמים מעבר לתקציב שלה
השיתוף הזה נעצר בגבול התהליך. שני תהליכים נפרדים על אותו שורש עדיין מחזיקים אינדקסים נפרדים בזיכרון, ולכן תנו לכל אפליקציה שרצה במקביל שורש מטמון משלה. צופים שמרנדרים על worker threads מסתדרים בתוך תהליך אחד: PrefetchLoadedPages והתור שמכוסה ב-רינדור ברקע עם תור בקשות שניהם עוברים דרך אותו נתיב ממוסף ואותה נעילה
עזר זריז: צ'קליסט RenderCacheFolder
- מגדירים
RenderCacheFolder,RenderCacheMaxDocumentsו-RenderCacheMaxBytesלפני הקריאה הראשונה ל-RenderLoadedPageToBitmapCached; עבור טעינות stream ו-random-access, מגדירים את התיקייה לפני הטעינה - משדרגים ל-v2.770.140 ומעלה אם סומכים על שכבת הדיסק; גרסאות קודמות מקבלות את ה-property אבל לעולם לא משיבות עמוד מהדיסק עבור טעינות רגילות
- מצפים לאין שמירת דיסק עבור PDF מוצפנים, עבור מסמכים שנערכו אחרי הטעינה, או בזמן ש-
RenderFallbackPolicyאינוrfpIgnore - משחררים את מופע ה-THotPDF כרגיל; מאז v2.770.140 גם
FreeוגםInvalidateRenderedPageCacheלא מוחקים רשומות דיסק - שינוי
PageRenderBackendאו זרימת ה-ICC משאיר את המסמך בשכבת הדיסק תחת מפתח אחר - משתמשים בשורש מטמון אחד לכל אפליקציה רצה; מופעים בתוך תהליך אחד חולקים את האינדקס מאז v2.770.52
- משאירים את שורש המטמון במיקום לכל משתמש; תתי-תיקיות מסמך שהן junction מדולגות מאז v2.770.173
מטמון עמודים מתמשך משתלם בעיקר בצופן שפותח מחדש את אותם מסמכים כל היום, מה שהוא בדיוק הצורה של ארכיטקטורת צופן PDF מותאם ב-Delphi שמתוארת במקום אחר בבלוג הזה. RenderCacheFolder, מטמון הרסטר בזיכרון ומנוע הרינדור לעמודים מגיעים עם HotPDF Delphi PDF component עבור Delphi ו-C++Builder