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

Γιατί οι επεξεργασίες πεδίων XFA εξαφανίζονται κατά την αποθήκευση στο PDFium για Delphi

Το TPdf.SetFocusedFormFieldText στο PDFium Component γράφει στο ζωντανό buffer επεξεργασίας του τρέχοντος εστιασμένου πεδίου φόρμας, και για μια φόρμα XFA εκείνο το buffer ποτέ δεν φτάνει στο πακέτο datasets που σειριοποιείται στον δίσκο — οπότε μια τιμή που πληκτρολογεί ένας χρήστης, και ο κώδικάς σας επιβεβαιώνει ότι έγινε αποδεκτή, εξαφανίζεται σιωπηλά την επόμενη φορά που ανοίγει το αρχείο. Τα πεδία AcroForm δεν έχουν αυτό το πρόβλημα: η ίδια κλήση δεσμεύει στην καταχώριση /V του πεδίου τη στιγμή που η εστίαση απομακρύνεται. Ένας χρήστης που συμπληρώνει μια φόρμα εισαγωγής XFA, αποθηκεύει, και ξανανοίγει για να βρει το πεδίο ποσού κενό ξανά δεν χτυπά ένα σφάλμα απόδοσης — χτυπά την άκρη αυτού που εκθέτει η ίδια η μηχανή PDFium για την εγγραφή δεδομένων φόρμας

Αυτό είναι ένα στενότερο ερώτημα από τον εντοπισμό μιας φόρμας XFA εξαρχής, ή την εκτέλεση της JavaScript της: όχι "υποστηρίζει το PDFium XFA" και όχι "πώς εκτελώ σενάρια AcroForm" αλλά συγκεκριμένα τι συμβαίνει σε μια τιμή αφού το SetFocusedFormFieldText αναφέρει επιτυχία. Η σύντομη εκδοχή είναι ότι το AcroForm και το XFA δεν είναι δύο διάλεκτοι του ίδιου μοντέλου φόρμας όσον αφορά τη διαδρομή εγγραφής του PDFium — είναι δύο μοντέλα φόρμας με δύο εντελώς διαφορετικές σχέσεις μεταξύ του τι πληκτρολογεί ένας χρήστης και του τι πράγματι συλλαμβάνει μια αποθήκευση, και η σύγχυση των δύο είναι αυτό που μετατρέπει μια κλήση API μίας γραμμής σε εισιτήριο υποστήριξης τρεις εβδομάδες μετά την έναρξη λειτουργίας ενός πιλοτικού έργου πελάτη. Το άρθρο AcroForm JavaScript δείχνει την κλήση μίας γραμμής και δηλώνει το αποτέλεσμα AcroForm-έναντι-XFA σε ένα σχόλιο κώδικα· αυτό εδώ παραμένει στο ίδιο API και διατρέχει την εσωτερική διαδρομή εγγραφής, την απόδειξη με το πακέτο datasets ότι η εγγραφή XFA ποτέ δεν προσγειώνεται, γιατί το κενό βρίσκεται στο ίδιο το PDFium αντί στη σύνδεση Delphi, και μια λύση επιδιόρθωσης-του-δικού-σας-XML για έγγραφα που χρειάζονται η επεξεργασία να επιβιώσει από μια αποθήκευση

Πώς γράφει το SetFocusedFormFieldText μια τιμή πεδίου;

Το TPdf.SetFocusedFormFieldText λειτουργεί προσομοιώνοντας μια επεξεργασία σε επίπεδο πληκτρολόγησης, όχι σκαλίζοντας μια τιμή απευθείας μέσα στο μοντέλο εγγράφου. Εσωτερικά καλεί το FORM_SelectAllText για να επιλέξει το τρέχον περιεχόμενο του εστιασμένου πεδίου, έπειτα το FORM_ReplaceSelection για να αντικαταστήσει την επιλογή με τη νέα συμβολοσειρά — τις ίδιες δύο λειτουργίες που θα πυροδοτούσε μια επιλογή-όλων-και-πληκτρολόγηση οδηγούμενη από πληκτρολόγιο. Επειδή η εγγραφή περνά μέσα από τη διαδραστική διαδρομή επεξεργασίας κειμένου του PDFium αντί γύρω της, οποιοδήποτε σενάριο πλήκτρου, μορφής, ή υπολογισμού δεμένο στο πεδίο πυροδοτείται ακριβώς όπως θα το έκανε για έναν άνθρωπο που πληκτρολογεί, κάτι που κάνει το API χρήσιμο για προγραμματιστική συμπλήρωση φόρμας σε έναν viewer που κρατά τη JavaScript ζωντανή. Το αντίστοιχο πλευράς ανάγνωσης είναι το FocusedFormFieldText, υποστηριζόμενο από FORM_GetFocusedText, και αντικατοπτρίζει το ίδιο ζωντανό buffer που μόλις έγραψε το SetFocusedFormFieldText

if Pdf.FocusedFormFieldIndex >= 0 then
begin
  if Pdf.SetFocusedFormFieldText('1284.50') then
    Log('Buffer now reads: ' + Pdf.FocusedFormFieldText)
  else
    Log('No field is focused, or it does not accept text');
end
else
  Log('Focus a field first - FocusFormField or a real click');

Γιατί κρατά την τιμή το AcroForm και τη χάνει το XFA;

Τα πεδία κειμένου και combo AcroForm επιμένουν επειδή το δικό του περιβάλλον συμπλήρωσης φόρμας του PDFium δεσμεύει το buffer επεξεργασίας για εσάς: τη στιγμή που το πεδίο χάνει την εστίαση, το buffer γράφεται στην καταχώριση /V του πεδίου, το ίδιο κλειδί που κοιτάζει κάθε συμμορφούμενος αναγνώστης PDF για να γνωρίζει την αποθηκευμένη τιμή ενός πεδίου. Το TPdf.ClearFormFieldFocus — το οποίο καλεί το FORM_ForceToKillFocus από κάτω — εξαναγκάζει εκείνη τη δέσμευση κατ' απαίτηση, οπότε κώδικας που ορίζει μια τιμή προγραμματιστικά δεν χρειάζεται να περιμένει ένα πραγματικό κλικ ποντικιού αλλού στο UI. Αποθηκεύστε αμέσως μετά, και το νέο κείμενο είναι μέρος του γραφήματος αντικειμένων εγγράφου πριν τρέξει ποτέ το TPdf.SaveAs, επειδή το /V είναι μια πραγματική καταχώριση σε ένα πραγματικό λεξικό πεδίου, όχι κάτι βιδωμένο εκ των υστέρων

Pdf.FocusFormField(FieldIndex);
Pdf.SetFocusedFormFieldText('1284.50');
Pdf.ClearFormFieldFocus;              // forces the /V commit now
Pdf.SaveAs('invoice-acroform.pdf');

// Reopen and confirm - this is an AcroForm document, so it holds
Pdf.Active := False;
Pdf.FileName := 'invoice-acroform.pdf';
Pdf.Active := True;
Pdf.FocusFormField(FieldIndex);
Assert(Pdf.FocusedFormFieldValue = '1284.50');   // passes

Πού ζει πράγματι μια επεξεργασία πεδίου XFA;

Τα πεδία XFA δεν έχουν τέτοια καλωδίωση. Το κείμενο που πληκτρολογεί ένας χρήστης προσγειώνεται σε ένα buffer CPWL_Edit που ανήκει στο επίπεδο απόδοσης και αλληλεπίδρασης XFA του PDFium, και εκείνο το επίπεδο δεν έχει καμία διαδρομή κώδικα που αντιγράφει το buffer πίσω στο πακέτο datasets αποθηκευμένο στο PDF. Το TPdf.GetXfaDatasets κάνει το κενό ορατό: καλέστε το πριν και μετά μια επεξεργασία σε ένα πεδίο XFA και τα byte που επιστρέφει είναι πανομοιότυπα, επειδή η μέθοδος διαβάζει το αρχικό πακέτο με το οποίο ανοίχτηκε το έγγραφο, ποτέ τη ζωντανή κατάσταση του widget που μόλις επεξεργαστήκατε. Τίποτα από αυτό δεν είναι σφάλμα προσωρινής μνήμης ή ζήτημα χρονισμού ανανέωσης — το πακέτο datasets στον δίσκο και το buffer επεξεργασίας στη μνήμη είναι απλώς δύο διαφορετικά κομμάτια κατάστασης που το δημόσιο API του PDFium ποτέ δεν συνδέει

var
  Before, After: TBytes;
begin
  Before := Pdf.GetXfaDatasets;
  Pdf.FocusFormField(FieldIndex);
  Pdf.SetFocusedFormFieldText('1284.50');
  After := Pdf.GetXfaDatasets;
  // Before and After are byte-for-byte identical on an XFA document -
  // the edit never touched the packet GetXfaDatasets reads from
end;

Είναι αυτό σφάλμα του PDFium Component ή περιορισμός του PDFium;

Το κομμάτι που λείπει κάθεται στο ίδιο το PDFium, όχι στη σύνδεση Delphi από πάνω του. Το δημόσιο API του PDFium δεν έχει κανένα FPDF_SetXFAPacket για να εγχύσει ένα ενημερωμένο πακέτο και κανένα FPDF_SaveAsXFA για να ζητήσει από τη μηχανή XFA να σειριοποιήσει το τρέχον DOM της πίσω σε XML datasets πριν από μια αποθήκευση. Το FPDF_SaveAsCopy — η εξαγωγή που υποστηρίζει το TPdf.SaveAs — γράφει έξω το γράφημα αντικειμένων εγγράφου που ήδη έχει το PDFium· δεν έχει κανένα γάντζο να ζητήσει από τη μηχανή XFA να αδειάσει πρώτα τη ζωντανή κατάστασή της, επειδή εκείνος ο γάντζος δεν υπάρχει ανάντη. Το PDFium Component δεν μπορεί να προσθέσει συμφιλίωση που το ίδιο το PDFium ποτέ δεν υλοποίησε, και η διανομή ενός σπιτικού σειριοποιητή DOM-σε-XML που μαντεύει την εσωτερική κατάσταση XFA του PDFium θα ήταν χειρότερη από το ειλικρινές κενό: θα φαινόταν να λειτουργεί μέχρι η επόμενη έκδοση PDFium να αλλάξει κάτι που κανείς έξω από το έργο δεν μπορεί να δει

Αυτό το όριο αναδύθηκε κατά τον ίδιο έλεγχο v2.13.2 που έχτισε πρώτα το SetFocusedFormFieldText. Το FORM_ReplaceSelection είχε δεθεί στον πίνακα εισαγωγής DLL για εκδόσεις χωρίς ποτέ να έχει κληθεί από κώδικα Pascal, και η προσθήκη της διαδρομής εγγραφής που τελικά το χρησιμοποίησε είναι αυτό που έκανε το κενό επιμονής αρκετά συγκεκριμένο ώστε να τεκμηριωθεί αντί να παραμείνει θεωρητικό. Ο ίδιος γύρος ελέγχου βρήκε ένα ασύνδετο αλλά συγγενικό στο πνεύμα κενό: η JavaScript AcroForm είχε σιωπηλά απενεργοποιηθεί από την v2.13.0 επειδή η πλατφόρμα JS ήταν καλωδιωμένη μόνο μέσα στον κλάδο αρχικοποίησης XFA, οπότε συνηθισμένα έγγραφα AcroForm με app.alert ή υπολογισμένα πεδία ποτέ δεν έπαιρναν καθόλου μηχανή σεναρίου. Εκείνο ήταν διορθώσιμο — η επέκταση της πλατφόρμας JS σε κάθε έγγραφο ανεξάρτητα από XFA — και κυκλοφόρησε στην ίδια έκδοση· το κενό επιμονής που καλύπτεται εδώ δεν ήταν διορθώσιμο, για τους παραπάνω λόγους. Η διόρθωση JavaScript και τα συμβάντα βέτο host γύρω της καλύπτονται στο εκτέλεση JavaScript AcroForm με το PDFium Component

Τι πρέπει να κάνετε γι' αυτό σε Delphi;

Για έγγραφα AcroForm, η διόρθωση δεν είναι τίποτα περισσότερο από καλή συνήθεια: καλέστε το ClearFormFieldFocus (ή αλλιώς μετακινήστε την εστίαση) πριν το SaveAs όποτε μια τιμή ορίστηκε προγραμματιστικά, αντί να υποθέτετε ότι μια μεταγενέστερη αλληλεπίδραση UI θα πυροδοτήσει τη δέσμευση για εσάς. Για ένα έγγραφο που μπορεί να είναι είτε AcroForm είτε XFA — που είναι η συνηθισμένη περίπτωση σε έναν viewer γενικού σκοπού — ελέγξτε το FormType ή το boolean XFA πριν υποσχεθείτε σε έναν καλούντα ότι μια αποθήκευση θα κολλήσει, και διαβάστε το εντοπισμός φορμών XFA και εξαγωγή πακέτων XFA για το πλήρες σύνολο ελέγχων, συμπεριλαμβανομένης της περίπτωσης XFAF όπου περιεχόμενο XFA στρώνεται πάνω από κατά τα άλλα συνηθισμένα widgets AcroForm που πράγματι τιμούν το /V

Για μια γνήσια δυναμική φόρμα XFA όπου οι επεξεργασμένες τιμές πρέπει να επιβιώσουν από μια αποθήκευση, το διαδραστικό buffer επεξεργασίας δεν είναι καθόλου το σωστό εργαλείο. Η ανθεκτική διαδρομή είναι να αντιμετωπίσετε το GetXfaDatasets ως τη βάση σας, όχι το αποτέλεσμά σας: διαβάστε το μία φορά όταν ανοίγει το έγγραφο, κρατήστε το δικό σας αρχείο του τι άλλαξε ο χρήστης πεδίο προς πεδίο — ακριβώς τις τιμές που ήδη έχει το UI σας, αφού το PDFium δεν θα σας τις δώσει πίσω εκ των υστέρων — μπαλώστε αυτές στη βάση XML οι ίδιοι, και οδηγήστε τη δική σας έξοδο. Μια εγγραφή που περνά μέσα από XML που ελέγχει ο δικός σας κώδικας επιβιώνει από μια αποθήκευση που ένα buffer CPWL_Edit ποτέ δεν θα μπορούσε

function ExportEditedXfaValue(Pdf: TPdf; const FieldPath,
  NewValue: string): TBytes;
var
  DatasetsXml: string;
begin
  // GetXfaDatasets ships with PDFium Component; PatchXmlNode below is
  // your own helper over your own XML library, nothing PDFium provides
  DatasetsXml := TEncoding.UTF8.GetString(Pdf.GetXfaDatasets);
  DatasetsXml := PatchXmlNode(DatasetsXml, FieldPath, NewValue);
  Result := TEncoding.UTF8.GetBytes(DatasetsXml);
end;

Εντοπίζοντας το κενό πριν το κάνει ένας πελάτης

Το TPdf.SaveAs επιστρέφει True είτε επιβίωσε είτε όχι μια τιμή πεδίου XFA, επειδή από την οπτική γωνία του PDFium η αποθήκευση γνησίως πέτυχε — έγραψε κάθε byte που της ζητήθηκε να γράψει. Αυτό το κάνει ακριβώς το είδος ελαττώματος που περνά από ένα smoke test και φτάνει σε έναν πελάτη: τίποτα δεν εγείρει εξαίρεση, τίποτα δεν καταγράφεται, το αρχείο ανοίγει μια χαρά, μόνο η συγκεκριμένη τιμή είναι λάθος. Μια δοκιμή πήγαινε-έλα που πράγματι ξανανοίγει το αποθηκευμένο αρχείο και συγκρίνει την τιμή του πεδίου — ή συγκρίνει το GetXfaDatasets πριν και μετά, σύμφωνα με το προηγούμενο παράδειγμα — ανήκει στη σουίτα παλινδρόμησης για οποιονδήποτε viewer που επιτρέπει στους χρήστες να επεξεργάζονται περιεχόμενο XFA, όχι μόνο στις διαδρομές AcroForm που τυχαίνει να λειτουργούν εξ ορισμού

Τίποτα από αυτά δεν είναι ελάττωμα προς καταγγελία έναντι του PDFium Component όσο ένα όριο γύρω από το οποίο πρέπει να σχεδιάσετε: το SetFocusedFormFieldText κάνει ακριβώς αυτό που λέει το όνομά του και για τα δύο μοντέλα φόρμας, και η διαφορά στο αποτέλεσμα ανάγεται καθαρά σε αυτό με το οποίο συνδέει κάθε ένα από τα AcroForm και XFA εκείνο το buffer στην πλευρά του PDFium. Το API, τα πρωτόγονα εστίασης και αποθήκευσης, και οι αναγνώστες πακέτων που αναφέρονται εδώ είναι μέρος του εξαρτήματος PDFium για Delphi και C++Builder