Technical Article

טפסים אינטראקטיביים ב-PDF ב-Delphi: פעולות ו-JavaScript

שדה טופס PDF כשלעצמו הוא רק תיבה שמכילה ערך. מה שגורם לטופס להתנהג כמו אפליקציה קטנה הוא הפעולה המצורפת אליו: לחיצה שמסתירה קטע, משיכת ערכים שמורים מקובץ, קפיצה לעמוד האחרון, או הרצת סקריפט שמחשב סכום של עמודה. שום דבר מזה לא חי בתוך השדה. זה חי במילון פעולות (action dictionary), ותקן ISO 32000-1 מארגן את כל המשפחה הזו בסעיף 12.6. מאמר זה עובר על הפעולות שתוכנת Delphi משתמשת בהן לעיתים קרובות ומראה כיצד PDFlibPas מחבר כל אחת מהן לשדה או לקישור

המודל המחשבתי שחשוב לשמור עליו הוא ששדה ופעולה הם אובייקטים נפרדים המחוברים באמצעות הפניה. אנוטציית יישומון (widget annotation) או אנוטציית קישור נושאת פעולה בערך ה-/A שלה. הפעולה מציינת את השדה שעליו היא פועלת לפי הכותרת שלו, ולא לפי אינדקס, כך שהכותרת שאתה נותן לשדה היא המזהה (handle) שבו כל פעולה מאוחרת יותר משתמשת כדי למצוא אותו. ברגע שההפרדה הזו ברורה, ה-API מפסיק להיראות כמו אוסף אקראי של קריאות ומתחיל להיראות כמו תבנית אחת המיושמת על ארבעה סוגי פעלים

פעולות בעלות שם: ניווט ללא מספר עמוד

הפעולות הפשוטות ביותר אינן נושאות פרמטרים כלל. תקן ISO 32000-1 §12.6.4.11, טבלה 194, מגדיר פעולות בעלות שם (named actions): המציג מפרש שם סמלי בזמן ריצה במקום לעקוב אחר יעד שמור. ארבעה שמות נתמכים באופן אוניברסלי, והם בדיוק אלו שקורא מצפה להם מסרגל הכלים: NextPage, PrevPage, FirstPage, ו-LastPage. מכיוון שהיעד הוא יחסי לעמוד שבו המציג נמצא כרגע, כפתור "הבא" שנבנה בצורה זו עובד בכל עמוד מבלי שתצטרך לחשב יעד

ב-PDFlibPas פעולה בעלת שם מחוברת למלבן אזור חם (hotspot rectangle) בעמוד הנוכחי. הארגומנטים הרביעי והחמישי מסוג מספר שלם בוחרים את הפועל ואת המראה

// NamedActionType: 0 = NextPage, 1 = PrevPage, 2 = FirstPage, 3 = LastPage
// Options bit 0 (value 1) draws a border around the hotspot
Pdf.AddLinkToNamedAction(500, 560, 60, 18, 0, 1);   // Next
Pdf.AddLinkToNamedAction(40, 560, 60, 18, 1, 1);    // Previous
Pdf.AddLinkToNamedAction(110, 560, 60, 18, 3, 1);   // jump to last page

אין יעד שצריך לשמור מסונכרן, וזה כל העניין. פעולה בעלת שם שורדת הוספת או מחיקת עמודים מכיוון שהיא אינה מציינת עמוד ספציפי מלכתחילה. השווה זאת לקישור מעבר מפורש (go-to link), השומר אינדקס של עמוד יעד שאתה חייב למספר מחדש ברגע שהמסמך גדל

פעולת ה-Hide והמלכודת של המערך

פעולת ה-Hide, לפי ISO 32000-1 §12.6.4.10, טבלה 196, מחליפה את הניראות של שדה אחד או יותר. זוהי הדרך הנקייה ביותר לבנות התנהגות של הצגה והסתרה ללא שימוש בסקריפטים, וזה מה שאתה רוצה עבור קישור "הצג פרטים" או עבור שני פאנלים המוציאים זה את זה שבהם חשיפת אחד מהם מסתירה את השני. הפעולה נושאת יעד בערך ה-/T שלה וערך בוליאני /H שקובע את הכיוון: הסתרה כאשר true, הצגה כאשר false

הדקויות הן לחלוטין באופו שבו היעד מקודד, וזה סוג הפרטים שיוצר טופס שעובד אצלך במחשב אך נכשל אצל הלקוח. כאשר הפעולה מציינת שדה בודד, ה-/T נכתב כטקסט בודד (string). כאשר היא מציינת מספר שדות, ה-/T נכתב כמערך של מחרוזות טקסט. מציגים ישנים יותר אינם מתייחסים למערך של איבר אחד באותו אופן שהם מתייחסים למחרוזת פשוטה, ולכן הקידוד חייב להתפצל לפי הכמות: שם בודד חייב להיפלט כמחרוזת, ולא כמערך באורך אחד, כדי שטווח הקוראים הרחב ביותר יכבד אותו. PDFlibPas מקבל את ההחלטה הזו עבורך. אתה מעביר שמות שדות מופרדים בפסיקים, נקודה ופסיק או מעברי שורה, והכותב פולט מחרוזת בודדת עבור שם אחד ומערך עבור שניים או יותר

// HideFlag non-zero hides the listed fields (/H true); zero shows them.
// One name -> /T is a text string. Two or more -> /T is an array of strings.
Pdf.AddLinkToHideField(40, 700, 90, 18, 'ShippingAddress', 1, 1);
Pdf.AddLinkToHideField(140, 700, 90, 18,
  'ShippingName,ShippingAddress,ShippingZip', 1, 1);

מכיוון שהפעולה אינה מתייחסת למשאב חיצוני, היא נשארת תואמת ל-PDF/A. השמות שאתה מעביר הם כותרות שדות מוגדרות במלואן (fully qualified), וזו הסיבה ששדה בן בתוך קבוצה חייב להיות מופנה דרך הנתיב המלא שלו עם נקודות במקום שמו הבסיסי בלבד

ImportData: מילוי מראש מ-FDF

במקום שבו פעולת ה-Hide מארגנת מחדש את מה שכבר נמצא בעמוד, פעולת ייבוא הנתונים (import-data) מביאה ערכים מחוצה לו. תקן ISO 32000-1 §12.6.4.8, טבלה 198, מגדיר אותה כפעולה המאכלסת את ה-AcroForm מקובץ Forms Data Format (FDF) בדיסק. זו הפעולה העומדת מאחורי פקדי "טען מחדש נתוני דוגמה" או "אפס לברירת מחדל", שבהם קובץ FDF נשלח לצד ה-PDF ומחזיק את ערכי השדות הקנוניים. הקריאה דומה לאחרות, ולוקחת את מלבן האזור החם, הנתיב ל-FDF, ומסיכת ביטים של מראה: Pdf.AddLinkToImportData(40, 660, 120, 18, 'defaults.fdf', 1). הקובץ אינו חייב להיות קיים בעת בניית ה-PDF, אך הוא חייב להיות נוכח כאשר המשתמש לוחץ, וכל לוכסן אחורי בנתיב מומר עבורך לצורת הלוכסן הקנונית של PDF

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

JavaScript: חבילות גלובליות וסקריפטים לכל פעולה

עבור לוגיקה החורגת מעבר להצגה, הסתרה וייבוא, משפחת הפעולות מגיעה ל-JavaScript ברמת המסמך. ישנם שני מקומות נפרדים שבהם סקריפט יכול לחיות, וההבדל ביניהם חשוב. חבילת JavaScript ברמת המסמך נשמרו פעם אחת עבור הקובץ כולו ורצה כאשר המסמך נפתח, מה שהופך אותה לבית הנכון עבור הגדרות פונקציות ומצב משותף. סקריפט לכל פעולה (per-action) מחובר לקישור או שדה אחד ורץ רק כאשר האובייקט הזה מופעל, מה שהופך אותו לבית הנכון עבור השורה הבודדת שקוראת לפונקציה שהחבילה כבר הגדירה

ב-PDFlibPas חושף את שניהם. AddGlobalJavaScript שומר חבילה בעלת שם ברמת המסמך; שימוש חוזר בשם מחליף את מה שהיה שמור תחתיו. AddLinkToJavaScript מחבר סקריפט לאזור חם כך שלחיצה מריצה אותו

// Document-level package: define a reusable function once.
Pdf.AddGlobalJavaScript('Totals',
  'function recalcTotal() {' +
  '  var net = this.getField("Net").value;' +
  '  var tax = this.getField("Tax").value;' +
  '  this.getField("Gross").value = Number(net) + Number(tax);' +
  '}');

// Per-action script on a link: just call the shared function.
Pdf.AddLinkToJavaScript(40, 620, 100, 18, 'recalcTotal();', 1);

שמירת הפונקציה בחבילה הגלובלית והקריאה בקישור אינה העדפת סגנון. היא מונעת שכפול של אותו גוף סקריפט בכל פקד שזקוק לו, ופירוש הדבר שמציג עם סקריפטים מושבתים פשוט לא יעשה דבר בלחיצה במקום להיכשל על קטע קוד פנימי לא תקין. זה גם שומר על הערכים לכל פעולה קטנים, מה ששומר על הקובץ קריא כאשר תבחן אותו מאוחר יותר

שדות, שדות בנים והקפאת התוצאה

פעולות זקוקות לשדות כדי לפעול עליהם, לכן כדאי לראות כיצד שדה נוצר. NewFormField יוצר שדה בעמוד הנוכחי ומחזיר את האינדקס שלו; טיפוס המספר השלם בוחר את הסוג, כאשר 1 הוא טקסט (Text),‏ 2 הוא כפתור לחיצה (Pushbutton),‏ 3 הוא תיבת סימון (Checkbox),‏ 4 הוא כפתור רדיו (Radiobutton),‏ 5 הוא בחירה (Choice),‏ 6 הוא חתימה (Signature), ו-7 הוא אב (Parent) שמחזיק בנים אך אינו מצייר דבר בעצמו. הכותרת שאתה מעביר אינה יכולה להכיל נקודה, מכיוון שנקודה היא המפריד בשמות המלאים (fully qualified names) שפעולות משתמשות בהם כדי לפנות לבנים

קבוצות רדיו וטפסים היררכיים נבנים על ידי מתן בנים לשדה אב. NewChildFormField מוסיף בן תחת אב בעל שם, ועבור מקרי הרדיו והבחירה AddFormFieldSub מוסיף את האפשרויות הבודדות ומחזיר אינדקס זמני שבו אתה משתמש כדי למקם כל אחת מהן. כאשר השלב האינטראקטיבי מסתיים ואתה רוצה להקפיא שדה כך שהמראה הנוכחי שלו יהפוך לתוכן עמוד קבוע, FlattenFormField מצייר את השדה על העמוד ומסיר אותו מהטופס. לאחר שיטוח (flatten), האינדקסים של שדות מאוחרים יותר מוסטים למטה באחד, וזה הדבר היחיד שצריך לזכור אם משטחים מספר שדות בלולאה

var
  Pdf: TPDFlib;
  FldShip: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.SetOrigin(1);          // top-left origin
    Pdf.SetPageSize('A4');
    Pdf.NewPage;

    // A text field the Hide action will target by its title.
    FldShip := Pdf.NewFormField('ShippingAddress', 1);
    Pdf.SetFormFieldBounds(FldShip, 40, 120, 240, 20);
    Pdf.SetFormFieldValue(FldShip, '');

    // Wire a Hide link and a navigation link to this page.
    Pdf.DrawText(40, 110, 'Toggle shipping block:');
    Pdf.AddLinkToHideField(220, 100, 70, 16, 'ShippingAddress', 1, 1);
    Pdf.AddLinkToNamedAction(500, 800, 60, 18, 3, 1);  // Last page

    // A document-level script available to every event in the file.
    Pdf.AddGlobalJavaScript('OnOpen',
      'app.alert("Form ready", 3);');

    // Freeze the field if the output should no longer be editable.
    // Pdf.FlattenFormField(FldShip);

    if Pdf.SaveToFile('form_actions.pdf') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

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

בחירת הפועל הנכון

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

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