Τεχνικό Άρθρο

Δυναμικό XFA runtime φορμών σε Delphi: συναλλαγές HotPDF

Το HotPDF συμπληρώνει δυναμικές XFA φόρμες σε Delphi μέσω του TXFAWidgetRuntime, ένα ουδέτερο προς τον host layer widgets που αντιμετωπίζει κάθε επεξεργασία πεδίου ως μία συναλλαγή: snapshot, validate, calculate, reflow, και μετά δημοσίευση ή rollback ολόκληρο. Τρέχει μονονηματικά μέσα στον δικό σας VCL ή FMX host, δεν χρειάζεται εγκατεστημένο Acrobat, και επιβάλλει κάθε budget πριν δεσμεύσει οτιδήποτε

Το σενάριο είναι οικείο σε όποιον έχει παραδώσει λογισμικό εγγράφων σε κυβερνητικούς ή ασφαλιστικούς οργανισμούς. Μια φόρμα αποζημίωσης ή μια φορολογική δήλωση φτάνει ως PDF του οποίου το περιεχόμενο σελίδας είναι μια ενιαία ειδοποίηση «Please wait... if this message is not eventually replaced», και κάθε πραγματικό πεδίο βρίσκεται σε ένα πακέτο XFA που αποδίδει μόνο το Adobe Acrobat. Οι χρήστες σας θέλουν να τη συμπληρώσουν μέσα στην εφαρμογή σας. Δεν μπορείτε ούτε να βγείτε με rasterization, επειδή η φόρμα μεγαλώνει γραμμές όσο εισάγονται δεδομένα, και η διάταξη μετά την τρίτη γραμμή δεν είναι αυτή που στάλθηκε στο αρχείο

Γιατί το δυναμικό XFA παραμένει πρόβλημα που αξίζει λύσης

Το δυναμικό XFA επιβιώνει επειδή οι αναπτυγμένες φόρμες ζουν περισσότερο από τη μορφή που τις μετέφερε. Το ISO 32000-1 §12.7.8 περιγράφει το XFA ως μια εγγραφή /XFA στο λεξικό AcroForm που κρατά ένα stream πακέτου XDP, και το ISO 32000-2 αποσύρει όλο τον μηχανισμό· η απόσυρση τον αφαίρεσε από τον χάρτη πορείας, όχι από το πεδίο, και φόρμες γραμμένες κατά προδιαγραφή XFA 3.3 εκδίδονται ακόμα και παραμένουν νομικά δεσμευτικές. Το στατικό XFA μπορεί να αναχθεί σε συνηθισμένες σημειώσεις widgets, και το HotPDF το κάνει όταν καλείτε ApplyXFAAsAcroForm, με τα trade-offs που καλύπτονται στο flattening XFA forms into AcroForm fields. Το δυναμικό XFA είναι διαφορετικό ζώο: τα occur ranges του, το επεκτάσιμο κείμενο και τα scripts calculate κάνουν το σύνολο των πεδίων συνάρτηση των δεδομένων, οπότε δεν υπάρχει σταθερή λίστα σημειώσεων στην οποία να «ισοπεδωθεί» πριν ο χρήστης τελειώσει την πληκτρολόγηση. Αυτό το κενό γεμίζει το TXFAWidgetRuntime, κρατώντας το XFA DOM ζωντανό, επανυπολογίζοντας τη διάταξη μετά από κάθε αποδεκτή επεξεργασία, και δίνοντας στον host σας ένα επίπεδο array τοποθετημένων widgets για σχεδίαση και hit-test

Τι παραδίδει το runtime σε μια εφαρμογή host;

Σας παραδίδει γεωμετρία και κατάσταση, και τίποτα που να προϋποθέτει ένα UI toolkit. Το TXFAWidgetRuntime εκθέτει WidgetCount και Widgets[I] ως εγγραφές TXFAWidgetState που μεταφέρουν ID, Name, Kind, PageIndex, Bounds σε PDF points, Value, EditValue και τα flags Focused, Editing, ReadOnly, Valid, ενώ η ζωγραφική, η απεικόνιση caret και η δρομολόγηση πληκτρολογίου μένουν στον κώδικά σας. Η ταυτότητα widget είναι σταθερή και τακτική: κάθε widget παίρνει ID της μορφής name[n], όπου n μετρά τις προηγούμενες εμφανίσεις εκείνου του ονόματος πεδίου σε σειρά διάταξης, ώστε η δεύτερη γραμμή ενός επαναλαμβανόμενου subform να είναι amount[1]. Αυτή η ταυτότητα είναι που επιβιώνει από ένα rebuild, και είναι αυτή που μιλάνε όλα τα FocusWidget, BeginEdit, DispatchEvent και HitTest. Για ένα έγγραφο ήδη ανοιχτό σε instance THotPDF, το CreateLoadedXFAWidgetRuntime εξάγει τα πακέτα XDP, παίρνει το πρώτο page box ως μέγεθος σελίδας διάταξης, και επιστρέφει 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)]));
      // hit-test σε χώρο σελίδας, κερδίζει το κορυφαίο widget
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Τι πρέπει να είναι ατομικό όταν δεσμεύεται ένα πεδίο;

Όλα όσα μπορεί να αγγίξει η επεξεργασία, δηλαδή σημαντικά περισσότερα από την τιμή του πεδίου. Το CommitEdit καλεί CaptureSnapshot πριν γράψει οτιδήποτε, και αυτό το snapshot καλύπτει τέσσερα πράγματα: το serialised XFA DOM από TXFADocument.SaveToBytes, ολόκληρο το array των εγγραφών αλληλεπίδρασης TXFAWidgetState, τους μετρητές LastCalculationPasses και LastReflowPasses, και το τρέχον Warnings.Count. Η αποθήκευση μόνο τιμών κόμβων είναι η δελεαστική συντόμευση και είναι λάθος, επειδή ένα script calculate ή ένα ανεπίλυτο binding μπορεί να καλέσει EnsureValueNode και να υλοποιήσει κόμβους δεδομένων που δεν υπήρχαν όταν ξεκίνησε η επεξεργασία· μια restore μόνο τιμών δεν έχει τρόπο να τους αφαιρέσει, ώστε μια απορριφθείσα επεξεργασία θα άφηνε μόνιμο δομικό υπόλειμμα στο πακέτο datasets. Η ίδια η ακολουθία commit είναι αυστηρή — γράψε την υποψήφια τιμή, τρέξε validate για το επεξεργασμένο πεδίο, τρέξε calculate μέχρι σταθερό σημείο, μετά reflow μέχρι η διάταξη να σταθεροποιηθεί — και κάθε αποτυχία σε οποιοδήποτε στάδιο δρομολογείται μέσω FailAndRestore, που φορτώνει τα bytes του snapshot σε φρέσκο TXFADocument, ξαναχτίζει τη λίστα widgets, επανεφαρμόζει τις καταγεγραμμένες καταστάσεις αλληλεπίδρασης, μηδενίζει τους μετρητές και περικόπτει τα Warnings πίσω στο μήκος του snapshot. Το LastDiagnostic κρατά την αιτία σε αποτυχία, και κρατά το κυριολεκτικό XFA transaction rollback failed στην παθολογική περίπτωση όπου η ίδια η restore πετάει exception

Το HotPDF αντιμετωπίζει το commit ενός πεδίου XFA ως μία συναλλαγή, καταγράφοντας το serialised DOM, κάθε κατάσταση widget, τους μετρητές διελεύσεων και το πλήθος των προειδοποιήσεων πριν από validate, calculate και reflow, και μετά δημοσιεύει ή επαναφέρει και τα τέσσερα μαζί
Το CommitEdit κρατά snapshot τεσσάρων ειδών κατάστασης πριν γράψει οτιδήποτε, ώστε ένα αποτυχημένο validate, calculate ή reflow να μην αφήνει πίσω δομικό υπόλειμμα
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // read-only, ή δεν υπάρχει τέτοιο widget
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // κακό range, ή split surrogate
    Exit;
  end;
  Result := Runtime.CommitEdit;             // όλα-ή-τίποτα
  if not Result then
    // έγγραφο, widgets, μετρητές και προειδοποιήσεις είναι ήδη πίσω στην
    // προ-επεξεργασίας κατάσταση· το εστιασμένο widget απλώς σημειώνεται invalid
    ShowMessage(Runtime.LastDiagnostic);
end;

Το ReplaceSelection αξίζει σημείωμα δικό του, επειδή είναι το σημείο όπου η κακοδιατυπωμένη είσοδος απορρίπτεται φθηνότερα. Αρνείται επιλογή που χωρίζει ένα ζεύγος UTF-16 surrogate, αρνείται κείμενο αντικατάστασης που περιέχει unpaired high ή low surrogate, και αρνείται οποιοδήποτε αποτέλεσμα μακρύτερο από MaxValueChars. Το πιάσιμο αυτό στο layer πληκτρολόγησης σημαίνει ότι ο μηχανισμός συναλλαγής δεν χρειάζεται ποτέ να ξετυλίξει μισογραμμένο χαρακτήρα astral plane

Rebuild σε ιδιωτική λίστα, δημοσίευση με ένα swap

Ένα rebuild widgets δεν πρέπει ποτέ να γίνεται παρατηρήσιμο μισοτελειωμένο, ώστε το RebuildWidgets χτίζει μια εντελώς ξεχωριστή owning TObjectList και την ανταλλάσσει στη θέση της με μία εκχώρηση στο τέλος. Η αιτία δεν είναι αισθητική: το TXFALayoutEngine.ComputeLayout τρέχει όσο το rebuild βρίσκεται σε εξέλιξη και καλεί πίσω κώδικα host μέσω της συνάρτησης MeasureText που προμηθεύσατε, και μπορεί να πετάξει EXFAWidgetRuntimeError όταν χτυπήσει το όριο widgets. Αν το runtime μετάλλωνε τη ζωντανή λίστα του επιτόπου, οποιαδήποτε από τις δύο διαδρομές θα άφηνε τον host να κρατά λίστα που είναι μέρος παλιά διάταξη και μέρος νέα, με δείκτες DataNode μέσα σε έγγραφο που πρόκειται να γίνει rollback. Η σύγκλιση του reflow αποφασίζεται τότε από το LayoutSignature, ένα string χτισμένο από το πλήθος widgets συν κάθε ID, δείκτη σελίδας και bounding box στρογγυλεμένο σε τέσσερα δεκαδικά: το CommitEdit κάνει rebuilds, συγκρίνει signatures, και επαναλαμβάνει μέχρι δύο διαδοχικές signatures να ταιριάξουν ή το budget διελεύσεων να εξαντληθεί. Όταν η signature δεν άλλαξε καθόλου, το LastReflowPasses μένει 0, κάτι που σας λέει πώς να ξεχωρίσετε επεξεργασία μόνο τιμής από μία που όντως μεγάλωσε τη φόρμα, και η κατάσταση αλληλεπίδρασης μεταφέρεται σε κάθε rebuild κατά widget ID, ώστε focus και εν εξελίξει επεξεργασία να επιβιώνουν από την εισαγωγή γραμμής

Το XFA runtime του HotPDF ξαναχτίζει τη λίστα widgets σε ξεχωριστή owning λίστα όσο η διάταξη τρέχει και καλεί πίσω κώδικα μέτρησης του host, και μετά δημοσιεύει την ολοκληρωμένη λίστα με μία εκχώρηση που ο host δεν μπορεί να παρατηρήσει μισοτελειωμένη
Το rebuild γίνεται σε ιδιωτική λίστα επειδή το ComputeLayout μπορεί να πετάξει exception midway, και το LayoutSignature αποφασίζει πότε δύο διαδοχικά reflow έχουν συγκλίνει

Γιατί ένα δεσμευμένο πεδίο διαβάζει λάθος εγγραφή;

Επειδή το script τρέχει χωρίς context δεδομένων. Ένα πεδίο που κουβαλά ρητό <bind match="dataRef" ref="$record.actual"/> και ένα πεδίο που φέρει το όνομα του ίδιου κόμβου δεδομένων είναι δύο διαφορετικά widgets που δείχνουν σε μία τιμή, και ένα επαναλαμβανόμενο subform με <occur max="2"/> παράγει πολλά widgets που μοιράζονται όνομα και διαφέρουν μόνο στο ποια γραμμή δεδομένων ανήκουν· αν αξιολογήσετε validation και calculation ριζικά από τη ρίζα του εγγράφου, κάθε ένα από αυτά επιλύει το this στον πρώτο ταιριαστό κόμβο σε όλο το πακέτο datasets, ώστε η γραμμή δύο να επικυρώνει σιωπηλά τη γραμμή ένα. Το HotPDF το αποφεύγει αποθηκεύοντας το επιλυμένο DataNode σε κάθε εγγραφή widget όταν η διάταξη το παράγει, και μετά περνώντας αυτόν τον κόμβο και στις δύο κλήσεις HPDFXFAEvaluateFieldScript, για xfskValidate και xfskCalculate εξίσου. Το ίδιο context αποφασίζει εναντίον ποιου κόμβου δημιουργεί το EnsureValueNode όταν ένα calculation στοχεύει binding που δεν υπάρχει ακόμα, και όταν κανένα binding δεν μπορεί να επιλυθεί το commit αποτυγχάνει καθαρά με XFA calculation target is not bound αντί να γράψει σε λάθος γραμμή. Η σημασιολογία FormCalc πίσω από αυτά τα scripts αντιστοιχεί σε αυτό που παίρνουν τα έγγραφα AcroForm από τα actions που περιγράφονται στο AcroForm format and calculate scripts, αλλά οι κανόνες επίλυσης εδώ έχουν εύρος XFA και όχι εύρος ονόματος πεδίου

Τα budgets ελέγχονται πριν τις παρενέργειες, όχι μετά

Κάθε όριο στο runtime είναι προϋπόθεση, επειδή ένα budget που επιβάλλεται αφού έχει ήδη συμβεί η δέσμευση δεν είναι budget. Το TXFAWidgetRuntimeOptions.Default στέλνει MaxWidgets σε 10000, MaxValueChars σε 1048576, MaxCalculationPasses σε 16 και MaxReflowPasses σε 4, και οι προεπιλεγμένοι TXFAFormScriptOptions κουβαλούν MaxOperations σε 100000 με MaxElapsedMilliseconds σε 500. Από κάτω, το XFA DOM επιβάλλει δικά του TXFADOMLimits: όρια 128 MB σε αποσυμπιεσμένη είσοδο και έξοδο, το πολύ 1024 πακέτα ραμμένα μαζί, 1000000 κόμβοι, και βάθος εμφώλευσης 256. Δύο λεπτομέρειες μετράνε περισσότερο από οι ίδιοι οι αριθμοί. Πρώτον, τα budgets scripts είναι σε επίπεδο συναλλαγής και όχι ανά script: το CommitEdit σπέρνει έναν μόνο μετρητή υπόλοιπων λειτουργιών και ένα μονότονο deadline, και κάθε κλήση validate και calculate αντλεί από τον ίδιο μετρητή και λαμβάνει μόνο τα χιλιοστά του δευτερολέπτου που απομένουν, ώστε μια φόρμα με διακόσια πεδία που εκτελούν calculate δεν μπορεί να ξοδέψει τα πλήρη 500 ms διακόσιες φορές. Δεύτερον, το deadline προέρχεται από injectable συνάρτηση MonotonicMilliseconds, που είναι ακριβώς αυτό που κάνει τη συμπεριφορά χρόνου αναπαραγώγιμη σε test suite αντί για ρίψη κέρματος σε πολυάσχολο build agent

Layer budgets στο XFA runtime του HotPDF, από όρια widgets και τιμών μέσω limits λειτουργίας και χρόνου scripts μέχρι τα caps του XFA DOM, με έναν μετρητή λειτουργιών και ένα deadline που μοιράζεται κάθε κλήση σε μια συναλλαγή
Τα budgets scripts είναι σε επίπεδο συναλλαγής και όχι ανά script, ώστε διακόσια πεδία που εκτελούν calculate να μην μπορούν να διεκδικήσουν το καθένα φρέσκο 500 ms
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // default 10000
  Options.MaxCalculationPasses := 8;                       // default 16
  Options.MaxReflowPasses := 2;                            // default 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // whole transaction
  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;   // fired only when reflow actually moved widgets
      end;
    // ... drive the form ...
  finally
    Runtime.Free;
  end;
end;

Πού σταματά το runtime, και γιατί το λέει δυνατά

Το runtime σκόπιμα δεν είναι γενική μηχανή scripting XFA. Το DispatchEvent χειρίζεται natively τις δραστηριότητες enter και exit μετακινώντας focus, και για κάθε άλλη δραστηριότητα που κουβαλά script αρνείται με συγκεκριμένη, σταθερή διαγνωστική αντί να προσποιείται: scripts που αναφέρουν addInstance, removeInstance ή instanceManager επιστρέφουν XFA runtime does not support event-driven instance mutation, scripts που αγγίζουν .presence επιστρέφουν το αντίστοιχο presence, και οτιδήποτε άλλο επιστρέφει XFA runtime does not support this event script. Μια προβλέψιμη άρνηση στην οποία μπορείτε να κάνετε branch νικά μια μερική εξομοίωση που δουλεύει στο δείγμα σας και αποκλίνει στο αρχείο του πελάτη

Το threading model είναι εξίσου αμείλικτο: ένα instance runtime ανήκει σε ένα νήμα, χωρίς εσωτερικό locking, επειδή η μηχανή διάταξης φτάνει πίσω σε measurement callbacks του host και ένα lock γύρω από αυτό είναι deadlock που περιμένει repaint. Το rich content μέσα σε πεδία ακολουθεί την ίδια συντηρητική γραμμή όπως αλλού στη βιβλιοθήκη, όπου τα payloads exData χειρίζονται όπως περιγράφεται στο XFA exData rich text and hyperlinks, και widgets signature και button επιστρέφουν ως ReadOnly ενώ μη υποστηριζόμενα UI kinds εμφανίζονται ως xwkUnsupported και όχι ως editable text box που σιωπηλά χάνει δεδομένα

Όλα αυτά μαζί, είναι μια βιώσιμη απάντηση στο δυναμικό XFA σε Delphi: κρατήστε το DOM ζωντανό, κάντε κάθε επεξεργασία συναλλαγή που είτε προσγειώνεται ολοκληρωμένη είτε δεν αφήνει τίποτα πίσω, ορίστε όρια σε κάθε διέλευση, και να είστε ρητοί για το τι είναι εκτός εμβέλειας. Αν το αξιολογείτε για workflow αποζημιώσεων, φόρων ή επιδομάτων, το XFA runtime στέλνεται ως μέρος του HotPDF Delphi PDF component, μαζί με τις διαδρομές AcroForm, flattening και rendering που αυτά τα projects συνήθως καταλήγουν να χρειάζονται μαζί