מאמר טכני

הדגשת PDF לא-הרסנית ב-Delphi: שכבת הסקירה של HotPDF

מלבן שמצויר סביב פסקה במהלך סקירה לא חייב להפוך לסימון בתוך ה-PDF. THPDFViewerModel של HotPDF חושפת את AddHighlightRegion, פונקציה ששומרת כל הדגשה כרשומה בזיכרון בלבד ולא כשינוי במסמך הטעון, כך שסוקר יכול לסמן עשרות עמודים בעוד הקובץ על הדיסק נשאר זהה בייט-לבייט למה שהיה. זום ל-6400%, סיבוב העמוד ב-90 מעלות, מעבר מ-Fit Width ל-Fit Page, ואותו מלבן עדיין נוחת על אותה פסקה, משום שהחישוב הקואורדינטי עובר דרך גיאומטריית העיבוד האמיתית ברגע שהסימון צויר

כלי סקירה שנבנים סביב מציג PDF נתקלים בבעיה הזו כל הזמן. מסך הערות עריכה, בדיקת QA על חשבוניות שנוצרו אוטומטית, תהליך אישור פנימי: כולם צריכים לאפשר למישהו להפנות תשומת לב לאזור בעמוד בלי שכל סימון-טיוטה יהפוך לשינוי קבוע בקובץ, ובלי לפנות למערכת-משנה מלאה של הערות רק כדי להציג תיבה צבעונית בעוד מישהו עדיין מחליט אם הסימון שייך. HotPDF עונה על כך עם שכבת הדגשה ייעודית שיושבת כולה בצד ה-Model של הפיצול המתואר בבניית מציג PDF מותאם אישית עם ארכיטקטורת MVC ב-Delphi, וזו גם הסיבה שאותה רשימת הדגשות אפשר להניע מבדיקת יחידה בלי ידית חלון בשום מקום

מה בדיוק AddHighlightRegion של HotPDF שומרת?

AddHighlightRegion שומרת בדיוק שלושה דברים לכל סימון: אינדקס עמוד מבוסס-אפס, THPDFRectangle בקואורדינטות מרחב-משתמש של PDF, ו-TColor, הכל ארוז כרשומת THPDFViewerHighlight בתוך THPDFViewerModel. קריאה ל-Viewer.HighlightRegion(PageIndex, PageRect, clYellow), או המקבילה Model.AddHighlightRegion, מוסיפה אחת מהרשומות האלה למערך פרטי ומחזירה את האינדקס שלה, והאינדקס הזה הוא הידית היחידה שקוד קורא מקבל בחזרה: אין אובייקט נפרד, אין ממשק עם ספירת-הפניות, אין מה לשחרר. כל שאר היכולות במאמר הזה, ציור הסימון, מיפוי מחדש שלו אחרי שינוי זום, מחיקה שלו, בנויות מעל אותה רשומה קטנה אחת

כל מלבן מנורמל ונחתך (clip) לפני שהוא מתקבל. AddHighlightRegion מחליפה בין הקצוות השמאלי והימני אם סוקר גורר מימין לשמאל, מחליפה בין עליון ותחתון עבור גרירה כלפי מעלה, ואז חותכת את התוצאה מול ה-MediaBox של העמוד שמתקבל דרך GetLoadedPageBox. מלבן שמסתיים ברוחב אפס, גובה אפס, או כולו מחוץ לעמוד נדחה לחלוטין: הפונקציה מחזירה -1 ושום דבר לא נוסף לרשימה. ערך ההחזרה הזה אינו קישוטי: אצווה של הדגשות שנבנתה מחדש מקובץ סקירה חיצוני, או מקואורדינטות מיושנות אחרי שעמוד הוחלף, יכולה לאבד רשומות בשקט אם הקוד הקורא לא בודק זאת

איך הדגשה נשארת מיושרת אחרי זום או סיבוב?

הדגשה נשארת מיושרת משום ש-HotPDF שומרת אותה במרחב עמוד PDF ומקרינה אותה מחדש למרחב מסך בכל ציור-מחדש, במקום לשמור מלבן מסך שהיה מתיישן ברגע ששינוי רמת הזום מתרחש. THPDFViewerModel.PagePointToView וההופכית שלה, ViewPointToPage, מבצעות את ההקרנה הזו בשני שלבים: קודם ערך ה-/Rotate של העמוד עצמו, ואז ה-ViewRotation העצמאי של המציג, שאף פעם לא נכתב בחזרה ל-PDF ומשפיע רק על מה שהמציג מציג. ביטול הטרנספורמציה בשחרור העכבר מריץ את אותם שני שלבים בהיפוך, וזה מה שמאפשר להדגשה שצוירה בזום גבוה על עמוד שסובב ב-270 מעלות לנחות בדיוק במקום הנכון אחרי שהסוקר מאפס את התצוגה בחזרה ל-Fit Page

ה-DPI שמשמש להקרנה הזו חשוב לא פחות מהסיבוב. המציג של HotPDF לוכד את ה-DPI המדויק של הביטמאפ שכרגע על המסך ב-FRenderedDPI מיד אחרי כל עיבוד, ו-ImageMouseUp מעבירה את אותו ערך אל ViewPointToPage כך שקואורדינטת עכבר תמיד מומרת באמצעות הרזולוציה שבה היא בפועל צוירה, לא רזולוציה שמחושבת מחדש ממאפיין הזום הנוכחי. CreatePageSnapshot וקרובותיה מגבילות DPI לטווח 12 עד 2400, אבל נתיב העיבוד האינטראקטיבי לא נושא תקרה כזו: סולם הזום התקני מגיע עד 6400%, שמתורגם ליותר מ-2400 DPI בקו הבסיס המוגדר כברירת מחדל של 96 DPI, כך ששימוש חוזר במגבלת סגנון-snapshot למיפוי קואורדינטות היה מזיז כל הדגשה בכמה פיקסלים בקצה העליון של טווח הזום. שני ברירות מחדל קטנות יותר משלימות את האינטראקציה: גרירה קצרה משני פיקסלים בכל ציר נחשבת ללחיצה ולא מייצרת הדגשה, והדגשה לא יכולה להתחיל עד שלפחות עמוד אחד בפועל עבר עיבוד, משום ש-FRenderedDPI מתחיל באפס

חיווט הדגשה אינטראקטיבית למסך סקירה

הפעלת הדגשה אינטראקטיבית היא עבודה של שלושה מאפיינים על פקד THPDFViewer עצמו: הגדרת InteractionMode ל-vimHighlight במקום ברירת המחדל vimBrowse, בחירת HighlightColor, שברירת המחדל שלו היא clYellow, וטיפול ב-OnMarqueeSelect כדי לגלות מה הסוקר בדיוק צייר. כל השאר, לכידת העכבר, ציור מלבן הבחירה המנוקד בזמן שהסוקר גורר, המרת נקודת השחרור בחזרה למרחב עמוד, קריאה ל-AddHighlightRegion, קורה בתוך הפקד לפני שהאירוע הזה מופעל

type
  TReviewForm = class(TForm)
    Viewer: THPDFViewer;
    ReviewLog: TMemo;
    procedure FormCreate(Sender: TObject);
  private
    procedure ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
      PageIndex: Integer; const PageRect: THPDFRectangle;
      HighlightIndex: Integer);
  end;

// PdfDoc is a THotPDF already loaded elsewhere on the form
procedure TReviewForm.FormCreate(Sender: TObject);
begin
  Viewer.PDFDocument := PdfDoc;
  Viewer.InteractionMode := vimHighlight;
  Viewer.HighlightColor := clLime;
  Viewer.OnMarqueeSelect := ViewerMarqueeSelect;
end;

procedure TReviewForm.ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
  PageIndex: Integer; const PageRect: THPDFRectangle; HighlightIndex: Integer);
begin
  ReviewLog.Lines.Add(Format('page %d, mark #%d at (%.1f, %.1f)-(%.1f, %.1f)',
    [PageIndex + 1, HighlightIndex, PageRect.Left, PageRect.Bottom,
     PageRect.Right, PageRect.Top]));
end;

OnMarqueeSelect מופעל רק עבור גרירה שבאמת ייצרה הדגשה: לחיצה קטנה מדי כדי להיחשב גרירה מנקה את שכבת הבחירה מיד, וגרירה שנוחתת כולה מחוץ לעמוד מגיעה אל AddHighlightRegion אבל נדחית שם באותו אופן שקריאה פרוגרמטית הייתה נדחית, כך שהאירוע נשאר שקט בשני המקרים. פרט מימוש אחד ששווה לדעת אם הדגשה נראית כאילו מפסיקה להגיב בקצוות הפקד: לכידת העכבר שייכת ל-THPDFViewer עצמו, נצר של TScrollBox, לא ל-TImage הפנימי שמציג את ביטמאפ העמוד, וזה מה שמאפשר לסוקר לגרור מעבר לקצה העמוד המעובד ועדיין לקבל שחרור נקי

הוספה, הסרה, וקריאה מחדש של הדגשות מקוד

הדגשות לא חייבות להגיע מגרירת עכבר בכלל. Viewer.HighlightRegion(PageIndex, PageRect, Color), שמזרימה אל אותה Model.AddHighlightRegion שהגרירה האינטראקטיבית קוראת לה פנימית, היא ציבורית במיוחד כדי שמסך סקירה יוכל לבנות מחדש הדגשות מנתונים שכבר יש לו: הערות שנטענו ממסד נתונים, תוצאות מחיפוש טקסט, או סימונים ששוחזרו מסשן קודם. מכיוון שהקואורדינטות הן מספרים פשוטים במרחב-משתמש של PDF, שום דבר בנתיב הזה לא תלוי בכך שעמוד כבר עבר עיבוד קודם, בניגוד לגרירה האינטראקטיבית, שזקוקה ל-FRenderedDPI שכבר מחזיק ערך אמיתי

var
  I: Integer;
  Item: TPriorComment;    // your own record: PageIndex + PageRect
  NewIndex: Integer;
begin
  for I := 0 to PriorComments.Count - 1 do
  begin
    Item := TPriorComment(PriorComments[I]);
    NewIndex := Viewer.HighlightRegion(Item.PageIndex, Item.PageRect, clAqua);
    if NewIndex < 0 then
      LogWarning('comment %d fell outside the page and was dropped', [I]);
  end;
end;

הסרת הדגשה בודדת היא המקום שבו אחסון מבוסס-מערך מתגלה. RemoveHighlightRegion מוחקת רשומה אחת ומזיזה כל רשומה מאוחרת יותר מקום אחד למטה כדי לסגור את הפער, מה שאומר שכל אינדקס שנלכד קודם לכן, מאירוע OnMarqueeSelect או מספירה קודמת, כבר לא אמין ברגע שמשהו לפניו ברשימה מוסר. OnHighlightChange מופעל בכל הוספה, הסרה, וקריאת ClearHighlightRegions, אבל הוא לא נושא שום מידע על מה השתנה, כך שהתבנית הבטוחה היא להתייחס אליו כאיתות לבנות מחדש כל רשימה שפאנל סקירה מציג מתוך HighlightCount ו-TryGetHighlightRegion, במקום לתקן אינדקס שנשמר במטמון במקומו

procedure TReviewForm.ViewerHighlightChange(Sender: TObject);
var
  I: Integer;
  Mark: THPDFViewerHighlight;
begin
  MarkList.Items.Clear;
  for I := 0 to Viewer.Model.HighlightCount - 1 do
    if Viewer.Model.TryGetHighlightRegion(I, Mark) then
      MarkList.Items.AddObject(Format('page %d', [Mark.PageIndex + 1]),
        TObject(I));
end;

מתי סימון צריך להפוך להערת Highlight אמיתית במקום?

אזור הדגשה צריך להפוך להערה אמיתית ברגע שהוא זקוק לשרוד מחוץ למופע THPDFViewer הבודד ההוא. HotPDF חושפת גם AddHighlightAnnotation עבור עמוד חדש ו-AddLoadedHighlightAnnotation עבור מסמך שכבר טעון, ולמרות השם הכמעט-זהה, זהו מנגנון שונה לחלוטין: שניהם כותבים הערת סימון-טקסט אמיתית לפי ISO 32000-1 סעיף 12.5.6.10, /Subtype /Highlight של PDF, לתוך מערך ה-/Annots של העמוד, עם /QuadPoints שמסמן את ריצת התווים המדויקת, וכל מציג PDF תואם מציג אותה ברגע שהקובץ נשמר, לא רק המציג של HotPDF עצמו. אותו גבול מנגנון מחליט גם אם סימון עובר הלוך-ושוב דרך XFDF: הערה שנוצרה עם AddLoadedHighlightAnnotation היא אובייקט PDF רגיל שאותו ExportLoadedAnnotationsToXFDF אוסף ומוסר ל-Acrobat או לכלי סקירה אחר כסימון ISO 19444-1, המתואר בייבוא וייצוא הערות PDF כ-XFDF ב-Delphi, בעוד אזור שנוסף דרך AddHighlightRegion בלתי-נראה לייצוא ההוא משום שהוא אף פעם לא נכתב לגרף האובייקטים בכלל: הוא קיים רק כל עוד ה-THPDFViewerModel שיצר אותו קיים. משפחת סוגי ההערות המלאה, סימון וגיאומטרית, הזמינה על עמוד, ואיך מלבן ממקם כל אחת מהן, מתוארת בהמאמר על הערות PDF ב-Delphi עם HotPDF, והכלל המעשי פשוט: שמור סימון בר-השלכה כל עוד מסמך עדיין נדון, והפוך אותו להערה ברגע שהחלטה סופית

איפה שכבת ההדגשה נעצרת

שכבת ההדגשה, מצידה, לא מנסה להיראות כמו עט הדגשה שקוף: RefreshDocument מציירת כל אזור כמלבן מתאר בעובי שני פיקסלים בצבע שלו מעל ביטמאפ העמוד השמור במטמון, באותו אופן שבו היא מציירת פגיעות חיפוש, במקום למהול מילוי צבעוני מעל הטקסט שמתחת, כך שמראה שטיפת-צהוב קלאסי צריך להיצבע בקוד האפליקציה או להידחות להערה שקודמה עם זרם ההופעה (appearance stream) שלה. יכולת אחת ששווה לעשות בה שימוש חוזר ברגע שאזור קיים היא CreateCurrentPageRegionSnapshot, שלוקחת את אותו THPDFRectangle שהדגשה כבר נושאת ומעבדת בדיוק את האזור הזה לביטמאפ, שימושי לצירוף תמונת תצוגה מקדימה קטנה להערת סקירה בלי לייצא את העמוד המלא. בנייה לסקירה לא חייבת לבחור בין שני המנגנונים מראש: הגדר כברירת מחדל כל סימון חדש כאזור THPDFViewerHighlight בר-השלכה כל עוד שרשור הערות נשאר פתוח, וקרא ל-AddLoadedHighlightAnnotation רק ברגע שסוקר פותר אותו, מה שמשאיר את ה-PDF הטעון בלתי-נגוע במהלך ההלוך-ושוב שמייצר את מירב התנודה. פקד המציג המתואר כאן הוא חלק מרכיב HotPDF הסטנדרטי עבור Delphi ו-C++Builder, לצד שאר ה-API-ים של הערות וטפסים המוזכרים לעיל