מאמר טכני

פעולות מחזור-חיים של PDF: קטלוג /AA מול עמוד /AA ב-Delphi

PDFlibPas, ספריית ה-PDF הילידית של Delphi ו-C++Builder, נותנת למסמך PDF שני מקומות נפרדים לתלות בהם התנהגות אוטומטית: פעולות מחזור-חיים ברמת-מסמך כמו WillClose, ‏WillSave, ‏DidSave, ‏WillPrint ו-DidPrint, שמורות במילון ה-/AA של הקטלוג, ופעולות מחזור-חיים ברמת-עמוד — Open ו-Close — שמורות במילון ה-/AA של אובייקט העמוד עצמו במקום זאת. בלבול בין שתי המכולות הוא הדרך הנפוצה ביותר שבה פעולת מחזור-חיים בשקט לא עושה כלום

המקרים המניעים רגילים. צוות כספים רוצה תבנית דוח שמטביעה חותמת-זמן הדפסה ורושמת מי הדפיס אותה ברגע שההדפסה בפועל מתחילה, לא כשהקובץ סתם נפתח. זרימת עבודה כבדת-טפסים זקוקה לערכי שדה שנדחפים לשרת אוטומטית לפני שלקוח ה-PDF של הקורא מורשה לסגור את החלון, כך שלשונית סגורה לעולם לא תאמר עריכה אבודה. דוח רב-עמודי רוצה כרזה ספציפית-לעמוד שמופיעה רק בעוד העמוד ההוא על המסך. PDF בפועל מציע שכבה שלישית מתחת למסמך ועמוד עבור סוג ההתנהגות הזה — פעולות שמצומדות לרשומת /A של שדה טופס בודד או קישור, נושא מאמר נלווה על פעולות טופס אינטראקטיביות ו-JavaScript — אבל המאמר הזה נשאר בשתי השכבות שמעליה: המסמך כולו, ועמוד יחיד

אילו טריגרים חיים על ה-/AA של הקטלוג?

חמישה טריגרים חיים במילון ה-/AA של הקטלוג, וכל אחד מהם מופעל עבור אירוע שמשפיע על המסמך כולו, לא עמוד בודד. ‏ISO 32000-1 סעיף 12.6.3 (‏Trigger Events) מפרט את המפתחות ברמת-המסמך כ-WC, ‏WS, ‏DS, ‏WP ו-DP — השמות המילוליים בני-שני-האותיות שנכתבים לתוך מילון ה-/AA — עבור WillClose, ‏WillSave, ‏DidSave, ‏WillPrint ו-DidPrint בהתאמה, ו-PDFlibPas משקפת את הקבוצה ההיא בדיוק בטיפוס-המנייה TPDFlibDocumentActionTrigger: ‏datWillClose, ‏datWillSave, ‏datDidSave, ‏datWillPrint, ‏datDidPrint. ‏SetDocumentAction היא נקודת הכניסה היחידה שמצמידה כל אחת מהחמש, והפרמטר ActionKind שהיא מקבלת הוא אחד מעשרה קבועי PDF_ACTION_BUILDER_* משותפים בכל קריאת בונה-פעולה בספרייה, מ-URI פשוט לסקריפט לקפיצת יעד. מה ש-GoTo, קובץ-מרוחק, קובץ-משובץ, או פעולת Launch בפועל עושים ברגע שהם מופעלים היא שאלה שונה מהיכן הם מוצמדים, וזה נושא מאמר נלווה על פעולות GoTo, מרוחק, משובץ, ו-launch — זה נשאר עם שאלת-המכולה, קטלוג או עמוד, ולא שאלת-סוג-הפעולה

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.AddStandardFont(4);
    Lib.DrawText(40, 700, 'Quarterly statement');
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save', '', 0, 0);
    Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_SUBMIT,
      'https://example.com/forms/submit', 'CustomerName;OrderTotal', 0, 0);
    Lib.SaveToFile('statement.pdf');
  finally
    Lib.Free;
  end;
end;

איך טריגר ברמת-עמוד שונה מאחד ברמת-מסמך?

טריגר ברמת-עמוד מופעל רק עבור אובייקט העמוד הבודד שהוא מוצמד אליו, ו-PDFlibPas שומרת אותו במילון ה-/AA של העמוד ההוא עצמו ולא של הקטלוג. יש רק שני טריגרי-עמוד, Open ו-Close, המתאימים למפתחות O ו-C ש-ISO 32000-1 מגדיר עבור מילון הפעולות-הנוספות של עמוד, ו-PDFlibPas חושפת אותם כ-patOpen ו-patClose דרך SetPageAction, שמצמידה לאיזה עמוד שכרגע נבחר דרך SelectPage — פרט שחשוב בפעם הראשונה שאתה לולאת על פני מסמך ומצפה שקריאה אחת תחול בכל מקום, משום שהיא אף פעם לא. הצמדת אחד משני סוגי הטריגר גם מעלה את גרסת ה-PDF המינימלית של הקובץ, ושתי המכולות מבקשות רצפות שונות: PDFlibPas מעלה את המסמך לפחות ל-PDF 1.4 בפעם הראשונה שהיא כותבת רשומת /AA של קטלוג, ולפחות ל-PDF 1.5 בפעם הראשונה שהיא כותבת רשומת /AA של עמוד, ללא קשר לאיזה סוג-פעולה יושב בפנים. זו דרישה ברמת-מכולה ששוכבת מעל כל מה שהפעולה עצמה זקוקה לו בזכות עצמה, כך שפעולת URI ערומה שהייתה זקוקה רק ל-PDF 1.1 בעמידה עצמאית עדיין מושכת את הקובץ כולו ל-PDF 1.5 ברגע שהוא עטוף בטריגר פתיחת-עמוד

Lib.SelectPage(3);
Lib.SetPageAction(patOpen, PDF_ACTION_BUILDER_JAVASCRIPT,
  'app.alert("Section 3: internal review only");', '', 0, 0);
Lib.SetPageAction(patClose, PDF_ACTION_BUILDER_WEB,
  'https://example.com/analytics/page-3-closed', '', 0, 0);

קריאה והסרה של פעולות מחזור-חיים

GetDocumentActionInfo ו-GetPageActionInfo שתיהן מחזירות רשומת TPDFlibActionInfo, ושדה ה-Kind חוזר akNone בכל פעם שלטריגר ההוא אין כלום מוצמד, כך שבדוק את Kind לפני שאתה בוטח בשום שדה אחר ברשומה — URI, ‏JavaScript, ‏FileName והשאר משמעותיים רק עבור סוג-הפעולה הבודד ש-Kind בפועל מדווח, שכן אותה צורת רשומה משמשת מחדש על פני כל סוג-פעולה שהבונה יכול לייצר. ‏RemoveDocumentAction ו-RemovePageAction כל אחת מנקה טריגר בודד ומדווחת 1 כשהיא מצאה משהו להסיר, 0 כשהטריגר כבר היה ריק; כאשר הרשומה שהוסרה הייתה האחרונה שנשארה במילון ה-/AA, PDFlibPas מוחקת את ה-/AA הריק-כעת עצמו במקום להשאיר מכולה תלויה, חסרת-משמעות מאחור בקטלוג או בעמוד

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetDocumentActionInfo(datWillSave);
  if Info.Kind = akURI then
    WriteLn('WillSave calls out to: ', string(Info.URI));

  if Lib.RemoveDocumentAction(datWillSave) = 1 then
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save-v2', '', 0, 0);
end;

האם PDF/A מאפשר פעולות מחזור-חיים בכלל?

לא. תאימות PDF/A דוחה את כל מכולת הפעולות-הנוספות, לא רק את סוגי-הפעולה שנשמעים מסוכנים, משום ש-ISO 19005 מגבילה את מודל הפעולה-האינטראקטיבית של PDF על ההנחה שקובץ ארכיוני חייב להיות מעובד באותו אופן עשרות שנים מהיום, בלי להיות תלוי במנוע סקריפט או חיבור רשת שאולי לא יהיה קיים עד אז. ‏SetLifecycleAction, הבונה המשותף מאחורי גם SetDocumentAction וגם SetPageAction, בודקת את PDFAMode עוד לפני שהיא אי-פעם מסתכלת על ActionKind, כך שפעולת URI שסתם פותחת דף אינטרנט חברתי או פעולת Named ששוקלת רק ללכת לעמוד הבא נלכדת באותה רשת כמו אחת מסוכנת — שום דבר שסוקר אבטחה בדרך כלל היה מסמן, נחסם בכל זאת, משום שההגבלה מבנית ולא לפי-מקרה. הסכנה המעשית היא שהדחייה שקטה: ‏SetDocumentAction ו-SetPageAction שתיהן מחזירות 0 בלי להעלות חריגה, כך שאתר-קריאה שאף פעם לא בודק את ערך ההחזרה משלח מסמך שבשקט חסר את הטריגר שהיה אמור לשאת

Lib.SetPDFAMode(2); // PDF/A-1b
if Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_NAMED,
     '', '', 0, 0) = 0 then
  // rejected: PDF/A-1b forbids Catalog /AA, even a plain Named action
  WriteLn('lifecycle action not attached');

אי-סימטריה אחת שווה לזכור. ‏RemoveDocumentAction ו-RemovePageAction אף פעם לא בודקות PDFAMode, כך שטעינת קובץ שכבר נושא פעולות מחזור-חיים בלתי-תואמות והסרתן בדרך לשמירה תואמת-PDF/A עובדת בדיוק כמצופה — רק נתיב-הכתיבה, הצמדת טריגר חדש, מוגבל לפי מצב-התאימות

איפה הדפסה-בפתיחה משתלבת בלי טריגר WillOpen?

למילון ה-/AA של הקטלוג אין רשומת WillOpen בכלל, בעיצוב — /AA ברמת-מסמך ב-ISO 32000-1 מגדיר בדיוק חמישה מפתחות, ‏WillClose, ‏WillSave, ‏DidSave, ‏WillPrint ו-DidPrint, ושום דבר ברשימה ההיא לא מופעל אך ורק משום שקובץ נפתח. ה-hook של זמן-פתיחה חי ברשומת קטלוג נפרדת, ‏/OpenAction, ש-PDFlibPas חושפת דרך משפחת קריאות משלה, ‏SetOpenActionJavaScript, ‏SetOpenActionDestination ו-SetOpenActionNamedDestination ביניהן, אף אחת מהן לא נוגעת במילון ה-/AA או בטיפוס-המנייה TPDFlibDocumentActionTrigger בכלל. שני המנגנונים כן מרכיבים יחד, עם זאת, וזה בדרך כלל מה שתבנית הדפסה-בפתיחה בפועל זקוקה לו: בנה את התבנית כך שה-/OpenAction שלה מתחיל את עבודת ההדפסה, בדרך כלל פעולת JavaScript שקוראת לפקודת ההדפסה של המציג עצמו, וההדפסה עצמה היא מה שנותן ל-WillPrint ו-DidPrint משהו לרוץ מולו — חותמת-זמן שמוטבעת לפני שהעמודים נשלחים ל-spool, רשומת ביקורת שנכתבת ברגע שהם מסתיימים

עד כמה הטריגרים האלה אמינים על פני מציגי PDF?

לא כל מציג מריץ אותם, אפילו מחוץ ל-PDF/A, כך שהתייחס לפעולת מחזור-חיים כבקשה ולא ערבות. ‏Acrobat ורוב הקוראים המלאים לשולחן-עבודה מבצעים את הקבוצה כולה בנאמנות, אבל חלק גדול מצריכת PDF אמיתית בעולם אף פעם לא נוגעת במילון פעולות-נוספות בכלל: מציגים משובצי-דפדפן, רוב הקוראים הניידים, וכמעט כל צינור עיבוד או חילוץ-טקסט בצד-שרת או מתעלמים מ-/AA לחלוטין או מכבדים רק פרוסה צרה ממנו, כאשר WillPrint ו-DidPrint בדרך כלל מסתדרים הכי גרוע שכן להמרה חסרת-ראש אין פעולת-הדפסה שהם יכולים להתחבר אליה. אם פעולת שליחת-טופס ב-WillClose היא הנתיב היחיד שלוכד נתוני טופס, זה לא נתיב אמין — צמד אותה עם כפתור-שליחה מפורש, והתייחס לטריגר האוטומטי כנוחות עבור הקוראים שבמקרה תומכים בו

טריגרי מסמך, עמוד, ושדה הם שלוש שכבות של אותו מנגנון מילון-פעולה בסיסי, וברגע שהמכולה ברורה, השאר הוא בחירת קבוע ה-ActionKind הנכון ובדיקת קוד ההחזרה. הטריגרים האלה של מחזור-חיים, לצד ה-API הרחב יותר של בונה-הפעולה שהמאמר הזה נוגע בו, נשלחים כחלק מספריית ה-PDF Delphi PDFlibPas הסטנדרטית, עם מסמך העזר המלא לטריגר וסוג-פעולה בתיעוד המוצר