אתם מקבלים תיקייה של קובצי PDF ממקור כלשהו, והמשימה נשמעת טריוויאלית: תגידו אילו סימניות קופצות ל-URL חיצוני, אילו מריצות JavaScript, ואיפה היעד של הפנימיות באמת נמצא. ואז פותחים את תיעוד ה-API ומגלים שהספרייה יודעת ליצור כל אחת מהפעולות האלה, אבל לא מספקת דרך לקרוא אותן חזרה. האסימטריה הזו נפוצה בכלי PDF. כתיבת סימניה שפותחת https://example.com היא שורת קוד אחת; שאלת סימניה קיימת "מה אתה עושה, ולאיזה יעד?" בדרך כלל מחייבת מעבר ידני על עץ האובייקטים הגולמי דרך /A, /S, /Dest ומערך וריאציות fit-type שכמעט אף אחד לא מצליח ליישם נכון בפעם הראשונה.
PDFlibPas היא ספריית PDF מקורית ב-Object Pascal ל-Delphi ול-C++Builder, ובמשך זמן רב היה לה אותו פער: סט עשיר של setters בצד הכתיבה, ו-getters שהחזירו לכם TPDFObject גולמי והשאירו אתכם לצלול פנימה. גרסת v3.77.0 סגרה חלק מזה עם קבוצה קטנה של קריאות introspection מוקלדות שמדווחות על סוג הפעולה, על המטען שלה ועל גאומטריית היעד כרשומות פשוטות. המאמר הזה מסביר איך הקריאות האלה ממפות למודל הפעולות והיעדים של ISO 32000-1, ומה שלושת המכשולים המעשיים שגורמים לגרסאות ידניות של הקוד הזה להשתבש בשקט.
למה קריאת פעולות קשה יותר מכתיבה שלהן
פעולה ב-PDF היא מילון עם מפתח /S שמציין את תת-הסוג שלה: GoTo, GoToR, URI, Launch, Named, JavaScript, ועוד שורה ארוכה שפוגשים לעיתים רחוקות (ISO 32000-1 §12.6.4). הבעיה היא שהמטען יושב במפתח אחר עבור כל תת-סוג, ואין חריץ אחיד של "תן לי את היעד". פעולת URI שומרת את הכתובת שלה ב-/URI. פעולת GoToR או Launch שומרת מפרט קובץ ב-/F. פעולת JavaScript שומרת את הסקריפט שלה ב-/JS, שיכול להיות מחרוזת או stream. פעולת GoTo לא נושאת מטען משלה בכלל; היעד שלה הוא destination, שתלוי ב-/D, ואותו צריך לפתור בנפרד.
כשכותבים פעולה, סוגה ידוע מראש, ולכן כל זה לא משנה. כשקוראים פעולה, צריך קודם להתפצל לפי /S, אחר כך להיכנס למפתח הנכון, ואז להתמודד עם העובדה שאותה תפיסה לוגית ("הדבר שהפעולה מצביעה עליו") מקודדת בשלוש דרכים לא תואמות. בדיוק את ההתפצלות הזו ה-getters המוקלדים סופגים. GetOutlineActionInfo ו-GetAnnotActionInfo מחזירים שניהם רשומת TPDFlibActionInfo:
type
TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
akLaunch, akNamed, akJavaScript);
TPDFlibActionInfo = record
Kind: TPDFlibActionKind;
URI: AnsiString; // populated for akURI
JavaScript: WideString; // populated for akJavaScript
FileName: AnsiString; // populated for akGoToR / akLaunch
OpenInNewWindow: Boolean; // akGoToR / akLaunch
end;
הרשומה אומרת אילו שדות רלוונטיים באמצעות Kind. אם Kind חוזר akURI, קראו את URI והתעלמו מהשאר. אם הוא חוזר akGoTo, אף אחד משדות המטען לא חל ואתם עוברים ליעד, שמטופל בקריאה נפרדת בהמשך. akNone הוא התשובה הכנה כשסימניה או הערה פשוט לא מכילות פעולה, במקום אפס שאמור לנחש את משמעותו.
מעבר על עץ ה-outline כדי למצוא סימניה
לפני שאפשר לבצע introspection לסימניה צריך את ה-handle שלה. PDFlibPas מזהה צמתי outline לפי מזהה שלם, ו-FindOutlineByTitle מאתרת אחד לפי הטקסט הגלוי שלו עם שליטה מפורשת על עומק החיפוש:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
ארגומנט Depth הוא החלק ששווה לעצור עליו. osdSiblingsOnly סורקת את שרשרת האחים ברמה של צומת ההתחלה ועוצרת; היא תמצא סימניה שכנה אבל לעולם לא תרד לילדים של שכן. osdChildrenOnly מביטה רמה אחת למטה, אל הילדים המיידיים של צומת ההתחלה. osdFullSubTree מבצעת רקורסיה בכל הענף. בחירה לא נכונה היא החמצה שקטה, לא שגיאה: חיפוש sibling-only לכותרת שחיה שתי רמות עמוק פשוט מחזיר אפס, ואתם מסיקים שהסימניה לא קיימת כשהיא הייתה שם כל הזמן. העבירו את GetFirstOutline כ-ID ההתחלתי כדי לחפש מן השורש של המסמך.
var
Lib: TPDFlib;
FoundID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') = 1 then
begin
// Search the whole tree from the root for a nested bookmark.
FoundID := Lib.FindOutlineByTitle('Appendix B',
Lib.GetFirstOutline, osdFullSubTree);
if FoundID <> 0 then
// FoundID is now a handle you can pass to the action and
// destination getters below.
;
end;
finally
Lib.Free;
end;
end;
ההתאמה מתבצעת מול מחרוזת הכותרת המדויקת, בהשוואה כ-WideString, ולכן היא תלויה ברישיות ומתחשבת בטקסט Unicode בדיוק כפי שהוא נשמר. אם קובצי המקור שלכם מגיעים מיצרנים לא עקביים, נרמלו את הכותרת שאתם מחפשים באותה צורה שבה המסמך שמר אותה, אחרת תרדפו אחרי החמצות מדומות.
פענוח הפעולה והיעד של סימניה
עם handle ביד, GetOutlineActionInfo נותנת לכם את התצוגה המוקלדת. הדפוס הוא: קוראים לה, מבצעים switch על Kind, וקוראים את השדה שהסוג ממלא.
var
Info: TPDFlibActionInfo;
begin
Info := Lib.GetOutlineActionInfo(FoundID);
case Info.Kind of
akURI:
Writeln('Opens URL: ', Info.URI);
akGoToR, akLaunch:
Writeln('Opens file: ', Info.FileName,
' (new window: ', Info.OpenInNewWindow, ')');
akJavaScript:
Writeln('Runs script: ', string(Info.JavaScript));
akGoTo:
Writeln('Jumps within this document'); // see destination below
akNamed:
Writeln('Named action (NextPage, Print, etc.)');
akNone:
Writeln('Bookmark has no action');
end;
end;
זה המקום שבו מסתתר המלכוד האמיתי הראשון, וזה גם המלכוד שפידבק הבדיקות חשף בזמן המימוש. יש getter ותיק יותר, GetActionURL, והפנייה אליו כדי לקרוא פעולת URI היא הטעות הברורה-לכאורה. GetActionURL פותר מפרט קובץ דרך המפתח /F. זה הדבר הנכון ל-GoToR ול-Launch, שהיעדים שלהם באמת הם קבצים, אבל זה המפתח הלא נכון לגמרי עבור פעולת URI. הכתובת של פעולת URI היא מחרוזת רגילה במפתח /URI של הפעולה עצמה, לא file spec. תנו לפעולת URI לעבור במסלול file-spec ותשיגו תוצאה ריקה או חסרת היגיון. ה-getter המוקלד מטפל בזה פנימית על ידי קריאה ישירה של /URI עבור akURI ורק אחר כך שימוש ב-resolver של מפרט הקובץ עבור akGoToR ו-akLaunch, וזה בדיוק ההבחנה שגרסה ידנית נוטה לטשטש.
סוגי fit של יעד והגיאומטריה שמאחוריהם
פעולת akGoTo פירושה "נווט בתוך המסמך הזה", אבל היא לא אומרת לכם כלום על איפה או איך. זה תפקידו של היעד, והיעדים נושאים יותר ניואנסים ממה שרוב האנשים מצפים. יעד PDF הוא לא רק מספר עמוד; הוא עמוד יחד עם מפרט "fit" שמסביר איך הצופה צריך למסגר את העמוד הזה (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo מחזירה אותו כרשומה:
type
TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);
TPDFlibDestinationInfo = record
Kind: TPDFlibDestinationKind;
Page: Integer; // 1-based; 0 when unresolved
Left, Top, Right, Bottom, Zoom: Double;
end;
שמונה סוגי ה-fit עונים על שאלות מסגור שונות. dkXYZ מציב נקודה ספציפית בפינה השמאלית העליונה בזום מפורש, ולכן הוא משתמש ב-Left, Top ו-Zoom. dkFit מתאים את כל העמוד לחלון ומתעלם מקואורדינטות. dkFitH ו-dkFitV מתאימים את רוחב או גובה העמוד עם קואורדינטה רלוונטית אחת בלבד, קצה עליון או קצה שמאלי. dkFitR הוא המעניין: הוא מתאים מלבן מוגדר, ולכן כל ארבעת הקצוות חשובים. משפחת dkFitB* עושה את אותן פעולות ביחס ל-boundary box של התוכן הנראה במקום לעמוד כולו. לדעת אילו שדות חיים עבור כל סוג הוא ההבדל בין קריאת יעד נכון לבין הדפסת קואורדינטות זבל במקרה של אפס.

מאחורי הקלעים, המימוש נשען על יישור מכוון שכדאי להכיר, כי הוא מסביר למה המיפוי אמין. GetDestType הפנימי מחזיר מספר שלם 1..8 עבור שמונת סוגי ה-fit בדיוק בסדר XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind הוגדרה כך שה-ordinals שלה מיושרים אחד-לאחד: dkXYZ הוא ordinal 1, dkFitBV הוא ordinal 8, ו-dkNone יושב ב-0. לכן ההמרה היא cast ישיר של ordinal עם guard לטווח, לא טבלת חיפוש שיכולה להיסחף מסנכרון כשה-enum גדל. זה פרט קטן, אבל זה בדיוק מסוג הדברים שבגישה נאיבית הופכים לבאג off-by-one בפעם הראשונה שמישהו מסדר מחדש enum.
var
Dest: TPDFlibDestinationInfo;
begin
Dest := Lib.GetOutlineDestinationInfo(FoundID);
if Dest.Page = 0 then
Exit; // destination did not resolve
case Dest.Kind of
dkXYZ:
Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
dkFitR:
Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
dkFit, dkFitB:
Writeln(Format('Page %d, fit whole page', [Dest.Page]));
else
Writeln(Format('Page %d, fit kind %d',
[Dest.Page, Ord(Dest.Kind)]));
end;
end;
עמוד שערכו אפס הוא האות לכך שהיעד לא נפתר, בדרך כלל כי הפעולה לא נושאת יעד או שה-named destination לא נמצא. בדקו זאת לפני שאתם סומכים על כל קואורדינטה. שימו לב גם ש-GetOutlineDestinationInfo בודקת בשני המקומות שבהם יעד יכול לחיות: ישירות על /Dest של הסימניה, וגם בתוך /D של פעולת GoTo משובצת. אין צורך לדעת איזה צורה היצרן בחר.
פעולות של הערות והמלכודת של SelectPage
הערות קישור נושאות פעולות בדיוק כמו סימניות, ו-GetAnnotActionInfo מחזירה את אותה רשומת TPDFlibActionInfo עם אותו דפוס של סוג ואז מטען. אבל כאן יש מלכוד תלוי-מצב שלא קיים ב-outline, וזה המלכוד השלישי.
הערות שייכות לעמודים, ו-PDFlibPas חושפת את ההערות של העמוד הנוכחי דרך מצב שנעשה תקף רק אחרי שבוחרים את העמוד. קראו ל-GetAnnotActionInfo בלי לקרוא קודם ל-SelectPage(N) וה-handle של ההערה יהיה אפס; הקריאה תחזיר akNone ואתם תסיקו בטעות שלעמוד אין הערות עם פעולה. התיקון הוא שורת קוד אחת, אבל קל לשכוח אותו כשעוברים בלולאה על עמודים:
var
P: Integer;
Info: TPDFlibActionInfo;
begin
for P := 1 to Lib.PageCount do
begin
Lib.SelectPage(P); // mandatory before touching annotations
// GetAnnotActionID(1) <> 0 is the reliable "has an action"
// test. CheckPageAnnots returns a boolean-style flag, not a
// count, so it is the weaker signal here.
if Lib.GetAnnotActionID(1) <> 0 then
begin
Info := Lib.GetAnnotActionInfo(1);
if Info.Kind = akURI then
Writeln(Format('Page %d link -> %s', [P, Info.URI]));
end;
end;
end;
שני דברים בלולאה הזו מכוונים. ראשית, SelectPage(P) מגיע לפני כל גישה להערות בכל איטרציה; מצב ההערות הפר-עמודי לא נשמר בין עמודים. שנית, בדיקת הקיום משתמשת ב-GetAnnotActionID(1) <> 0 ולא ב-CheckPageAnnots. האחרונה מדווחת על נוכחות כדגל בסגנון בוליאני ולא כמונה, ולכן מזהה פעולה לא-אפס הוא הדרך המדויקת יותר לשאול "האם יש כאן הערה ראשונה, והאם יש לה פעולה שאפשר לקרוא?" עוד ניואנס אחד שכדאי לסמן: עבור הערות, script של פעולת JavaScript נקרא ישירות מ-/JS, תוך פענוח stream כשהוא נשמר כך וקריאה כמחרוזת אחרת, ולכן הוא שורד את שתי שיטות הקידוד הנפוצות.
איפה נכנסת אינטרוספקציית צד-הקריאה
ה-getters האלה מכוונים במפורש לצרכים צרים. הם קריאות טהורות שנבנות על שכבות הפעולה והיעד הקיימות של הספרייה עם מזהים שלמים, כך שהן לא נוגעות במסלול הכתיבה ולא מוסיפות סיכון למסמכים שאתם גם עורכים. הן מדווחות מה יש בקובץ; הן לא מאמתות אותו מול מדיניות ולא כותבות דבר מחדש. אם המטרה שלכם היא ההפך, לבנות סימניות והערות קישור שנושאות את הפעולות האלה מלכתחילה, זה נמצא בצד הכתיבה, והחלק המשלים על פעולות טפסים אינטראקטיביים ו-JavaScript ב-Delphi עובר על יצירתן. לחילוץ התוכן הגלוי והמבני מקובץ PDF במקום גרף הניווט שלו, ראו חילוץ טקסט, תמונות וגופנים עם PDFlibPas.
הגבול הכנה שכדאי לזכור: אינטרוספקציה רואה רק מה שהיצרן באמת כתב. סימניה שהפעולה שלה נוצרה פגומה, או יעד שמצביע ל-named target שמעולם לא הוגדר, יוצגו כ-akNone או כ-Page אפס במקום כחריגה. זו התנהגות נכונה עבור API קריאה שבודק קבצים לא מהימנים, אבל היא אומרת שהקוד שלכם צריך להתייחס לתוצאות האפס האלה כ-"חסר או לא נפתר", ולא כהבטחה לקלט תקין. אינטרוספקציית הפעולה והיעד המוקלדת שמוצגת כאן היא חלק מ-PDFlibPas, ספריית ה-PDF המקורית ל-Delphi ול-C++Builder.