HotPDF מפריד את מציג ה-PDF שלו ב-Delphi לשני חלקים: THPDFViewerModel, מחלקה פשוטה שמחזיקה מצב זום, סיבוב, חיפוש, הדגשה וניווט ללא תלות בידית חלון (window handle), ו-THPDFViewer, פקד מבוסס TScrollBox שהופך את המצב הזה לפיקסלים. הפיצול הזה הוא מה שמאפשר ללוגיקת המציג לרוץ, ולהיבדק, מבלי ליצור אף פעם טופס (form)
רוב פקדי המציג המותאמים אישית לא נראים כך. רמת הזום יושבת בשדה פרטי על הפקד, ניווט העמודים כובל את הגבולות שלו בתוך handler מסוג OnClick של כפתור, והדרך היחידה לדעת אם Ctrl+גלילה מכבד תקרת זום היא להריץ את האפליקציה, ללחוץ, ולהסתכל. פקד שנבנה כך עובד טוב עד שהוא זקוק לחבילת רגרסיה, או למארח שני — תיבת דו-שיח של תצוגה מקדימה להדפסה, פס תמונות ממוזערות, סוקר אצווה (batch reviewer) ללא חלון גלוי בכלל — והמצב שאתה זקוק לו מתברר כמרותך אל TWinControl שמתעקש על ידית אמיתית לפני שהוא יעשה משהו
למה בכלל פקד מציג PDF זקוק לפיצול MVC?
מציג PDF זקוק לסוג הזה של פיצול משום שהמצב שלו וההצגה שלו משתנים מסיבות שונות ובקצבים שונים. אינדקס עמוד, זום, סיבוב תצוגה, פגיעות חיפוש, ואזורי הדגשה הם מצב עסקי: אפשר לחשב, לאמת, ולסדר אותם (serialize) בלי פיקסל בודד על המסך. ציור ביטמאפ, לכידת העכבר, וציור מלבן בחירת-מרקיז (marquee) הם עניינים של הצגה שהגיוניים רק ברגע שפקד קיים. HotPDF שומר את הקבוצה הראשונה ב-THPDFViewerModel, מחלקה ללא אב-טיפוס חלונות VCL בכלל, ואת הקבוצה השנייה ב-THPDFViewer, שמחזיקה מופע מודל ומגיבה אליו — קרוב יותר לזוג מודל-תצוגה מאשר ל-MVC תלת-שכבתי מספר לימוד, שכן אין מחלקת בקר (Controller) נפרדת ו-THPDFViewer עצמו הופך אירועי מקלדת ועכבר גולמיים לקריאות למודל. מה שחשוב יותר מהתווית הוא כיוון התלות: שום דבר ב-THPDFViewerModel לא דורש Handle, לולאת הודעות, או שולחן עבודה גלוי, וזה בדיוק מה שמאפשר לחבילת הבדיקות של HotPDF עצמה להריץ דפדוף, כיבוש זום, פקודות מקלדת, ומעברי קואורדינטות הלוך-ושוב דרך DUnitX בלי לפתוח חלון
uses
DUnitX.TestFramework,
HPDFDoc, HPDFViewerModel;
type
[TestFixture]
TViewerModelTests = class
public
[Test]
procedure ZoomInStopsAtTheTopPresetLevel;
end;
procedure TViewerModelTests.ZoomInStopsAtTheTopPresetLevel;
var
Doc: THotPDF;
Model: THPDFViewerModel;
begin
Doc := THotPDF.Create(nil);
Model := THPDFViewerModel.Create;
try
Doc.LoadFromFile('sample.pdf');
Model.Document := Doc;
Model.Zoom := 64.0; // top of the preset table (6400%)
Model.ZoomIn; // already at the ceiling
Assert.AreEqual(64.0, Model.Zoom, 0.0001);
finally
Model.Free;
Doc.Free;
end;
end;
מה THPDFViewerModel באמת מחזיקה
THPDFViewerModel מחזיקה כל מה שמציג צריך כדי לענות מה אמור להיות כרגע על המסך, מבלי להחזיק איך לצייר את זה. PageIndex, PageNumber, ו-PageCount עוקבים אחר מיקום; Zoom ו-ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) עוקבים אחר קנה מידה; ViewRotation עוקב אחר סיבוב על-מסך לא-הרסני שאף פעם לא נוגע בערך /Rotate של העמוד עצמו. פונקציות ניווט — FirstPage, PriorPage, NextPage, LastPage — ופונקציות זום — ZoomIn, ZoomOut, שעוברות על טבלה קבועה של תשע-עשרה רמות מוגדרות מראש מ-5% עד 6400% — נמצאות גם הן כאן, לצד FindAll/FindNext/FindPrevious לחיפוש טקסט ו-AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions להערות עמוד קבועות שקורא רוצה לשמור בין עיבודים. המודל מחזיק פלט כמו גם קלט: CreateCurrentPageSnapshot ו-CreateCurrentPageMetafile מייצאים בדיוק את העמוד הנוכחי על המסך, ו-PrintCurrentView שולחת את אותה תצוגה נוכחית — עמוד נוכחי, DPI נגזר מזום נוכחי, סיבוב נוכחי — ל-TPrinter, עבודה צרה יותר, ממוקדת-תצוגה, מצינור ההדפסה כלל-המסמך המתואר בסיור ההדפסה של HotPDF עם TPrinter. כל מוטציה חשובה גם מעלה אירוע תואם — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — כך שמנוי מגלה מה השתנה בלי לבצע polling
איך THPDFViewer יודעת מתי לצייר מחדש?
THPDFViewer יודעת מתי לצייר מחדש משום שהיא נרשמת למודל במקום לנחש. הבנאי (constructor) של THPDFViewer יוצר THPDFViewerModel פרטי, ואז מחבר כל אחד מאירועי ההודעה שלו — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — ל-handler פרטי תואם. התפקיד של כל handler קטן: לקרוא ל-RefreshDocument, הפונקציה שבפועל מבצעת רסטריזציה לעמוד הנוכחי דרך אותו מנוע עיבוד עמודים במטמון המתואר בהפנימיות של עיבוד עמוד-לביטמאפ ב-HotPDF, ואז מרכיבה תיבות הדגשה ופגיעות חיפוש מעל, ומיישמת את סיבוב התצוגה הנוכחי. מאפיינים מפורסמים (published) כמו PageIndex, Zoom, ZoomMode, ו-ViewRotation הם מעבירים דקים (thin forwarders) — ה-getter קורא FModel.PageIndex, ה-setter כותב FModel.PageIndex — כך שמתוך Object Inspector או מתוך קוד, נראה שהפקד מחזיק את המצב ישירות, אף על פי ש-THPDFViewerModel הוא המקום היחיד שבו המצב באמת יושב. הקוד הקורא גם אינו מוגבל לקבוצת המשנה המועברת: THPDFViewer חושף את המודל עצמו דרך מאפיין Model: THPDFViewerModel לקריאה בלבד, כך שקוד שרוצה FindFormFieldAt או PrefetchCurrentPageSnapshots — ששני אלה הפקד לא חושף מחדש — יכול לעקוף את העטיפה ולקרוא ישירות למודל
procedure THPDFViewer.RefreshDocument;
var
Bitmap: TBitmap;
DPI: Integer;
begin
// simplified: the real method also resolves fit-mode DPI
// and composites highlight and search-hit rectangles first
if (FModel.Document = nil) or (FModel.PageIndex < 0) then Exit;
DPI := Round(96 * FModel.Zoom);
Bitmap := FModel.Document.RenderLoadedPageToBitmapCached(FModel.PageIndex, DPI);
try
FModel.ApplyViewRotation(Bitmap);
FImage.Picture.Bitmap.Assign(Bitmap);
finally
Bitmap.Free;
end;
end;
BeginUpdate ו-EndUpdate: עצירת סופות ציור-מחדש
BeginUpdate ו-EndUpdate קיימות משום ששינוי לוגי בודד נוגע לעיתים קרובות בכמה חלקי מצב בבת אחת, וציור מחדש אחרי כל חלק היה בזבזני ורועש חזותית. החלפת המסמך הטעון היא הדוגמה הברורה ביותר: הצבה ל-THPDFViewerModel.Document מאפסת את סיבוב התצוגה, מנקה פגיעות חיפוש, מנקה אזורי הדגשה, וקופצת לעמוד הראשון, וכל אחד מהצעדים הללו בדרך כלל מפעיל אירוע שינוי משלו. THPDFViewerModel עוטפת את הרצף הזה ב-BeginUpdate/EndUpdate, זוג עם ספירת-הפניות שבו קריאות מקוננות מפעילות את OnBeginUpdate רק במעבר לתוך הקריאה החיצונית ביותר ואת OnEndUpdate רק במעבר בחזרה החוצה. THPDFViewer עוקבת אחר אותו עומק בצד שלה ומדלגת על RefreshDocument עבור כל אירוע פרטני כל עוד המונה מעל אפס, ואז מציירת מחדש בדיוק פעם אחת כשהאצווה נסגרת. האירועים הפרטניים עדיין מופעלים במהלך האצווה, כך שמנוי שאכפת לו רק מ-OnSearchChange עדיין שומע על כך; רק הציור-מחדש של הפקד עצמו מתכווץ לקריאה אחת במקום ארבע
איך הדגשת מרקיז ממפה גרירת עכבר בחזרה לקואורדינטות PDF?
הדגשת מרקיז ממפה גרירת עכבר בחזרה לקואורדינטות PDF דרך זוג פונקציות מודל שנבנו בדיוק למסע ההלוך-ושוב הזה: PagePointToView ו-ViewPointToPage. שתיהן מקבלות אינדקס עמוד, DPI, ונקודה, ושתיהן פותרות את הטרנספורמציה בשני שלבים — קודם ערך ה-/Rotate של העמוד עצמו ומקור ה-PDF שלו בפינה השמאלית-תחתונה, ואז ה-ViewRotation הנפרד והלא-הרסני של התצוגה ומקור ההתקן העליון-שמאלי של המציג — במיוחד כדי שהכיוון ההפוך יוכל לבטל את שני השלבים בסדר הפוך מדויק ולעבור הלוך-ושוב נכון על פני כל שש-עשרה השילובים של סיבוב עמוד וסיבוב תצוגה. THPDFViewer קוראת ל-ViewPointToPage כאשר המשתמש משחרר את העכבר אחרי גרירת מלבן במצב אינטראקציה vimHighlight, הופכת את שתי נקודות ההתקן ל-THPDFRectangle במרחב העמוד, ומוסרת אותו ל-Model.AddHighlightRegion. פרט אחד ששווה לדעת אם בונים משהו דומה: לכידת העכבר שייכת למציג הנצר של TScrollBox, לא ל-TImage הבן שהביטמאפ מצויר לתוכו, משום ש-TControl.MouseCapture מוגן (protected) ורק הפקד ההורה יכול לתבוע אותה — כך שגרירה שיוצאת מגבולות התמונה לפני שהכפתור עולה עדיין נפתרת דרך ה-MouseMove/MouseUp המוחלפים (overridden) של המציג עצמו במקום להיזרק בשקט על ידי פקד הבן
var
ViewPt, PagePt: THPDFViewerPoint;
Rect: THPDFRectangle;
begin
ViewPt.X := 240; // device pixels inside the rendered image
ViewPt.Y := 96;
if Model.ViewPointToPage(Model.PageIndex, ViewPt, PagePt,
RenderedDPI) then // DPI you last rendered at
begin
Rect.Left := PagePt.X - 40; Rect.Bottom := PagePt.Y - 10;
Rect.Right := PagePt.X + 40; Rect.Top := PagePt.Y + 10;
Model.AddHighlightRegion(Model.PageIndex, Rect);
end;
end;
מה הפיצול קונה לך מעבר לחבילת בדיקות ירוקה
התועלת אינה מוגבלת לבדיקות שעוברות בעבודת CI ללא הפעלת שולחן-עבודה. משום ש-THPDFViewer מעבירה אל THPDFViewerModel במקום לשכפל את הלוגיקה שלה, HotPDF הצליחה להוסיף צרכן שלישי — THPDFViewerAction ותת-מחלקות קונקרטיות כמו THPDFZoomInAction ו-THPDFFindNextAction — שמחברות ניווט, זום, חיפוש, וסיבוב ל-TActionList תקני של Delphi, כך שכפתור סרגל כלים או פריט תפריט יכולים להניע את המציג באופן דקלרטיבי, ולהפעיל את עצמם אוטומטית בהתאם לשאלה אם מציג נפתר כרגע כיעד של הפעולה. אף אחד מהשכבה הזו לא היה צריך לדעת דבר על ביטמאפים או GDI; היא קוראת ל-Viewer.NextPage או ל-Viewer.Model.FindNext, ושרשרת האירועים הקיימת דואגת לציור-מחדש. ומשום ששום דבר ב-THPDFViewerModel לא מפנה אל TScrollBox, TImage, או ידית חלון, מכונת המצבים שמתחת אינה מרותכת גם היא לפקד ההוא היחיד — אותו מודל יכול לשבת מאחורי משטח עיבוד שונה בלי לגעת בשורה אחת של לוגיקת ניווט, זום, או חיפוש
איפה מטמון העיבוד עוזר, ואיפה לא
מטמון העיבוד של THPDFViewerModel עוזר בתוך מסמך טעון, אבל הוא לא משנה מה עולה לטעון את המסמך הזה מלכתחילה. CreatePageSnapshot, CreateCurrentPageSnapshot, ופונקציות ה-prefetch PrefetchPageSnapshots/PrefetchCurrentPageSnapshots כולן עוברות דרך אותו מנוע עיבוד במטמון הממופתח לפי עמוד ו-DPI, כך שדפדוף בחזרה לעמוד שכבר נצפה באותה רמת זום הוא פגיעה במטמון (cache hit) ולא עיבוד מחדש, ו-prefetch של רדיוס קטן של עמודים שכנים מחליק את המקרה הנפוץ של קורא שמדפדף קדימה עמוד אחד בכל פעם. אף אחד מזה לא נוגע בעלות של קריאת LoadFromFile הראשונית, לעומת זאת, ומציג שנבנה לפתוח כל מה שמשתמש גורר אליו נתקל בסופו של דבר בקובץ גדול מספיק כדי להפוך את הקריאה הזו לצוואר הבקבוק האמיתי. עבור החלופה המדורגת, מבוססת-ידית, לטעינה מלאה — ששווה להכיר לפני שהיום הזה מגיע — ראה המאמר הנלווה על ה-Direct File API עבור קובצי PDF גדולים
מחלקות המודל והתצוגה המתוארות כאן הן שני חלקים נוספים מאותו משטח מסמך-טעון המשמש בכל רכיב HotPDF עבור Delphi ו-C++Builder, בנוי כך שאפשר להניע אותו מטופס, מ-TActionList, או משניהם בכלל לא