התוכנה HotPDF מוסיפה סימניות לקובץ PDF קיים ב-Delphi באמצעות AddLoadedOutline, היוצרת רשומות סימנייה בכל רמת קינון במסמך טעון, והיא קוראת וכותבת יעדים בעלי שם (named destinations) באמצעות ResolveLoadedNamedDestination ו-AddLoadedNamedDestination. טענו את הקובץ, בנו את העץ, קראו ל-SaveLoadedDocument, וסרגל הצד של הקורא שהיה ריק בעבר יציג כעת תוכן עניינים פעיל
התרחיש המניע את כל זה הוא נפוץ להפליא. אתם ממזגים תריסר חוזים לחבילת בדיקה אחת, או תופרים שלושה מדריכי מוצר לקובץ הפצה יחיד, והפלט תקין מבחינה מבנית: כל דף קיים, כל גופן שלם. ואז מישהו פותח אותו ב-Acrobat ופאנל הסימניות ריק. מסמך בן 400 עמודים ללא ניווט הוא שלם מבחינה טכנית אך אינו שמיש באופן מעשי, ועד לגרסה 2.347.0 HotPDF יכלה לקרוא, לשנות ולמחוק סימניות במסמכים טעונים אך לעולם לא ליצור אותן. פער זה נסגר כעת, ומאמר זה עובר על ממשק ה-API החדש ועל פיסת מידע אחת הקשורה למבנה ה-PDF, מפתח ה-/Count, שתכשיל אתכם אם תתייחסו אליו כאל מונה פשוט של רשומות
מהו עץ הסימניות בפועל
סימניית PDF היא עץ מקושר כפול של מילונים, ולא רשימה שטוחה, ומבנה זה מסביר כל פרמטר ב-API. תקן ISO 32000-1 §12.3.3 מגדיר זאת: קטלוג המסמך מצביע על שורש /Outlines, השורש מצביע על הילדים הראשון והאחרון שלו באמצעות /First ו-/Last, והאחים מקושרים בשרשרת דרך /Next ו-/Prev בעוד שכל רשומה מצביעה חזרה למעלה דרך /Parent. כל רשומה נושאת מחרוזת /Title ובדרך כלל /Dest המציין לאן לחיצה עליה צריכה לקחת את הקורא
יעדים מגיעים בשתי צורות, ותקן ISO 32000-1 §12.3.2 מפריד ביניהם במכוון. יעד מפורש (explicit destination) הוא מערך פנימי כמו [page /XYZ x y zoom]: הפנייה ישירה לאובייקט דף בתוספת מפרט תצוגה. יעד בעל שם (named destination) שומר במקום זאת מחרוזת שם, והיעד האמיתי חי בקטלוג המסמך תחת עץ השמות /Names /Dests, רמה אחת של עקיפין המאפשרת לקישורים רבים לשתף יעד אחד ומאפשרת ליעד לזוז מבלי לשנות את הקישורים. HotPDF כותבת יעדי /XYZ מפורשים כאשר היא יוצרת סימניות, ומספקת לכם קריאות נפרדות לקריאה ולהרחבה של עץ השמות
כיצד מוסיפים סימניות לקובץ PDF קיים ב-Delphi?
קראו ל-AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) לאחר LoadFromFile: העבירו ParentIndex = -1 עבור רשומה ברמה העליונה, או את האינדקס מבוסס האפס של רשומה קיימת ברמה העליונה כדי לקנן תחתיה. הפונקציה יוצרת את שורש ה-/Outlines אם למסמך לא היה כזה מעולם, בונה את מילון הרשומה, כותבת מערך /Dest [page /XYZ x y zoom] המצביע על ה-DestPageIndex מבוסס האפס, ומקשרת את הרשומה החדשה לשרשרת האחים. היא מחזירה 0 בהצלחה ו-1- במקרה של כישלון, למשל כאשר DestPageIndex מחוץ לטווח
פרט קישור אחד חשוב לצורך הסדר: רשומה חדשה מוכנסת בראש שרשרת הילדים של ההורה שלה, כ-/First החדש. לכן, אם תוסיפו את "פרק 1" ואז את "פרק 2" כרשומות ברמה העליונה, פרק 2 יופיע מעל פרק 1 בסרגל הצד, ופרק 1 יוסט לאינדקס 1 ברמה העליונה. התבנית המעשית היא להוסיף רשומות בסדר קריאה הפוך, או להוסיף כל הורה ולמלא מיד את ילדיו בעודו נמצא באינדקס 0
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
begin
// Add chapters in reverse reading order: each new
// top-level entry becomes /First (index 0).
Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
// Chapter 1 is now top-level index 0; nest sections
// under it (children also prepend, so reverse order).
Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
end;
finally
Pdf.Free;
end;
end;
הפרמטרים X, Y ו-Zoom ממופים ישירות ליעד /XYZ וכולם מוגדרים כברירת מחדל ל-0. מבנה ה-PDF מציב את ראשית הצירים בפינה השמאלית-תחתונה, כך ש-Y = 792 מכוון לראש דף מסוג US Letter, ולפי תקן ISO 32000-1 ערך אפס בפרמטר /XYZ מורה למציג לשמור על הגדרתו הנוכחית, וזה בדיוק מה שאתם רוצים עבור זום כמעט בכל מקרה. הכל מתרחש על גרף האובייקטים הטעון בזיכרון; שום דבר אינו נוגע בדיסק עד להרצת SaveLoadedDocument, אותו מודל של עריכה ולאחר מכן שמירה שבו משתמש ממשק ה-API של מסמכים טעונים עבור שכתוב כותרת, מחבר ומטא-נתונים של XMP בקובצי PDF טעונים
שינוי יעד של סימניות: מעברי דפים לעומת פעולות URI
ניתן לשנות את הקישור של סימניות קיימות מבלי ליצור אותן מחדש, ו-HotPDF נותנת לשני המקרים שתי קריאות נפרדות מכיוון שמבני ה-PDF שמתחתיהם שונים לחלוטין. הפונקציה SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) מחליפה את ה-/Dest של הרשומה ברמה העליונה ב-Index במערך /XYZ חדש, הכלי לתיקון כשל המיזוג הקלאסי שבו כל סימנית עדיין מצביעה על מספרי הדפים של קובץ המקור המקורי שלפני המיזוג. הפונקציה SetLoadedOutlineURI(Index, URI) עושה משהו שונה מבחינה מבנית: היא מצרפת מילון פעולה /A עם /S /URI, והופכת את הסימנית לקישור אינטרנט במקום למעבר בתוך המסמך
// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);
// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');
Pdf.SaveLoadedDocument('retargeted.pdf');
הקפידו להבדיל בין שני המנגנונים הללו בעת עיצוב העץ. /Dest הוא ניווט טהור בתוך המסמך; פעולת URI עוזבת את המסמך לחלוטין, ותקן ISO 32000-1 מצפה מרשומת סימנייה לניווט דרך מנגנון אחד או אחר, ולא שניהם. לכן שמרו את SetLoadedOutlineURI עבור רשומות שאינן נושאות כבר יעד דף, כגון "דף הבית של המוצר" או "דווח על בעיה" בסוף העץ ולא כותרת פרק שהומרה. פעולת ה-URI שנכתבת כאן היא אותו מבנה /S /URI המשמש להערות קישור (link annotations) על תוכן הדף, המכוסה בהרחבה במדריך ליצירת היפר-קישורים במסמכי PDF עם HotPDF
כיצד עובדים יעדים בעלי שם ב-PDF טעון?
יעדים בעלי שם הם מנגנון העוגן היציב של תקן ISO 32000-1 §12.3.2.3: במקום להטמיע הפניה לדף בכל קישור, המסמך שומר מפת שם-ליעד בעץ השמות /Names /Dests של הקטלוג, וקישורים מתייחסים לרשומות לפי שם. הפונקציה ResolveLoadedNamedDestination(Name) מחפשת שם בעץ זה ומחזירה את אינדקס הדף מבוסס האפס אליו הוא מצביע, או 1- אם השם אינו קיים. המפענח מטפל בשתי צורות האחסון שהמפרט מאפשר עבור הערך הממופה: מערך יעד חשוף כמו [page /XYZ x y zoom] וצורת המילון העוטפת את המערך תחת מפתח /D
הפונקציה AddLoadedNamedDestination(Name, DestPageIndex, X, Y) פועלת בכיוון ההפוך: היא רושמת עוגן חדש בעץ /Names /Dests, יוצרת את העץ עצמו כאשר למסמך אין כזה, ומחזירה True בהצלחה. יחד, הצמד מכסה את תהליך העבודה שבו מסמך ממוזג חייב לכבד עוגנים שכלים קודמים כתבו, ולפרסם חדשים עבור קישורים עתידיים לטרגוט
var
PageIdx: Integer;
begin
// Honor an anchor another tool wrote into /Names /Dests.
PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
if PageIdx >= 0 then
Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);
// Publish a new anchor for other links to target.
if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
Pdf.SaveLoadedDocument('anchored.pdf');
end;
הגבולות הכנים: היעדים ש-HotPDF כותבת, לסימניות ועוגנים בעלי שם כאחד, הם יעדי /XYZ. הסוגים המפורשים האחרים שהמפרט מגדיר, /Fit, /FitH, /FitB ואחרים, אינם מופקים על ידי קריאות אלו, למרות ש-/XYZ עם זום אפס מכסה את מקרה השימוש העיקרי של "עבור לנקודה זו, שמור על הזום שלי". והפונקציה GetLoadedBookmarkPageIndex(Title), חיפוש הנוחות המקבל כותרת סימנייה ומחזיר את דף היעד שלה, סורק את הרמה העליונה של עץ הסימניות, לכן השתמשו בו כדי לאמת את הרשומות ברמת הפרק שיצרתם ולא על פני תתי-רשומות עמוקות
מלכודת ה-/Count: הוא סופר צאצאים, לא רשומות
מפתח ה-/Count בשורש הסימניות אינו מייצג את מה שרוב המפתחים מניחים, והנחה שגויה מייצרת כשלי בדיקה שנראים כמו שחיתות נתונים. תקן ISO 32000-1 §12.3.3 מגדיר את /Count כמספר הכולל של צאצאים גלויים בכל הרמות, ולא מספר הרשומות ברמה העליונה. ברשומה בודדת גם הסימן נושא משמעות: ערך חיובי פירושו שהרשומה פתוחה וצאצאים רבים מוצגים בסרגל הצד, בעוד שערך שלילי פירושו שהרשומה מכווצת וצאצאים גלויים מוסתרים בתוכה
זה עלה מתוך מערך הבדיקות של HotPDF עצמה בזמן בניית ממשקי ה-API של סימניות מסמכים טעונים. מסמך בדיקה הכיל שלוש סימניות ברמה העליונה, אחת עם ילד; לאחר מחיקת רשומה אחת ברמה העליונה, GetLoadedOutlineCount, הקורא את שורש ה-/Count, דיווח על 1 במקום על 2 הצפוי. שום דבר לא הושחת: ה-3 הראשוני מעולם לא היה "שלוש רשומות ברמה העליונה" מלכתחילה, והחישוב הנכון מחדש צריך לעבור על השרשרת ברמה העליונה ולצבור כל צומת יחד עם ה-/Count החיובי שלו, תוך דילוג על צאצאים של צמתים מכווצים. הלקח לקוד שלכם הוא ישיר: לעולם אל תניחו הנחות על שינויי /Count לאחר עריכות מבניות, מכיוון שהערך משתנה באופן לא ליניארי עם צאצאים גלויים. אמתו את המבנה לפי תוכן במקום זאת
// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
ShowMessage('Outline verified');
היכן זה משתלב בארגז הכלים של מסמכים טעונים
יצירת סימניות ויעדים בעלי שם משלימה תמונה שממשק ה-API של מסמכים טעונים ממלא כבר זמן מה: אותו מחזור של טעינה-עריכה-שמירה כבר משכתב מטא-נתונים של מסמכים, מוסיף הערות קישור, ומעביר הערות הלוך ושוב דרך יבוא ויצוא XFDF. הדפוס אחיד, LoadFromFile, רצף של קריאות *Loaded* מול גרף האובייקטים בזיכרון, ואז SaveLoadedDocument אחד, מה שמאפשר לשלב בקלות בניית סימניות בצינור מיזוג או החתמה קיים כשלב אחד נוסף לפני השמירה
ממשקי ה-API שנדונו כאן, AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination ו-GetLoadedBookmarkPageIndex, מגיעים בגרסה הסטנדרטית של רכיב HotPDF עבור Delphi ו-C++Builder, ללא תלות חיצונית וללא צורך בזמן ריצה נפרד של מציג