מאמר טכני

ריצת טפסי XFA דינמיים ב־Delphi: טרנזקציות של HotPDF

HotPDF ממלא טפסי XFA דינמיים ב־Delphi דרך TXFAWidgetRuntime, שכבת ווידג'טים נייטרלית למארח שמתייחסת לכל עריכת שדה כטרנזקציה אחת: צילום מצב, אימות, חישוב, סידור מחדש, ואז פרסום או החזרה שלמה. היא רצה בתהליכון יחיד בתוך המארח מסוג VCL או FMX שלכם, לא דורשת Acrobat מותקן ואוכפת כל תקציב לפני שהיא מקצה משהו

התרחיש מוכר לכל מי ששילח תוכנת מסמכים לעבודה ממשלתית או ביטוחית. טופס תביעה או הצהרת מס מגיע כ־PDF שתוכן העמוד שלו הוא הודעת "Please wait... if this message is not eventually replaced" אחת, וכל שדה אמיתי חי בחבילת XFA שרק Adobe Acrobat מעבד. המשתמשים שלכם רוצים למלא אותו בתוך היישום שלכם. גם ריסוטר אינו פותר, כי הטופס מוסיף שורות תוך כדי הזנת נתונים, והפריסה אחרי השורה השלישית אינה הפריסה שיצאה מהקובץ

למה XFA דינמי עדיין בעיה ששווה לפתור

XFA דינמי שורד כי טפסים שנפרסו מאריכים ימים מהפורמט שנשא אותם. ISO 32000-1 §12.7.8 מתאר XFA כרשומת /XFA במילון ה־AcroForm המחזיקה זרם חבילת XDP, ו־ISO 32000-2 פוסל את כל המנגנון; הפסילה הוציאה אותו ממפת הדרכים, לא מהשטח, וטפסים שנכתבו לפי מפרט XFA 3.3 עדיין מופקים ועדיין מחייבים משפטית. XFA סטטי אפשר לצמצם להערות ווידג'ט רגילות, ו־HotPDF עושה זאת כשקוראים ל־ApplyXFAAsAcroForm, עם הפשרות שנסקרו במאמר על שיטוח טפסי XFA לשדות AcroForm. XFA דינמי הוא חיה אחרת: טווחי ה־occur, הטקסט הגדל וסקריפטי ה־calculate הופכים את קבוצת השדות לפונקציה של הנתונים, כך שאין רשימת הערות קבועה לשיטוח אליה עד שהמשתמש סיים להקליד. זה הפער ש־TXFAWidgetRuntime ממלא, כשהוא משאיר את ה־DOM של ה־XFA חי, מחשב מחדש את הפריסה אחרי כל עריכה מאושרת ונותן למארח שלכם מערך שטוח של ווידג'טים ממוקמים לציור ולבדיקת פגיעה

מה הריצה מוסרת ליישום מארח?

היא נותנת לכם גיאומטריה ומצב, ושום דבר שמניח ערכת כלי UI. TXFAWidgetRuntime חושף WidgetCount ו־Widgets[I] כרשומי TXFAWidgetState הנושאים ID, Name, Kind, PageIndex, Bounds בנקודות PDF, Value, EditValue ואת הדגלים Focused, Editing, ReadOnly, Valid, בעוד שהציור, ציור סמן ההקלדה וניתוב המקלדת נשארים בקוד שלכם. זהות הווידג'ט יציבה וסודרית: כל ווידג'ט מקבל ID מהצורה name[n], כאשר n סופר הופעות קודמות של אותו שם שדה בסדר הפריסה, ולכן השורה השנייה של תת־טופס חוזר היא amount[1]. הזהות הזאת היא מה ששורד בנייה מחדש, והיא מה ש־FocusWidget, BeginEdit, DispatchEvent ו־HitTest כולם מדברים. עבור מסמך שכבר פתוח במופע THotPDF, CreateLoadedXFAWidgetRuntime מחלץ את חבילות ה־XDP, לוקח את תיבת העמוד הראשונה כגודל עמוד הפריסה ומחזיר nil כשהקובץ אינו נושא XFA בכלל

var
  Pdf: THotPDF;
  Runtime: TXFAWidgetRuntime;
  WidgetID: AnsiString;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('claim-dynamic.pdf');
    Runtime := Pdf.CreateLoadedXFAWidgetRuntime;   // nil כשאין /XFA
    if Runtime = nil then
      Exit;
    try
      for I := 0 to Runtime.WidgetCount - 1 do
        Memo1.Lines.Add(Format('%s p%d [%.1f %.1f %.1f %.1f] = %s',
          [string(Runtime.Widgets[I].ID), Runtime.Widgets[I].PageIndex,
           Runtime.Widgets[I].Bounds.Left, Runtime.Widgets[I].Bounds.Top,
           Runtime.Widgets[I].Bounds.Right, Runtime.Widgets[I].Bounds.Bottom,
           string(Runtime.Widgets[I].Value)]));
      // בדיקת פגיעה במרחב העמוד, הווידג'ט העליון מנצח
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

מה חייב להיות אטומי כאשר שדה נאשר?

כל מה שהעריכה יכולה לגעת בו, וזה הרבה יותר מערך השדה. CommitEdit קורא ל־CaptureSnapshot לפני שהוא כותב משהו, והצילום הזה מכסה ארבעה דברים: את ה־DOM של ה־XFA המסודר מ־TXFADocument.SaveToBytes, את מערך רשומות האינטראקציה המלא של TXFAWidgetState, את מוני LastCalculationPasses ו־LastReflowPasses ואת ה־Warnings.Count הנוכחי. שמירת ערכי צמתים בלבד היא הקיצור המפתה והוא שגוי, כי סקריפט calculate או קשירה שלא הופענחה יכולים לקרוא ל־EnsureValueNode ולהגשים צמתי נתונים שלא היו קיימים כשהעריכה החלה; שחזור של ערכים בלבד אינו יכול להסיר אותם, ולכן עריכה שנדחתה הייתה משאירה שארית מבנית קבועה בחבילת ה־datasets. רצף האישור עצמו קפדני — כתיבת ערך המועמד, הרצת validate לשדה שנערך, הרצת calculate עד נקודה יציבה, ואז סידור מחדש עד שהפריסה מתייצבת — וכל כשל בכל שלב מופנה דרך FailAndRestore, שטוען את בייטי הצילום לתוך TXFADocument טרי, בונה מחדש את רשימת הווידג'טים, מיישם מחדש את מצבי האינטראקציה שנרשמו, מאפס את המונים ומקצץ את Warnings בחזרה לאורך הצילום. LastDiagnostic מחזיק את הסיבה בכשל, ומחזיק את הליטרלי XFA transaction rollback failed במקרה הפתולוגי שבו השחזור עצמו מעלה חריגה

HotPDF מתייחס לאישור שדה XFA כטרנזקציה אחת, צולם את ה־DOM המסודר, את כל מצבי הווידג'טים, את מוני המעברים ואת מספר האזהרות לפני אימות, חישוב וסידור מחדש, ואז מפרסם או משחזר את כל ארבעתם יחד
CommitEdit צולם ארבעה סוגי מצב לפני שהוא כותב משהו, כך שאימות, חישוב או סידור מחדש שנכשלו לא משאירים שארית מבנית
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // לקריאה בלבד, או אין ווידג'ט כזה
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // טווח שגוי, או זוג סורוגט מפוצל
    Exit;
  end;
  Result := Runtime.CommitEdit;             // הכל או כלום
  if not Result then
    // המסמך, הווידג'טים, המונים והאזהרות כבר חזרו למצב שלפני
    // העריכה; הווידג'ט הממוקד פשוט מסומן כפסול
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection ראוי להערה משלו, כי שם הקלט הפגום הכי זול לדחות. הוא מסרב לבחירה שמפצלת זוג סורוגט UTF-16, מסרב לטקסט החלפה שמכיל סורוגט גבוה או נמוך שאינו מזווג, ומסרב לכל תוצאה ארוכה מ־MaxValueChars. לתפוס את זה בשכבת המקשים פירושו שמנגנון הטרנזקציות לעולם לא יצטרך לפרום חצי תו ממישור אחר שנכתב חלקית

בנייה מחדש לרשימה פרטית, פרסום בהחלפה אחת

בנייה מחדש של ווידג'טים אסור שתיראה חצי גמורה, ולכן RebuildWidgets בונה TObjectList נפרד לגמרי שבבעלותו ומחליף אותו למקום בהשמה אחת בסוף. הסיבה אינה אסתטיקה: TXFALayoutEngine.ComputeLayout רץ בזמן שהבנייה מחדש בעיצומה וקורא בחזרה לקוד מארח דרך פונקציית MeasureText שסיפקתם, והוא יכול להעלות EXFAWidgetRuntimeError כשמגבלת הווידג'טים נפגעת. אם הריצה הייתה משנה את הרשימה החיה במקום, כל אחד מהנתיבים היה משאיר את המארח מחזיק רשימה שחציה פריסה ישנה וחציה חדשה, עם מצביעי DataNode אל מסמך שעומד להתהפך. התכנסות הסידור מחדש נקבעת אז על ידי LayoutSignature, מחרוזת שנבנית ממספר הווידג'טים ועוד כל ID, אינדקס עמוד ותיבה תוחמת מעוגלים לארבע ספרות: CommitEdit בונה מחדש, משווה חתימות וחוזר עד ששתי חתימות עוקבות נכתבות זהות או שתקציב המעברים מתרוקן. כשהחתימה לא השתנתה כלל, LastReflowPasses נשאר 0, וזה איך מבדילים עריכת ערך בלבד מעריכה שהגדילה באמת את הטופס, ומצב האינטראקציה עובר בכל בנייה מחדש לפי ID של ווידג'ט, כך שהמיקוד והעריכה שבעיצומה שורדים הוספת שורה

ריצת ה־XFA של HotPDF בונה מחדש את רשימת הווידג'טים שלה לרשימה נפרדת בבעלותה בזמן שהפריסה רצה וקוראת בחזרה לקוד מדידת המארח, ואז מפרסמת את הרשימה הגמורה בהשמה אחת שהמארח אינו יכול לראות חצי שלמה
הבנייה מחדש קורית ברשימה פרטית כי ComputeLayout יכול להעלות חריגה בעיצומה, ו־LayoutSignature קובע מתי שני סידורים מחדש עוקבים התכנסו

למה שדה קשור יקרא רשומה שגויה?

כי הסקריפט רץ בלי הקשר נתונים. שדה שנושא <bind match="dataRef" ref="$record.actual"/> מפורש ושדה שנקרא על שם אותו צומת נתונים הם שני ווידג'טים שונים המצביעים על ערך אחד, ותת־טופס חוזר עם <occur max="2"/> מייצר כמה ווידג'טים שחולקים שם ונבדלים רק בשורת הנתונים שאליה הם שייכים; הערכת אימות וחישוב מול שורש המסמך גורמת לכל אחד מהם לפענח את this לצומת התואם הראשון בכל חבילת ה־datasets, ולכן שורה שנייה מאמתת בשקט את שורה אחת. HotPDF נמנע מזה כשהוא שומר את ה־DataNode המפוענח בכל רשומת ווידג'ט כשהפריסה מייצרת אותה, ואז משחל את הצומת הזה דרך שתי הקריאות של HPDFXFAEvaluateFieldScript, ל־xfskValidate ול־xfskCalculate כאחד. אותו הקשר קובע מול איזה צומת EnsureValueNode יוצר כשחישוב מטיל על קשירה שעדיין לא קיימת, וכשאין קשירה שאפשר לפענח האישור נכשל בנקיון עם XFA calculation target is not bound במקום לכתוב לשורה השגויה. סמנטיקת ה־FormCalc שמאחורי הסקריפטים האלה מהדהדת את מה שמסמכי AcroForm מקבלים מהפעולות שתוארו במאמר על סקריפטי format ו־calculate של AcroForm, אבל כללי הפענוח כאן מוגדרים בהיקף XFA ולא בהיקף שם שדה

התקציבים נבדקים לפני תופעות לוואי, ולא אחרי

כל מגבלה בריצה היא תנאי מקדים, כי תקציב שנאכף אחרי שההקצאה כבר קרתה אינו תקציב. TXFAWidgetRuntimeOptions.Default מגיע עם MaxWidgets של 10000, MaxValueChars של 1048576, MaxCalculationPasses של 16 ו־MaxReflowPasses של 4, וברירות המחדל של TXFAFormScriptOptions נושאות MaxOperations של 100000 עם MaxElapsedMilliseconds של 500. בתוך זה, ה־DOM של ה־XFA מיישם TXFADOMLimits משלו: תקרות של 128 MB על קלט ופלט מפורקים, 1024 חבילות לכל היותר שנתפרות יחד, 1000000 צמתים ועומק קינון של 256. שני פרטים חשובים יותר מהמספרים עצמם. ראשית, תקציבי הסקריפטים הם ברמת הטרנזקציה ולא לכל סקריפט: CommitEdit זורע מונה פעולות שנותרו יחיד ותאריך יעד מונוטוני אחד, וכל הפעלת validate ו־calculate שואבת מאותו מונה ומקבלת רק את המילישניות שנותרו, כך שטופס עם מאתיים שדות מחשבים לא יכול לבזבז את מלוא 500 ה־ms מאתיים פעמים. שנית, תאריך היעד מגיע מפונקציית MonotonicMilliseconds שניתנת להזרקה, וזה מה שהופך את התנהגות זמן החלפה לניתנת לשחזור בחבילת בדיקות במקום הטלת מטבע על סוכן בנייה עמוס

שכבות תקציב בריצת ה־XFA של HotPDF, ממגבלות ווידג'טים וערכים דרך מגבלות פעולות וזמן של סקריפטים ועד לתקרות ה־DOM של ה־XFA, עם מונה פעולות אחד ותאריך יעד אחד המשותפים לכל קריאה בטרנזקציה
תקציבי הסקריפטים הם ברמת הטרנזקציה ולא לכל סקריפט, כך שמאתיים שדות מחשבים לא יכולים לתבוע כל אחד 500 ms טריים
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // ברירת מחדל 10000
  Options.MaxCalculationPasses := 8;                       // ברירת מחדל 16
  Options.MaxReflowPasses := 2;                            // ברירת מחדל 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // כל הטרנזקציה
  Options.ScriptOptions.Limits.MaxElapsedMilliseconds := 200;
  Options.MeasureText :=
    function(const AText: UnicodeString; const AFont: TXFAFontSpec;
      AMaxWidth: Double): TXFATextExtent
    begin
      Result := MeasureWithHostCanvas(AText, AFont, AMaxWidth);
    end;
  Runtime := TXFAWidgetRuntime.Create(XDPBytes, 612, 792, Options);
  try
    Runtime.OnLayoutChanged :=
      procedure
      begin
        RepaintAllPages;   // נורה רק כשה־reflow באמת הזיז ווידג'טים
      end;
    // ... מנהלים את הטופס ...
  finally
    Runtime.Free;
  end;
end;

איפה הריצה עוצרת, ולמה היא אומרת זאת בקול

הריצה אינה בכוונה מנוע סקריפטים כללי של XFA. DispatchEvent מטפל באופן טבעי בפעילויות enter ו־exit על ידי הזזת המיקוד, ולכל פעילות אחרת שנושאת סקריפט הוא מסרב עם אבחנה ספציפית ויציבה במקום להעמיד פנים: סקריפטים שמזכירים addInstance, removeInstance או instanceManager מחזירים XFA runtime does not support event-driven instance mutation, סקריפטים שנוגעים ב־.presence מחזירים את המקבילה של ה־presence, וכל השאר מחזירים XFA runtime does not support this event script. סירוב צפוי שאפשר להסתעף עליו עדיף על חיקוי חלקי שעובד על קובץ הדוגמה שלכם וסוטה אצל הלקוח

מודל התהליכונים צלול באותה מידה: מופע ריצה אחד שייך לתהליכון אחד, בלי נעילה פנימית, כי מנוע הפריסה חוזר לפונקציות מדידה של המארח ונעילה מסביבן היא מוות נעול שמחכה לריפוד. תוכן עשיר בתוך שדות הולך בשורה השמרנית של שאר הספרייה, שבה מטעני exData מטופלים כפי שתוארו במאמר על טקסט עשיר וקישורים ב־exData של XFA, ווידג'טים של חתימה וכפתור מוחזרים כ־ReadOnly בזמן שסוגי UI לא נתמכים עולים כ־xwkUnsupported ולא כתיבת טקסט לעריכה שמאבדת נתונים בשקט

ביחד, זו תשובה עובדת ל־XFA דינמי ב־Delphi: להשאיר את ה־DOM חי, להפוך כל עריכה לטרנזקציה שאו נוחתת כולה או לא משאירה דבר, לתחום כל מעבר ולהיות מפורשים לגבי מה שמחוץ להיקף. אם אתם מעריכים את זה לזרימת תביעות, מס או קצבאות, ריצת ה־XFA מגיעה כחלק מה־HotPDF Delphi PDF component, לצד נתיבי ה־AcroForm, השיטוח והרינדור שהפרויקטים האלה בדרך כלל מגלים שהם צריכים יחד