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

Ορισμός τιμών πεδίων φόρμας σε φορτωμένο PDF με Delphi

Το HotPDF Delphi Component γεμίζει υπάρχον πεδίο AcroForm σε φορτωμένο PDF μέσω THotPDF.SetFormFieldValue, με διεύθυνση είτε με δείκτη πεδίου zero-based είτε με πλήρως προσδιορισμένο όνομα πεδίου. Το γράψιμο της νέας εγγραφής /V είναι το εύκολο κομμάτι· αυτό που κάνει την κλήση αξιόπιστη σε φόρμες του πραγματικού κόσμου είναι ότι η ίδια μέθοδος κρατά και τρία κομμάτια κατάστασης συνεπή που είναι αόρατα μέχρι να πάνε στραβά: την αποκωδικοποιημένη ταυτότητα του πεδίου ώστε ένα μη-ASCII όνομα να μπορεί καθόλου να βρεθεί, την κατάσταση appearance /AS στα widgets checkbox και radio, και τον πίνακα δείκτων επιλογής /I στα πεδία choice. Το ορατό appearance stream είναι ξεχωριστό, ρητό βήμα μέσω EnsureLoadedFieldAppearanceStream

Το σενάριο είναι το πεζό: ένας πελάτης σου στέλνει τη δική του φόρμα, μια φορολογική δήλωση, μια απαίτηση ασφάλισης, μια παραγγελία που έφτιαξε κάποιος στο Acrobat χρόνια πριν, και η εφαρμογή σου Delphi πρέπει να τη γεμίσει από βάση δεδομένων και να παραδώσει πίσω αρχείο που ανοίγει σωστά παντού. Δεν έχεις κανέναν έλεγχο στο πώς γράφτηκε η φόρμα. Τα ονόματα πεδίων μπορεί να είναι κωδικοποιημένα UTF-16, οι τιμές εξαγωγής checkbox μπορεί να είναι 2 αντί για Yes, και τα combo boxes μπορεί να χρησιμοποιούν ζεύγη επιλογών [export display]. Καθεμία από εκείνες τις λεπτομέρειες έχει έναν κανόνα στο ISO 32000-1, και κάθε κανόνας είναι κάτι που το SetFormFieldValue πλέον χειρίζεται για σένα. Αυτό το άρθρο αφορά το τι κάνει, γιατί, και πού σταματά. Για το αδελφό πρόβλημα της δημιουργίας πεδίων που δεν υπάρχουν ακόμα, δες το προσθήκη πεδίων AcroForm σε φορτωμένο PDF στο Delphi

Γιατί το SetFormFieldValue αποτυγχάνει να βρει πεδίο με μη-ASCII όνομα;

Πριν το v2.752.1 η απάντηση ήταν η κωδικοποίηση: το πεδίο ζούσε στο αρχείο κάτω από hexadecimal όνομα UTF-16BE, και η cache ονομάτων αποθήκευε τη hex ορθογραφία αντί για το κείμενο. Το ISO 32000-1 §12.7.3.1 ορίζει το μερικό όνομα πεδίου /T ως text string, και το §7.9.2.2 λέει ότι ένα text string μπορεί να είναι UTF-16BE με οδηγό FE FF byte order mark. Τα εργαλεία συγγραφής σειριοποιούν ρουτίνα τέτοια ονόματα ως hex strings σύμφωνα με το §7.3.4.3, οπότε ένα πεδίο που λέγεται Straße φτάνει ως <FEFF005300740072006100DF0065>. Μέσα στο HotPDF, το THPDFStringObject.Value κρατά το ακατέργαστο hexadecimal κείμενο όποτε το IsHexadecimal είναι set, που είναι ακριβώς ό,τι θέλεις για μια χωρίς απώλειες διαδρομή με επιστροφή του πρωτότυπου dictionary και ακριβώς ό,τι δεν θέλεις ως κλειδί αναζήτησης. Το HPDFLoadedFormTextName χωρίζει τις δύο έγνοιες. Όταν χτίζεται η cache σχέσεων, κάθε τιμή /T περνάει μέσα από αυτό: αν το string object είναι hexadecimal, το HPDFHexToBytes επαναφέρει τη σειρά bytes· αν τα bytes ξεκινούν FE FF και έχουν άρτιο μήκος, το payload αποκωδικοποιείται ως UTF-16BE και ξανακωδικοποιείται ως UTF-8· το αποτέλεσμα τότε ενώνονται με το γονικό του όνομα με τελεία για να σχηματίσουν το πλήρως προσδιορισμένο όνομα που περιγράφει το §12.7.3.1, οπότε ένα kid που λέγεται City κάτω από γονέα Address καταχωρείται ως Address.City. Το κλειδί cache κανονικοποιείται σε πεζά, που κάνει το SetFormFieldValue('address.city', ...) να πετυχαίνει επίσης· είναι ευκολία πέρα από το standard, αφού η προδιαγραφή μεταχειρίζεται τα ονόματα ως ευαίσθητα σε πεζά/κεφαλαία. Καθοριστικά, αλλάζει μόνο το κλειδί cache. Το object /T στο dictionary του πεδίου κρατά τη hexadecimal κωδικοποίησή του, οπότε η αποθήκευση του εγγράφου δεν ξαναγράφει την ταυτότητα πεδίου που απλώς γέμισες

Πώς το HotPDF επιλύει μη-ASCII ονόματα AcroForm: το HPDFHexToBytes επαναφέρει το payload UTF-16BE πίσω από hexadecimal string /T, το byte order mark FE FF αποκωδικοποιείται και ξανακωδικοποιείται ως UTF-8, και το προσδιορισμένο όνομα ενώνει τον γονέα του ώστε Applicant.FullName και πεδίο που λέγεται Straße να προσγειώνονται και τα δύο στην cache αναζήτησης
Αλλάζει μόνο το κλειδί cache: το dictionary του πεδίου κρατά τη hexadecimal κωδικοποίησή του, οι αναζητήσεις κανονικοποιούνται σε πεζά ως ευκολία πέρα από το standard, και η αποθήκευση του εγγράφου δεν ξαναγράφει ποτέ την ταυτότητα πεδίου που απλώς γέμισες
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // Τα qualified ονόματα αποκωδικοποιούνται από strings /T UTF-16BE και
    // ενώνονται με τελείες, οπότε nested και μη-ASCII ονόματα επιλύονται
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // Τιμές που δεν είναι Latin-1 ταξιδεύουν ως hex UTF-16BE με πρόθεμα FEFF
    // και γράφονται ως hexadecimal string PDF
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

Τι γράφει πραγματικά το SetFormFieldValue;

Και τα δύο overloads τρέχουν τα ίδια πέντε βήματα: εντοπισμός του dictionary πεδίου, γράψιμο /V μέσω HPDFSetDictFormValue, συμφιλίωση δεικτών επιλογής choice, σήμανση του dictionary βρώμικου, συμφιλίωση καταστάσεων appearance κουμπιών, και τελικά καταγραφή του δείκτη πεδίου μέσω NoteLoadedFormFieldDirty. Εκείνο το τελευταίο βήμα μετράει αν η φόρμα κουβαλάει scripts υπολογισμού, επειδή το βρώμικο σύνολο είναι αυτό που καταναλώνει το overload RecalculateLoadedFormFieldsIncremental χωρίς παραμέτρους για να ξανατρέξει μόνο τους υπολογισμούς που διαβάζουν διαμεταχωρημένα αλλαγμένο πεδίο. Το HPDFSetDictFormValue το ίδιο είναι προσεκτικό με τον τύπο object που αντικαθιστά. Αν το υπάρχον /V είναι name object, που είναι ό,τι χρησιμοποιούν τα πεδία checkbox και radio για την τιμή εξαγωγής τους, η νέα τιμή γράφεται ως name, ποτέ ως string, επειδή τα PDF names είναι ASCII-only εκ κατασκευής. Αλλιώς γράφει string object και εξετάζει την τιμή που πέρασες: string που ξεκινά FEFF, έχει άρτιο μήκος, και αποτελείται μόνο από hex ψηφία μεταχειρίζεται ως τη μορφή μεταφοράς UTF-16BE από το §7.9.2.2 και αποθηκεύεται με set το IsHexadecimal, ώστε να σειριοποιείται ως <FEFF...> και όχι ως literal (FEFF...). Εκείνος είναι ο μηχανισμός στον οποίο στηρίζεται η γραμμή City παραπάνω· κάθε άλλο string αποθηκεύεται ως literal string με τα bytes που του έδωσες, οπότε για σκέτο λατινικό κείμενο περνάς σκέτο κείμενο

Γιατί ένα checkbox κρατά το παλιό του τικ αφού αλλάξει η τιμή;

Επειδή για πεδίο κουμπιού η τιμή από μόνη της δεν αποφασίζει τι σχεδιάζεται. Το ISO 32000-1 §12.7.4.2.3 ορίζει ότι ένα widget checkbox κουβαλάει κατάσταση appearance /AS που κατονομάζει ποιο stream στο /AP /N εμφανίζεται τώρα, και οι viewers ζωγραφίζουν από το /AS, όχι από το /V. Αν αλλάξεις το /V σε Yes αλλά αφήσεις το /AS στο Off, το αρχείο είναι εσωτερικά αντιφατικό, και το flattening θα ψήσει πρόθυμα την παρωχημένη ανοίγματος-υπογραφής εμφάνιση στη σελίδα ενώ τα δεδομένα φόρμας λένε τικαρισμένο. Το ReconcileLoadedButtonAppearanceStates υπάρχει για να κλείσει εκείνο το χάσμα: για πεδίο του οποίου το /FT είναι Btn, επισκέπτεται το ίδιο το dictionary πεδίου και κάθε εγγραφή στον πίνακα /Kids του, διαβάζει το όνομα on-state από το /AP /N, και ξαναγράφει το /AS σε εκείνο το όνομα όταν ταιριάζει την τιμή πεδίου ή σε Off όταν δεν ταιριάζει

Γιατί ένα checkbox HotPDF κρατά το παλιό του τικ όταν αλλάζει μόνο το /V: οι viewers ζωγραφίζουν από την κατάσταση appearance /AS μέσα στο /AP /N, οπότε το ReconcileLoadedButtonAppearanceStates επισκέπτεται το πεδίο και κάθε kid, διαβάζει το όνομα on-state ως το πρώτο κλειδί εκτός Off, και ξαναγράφει το /AS σε ταιριάσμα ή σε Off αλλιώς
Οι ομάδες radio συγκρίνουν κάθε kid απέναντι στην τιμή γονέα που ανακτά το InheritedButtonValue περπατώντας την αλυσίδα /Parent, οπότε το πέταγμα της ομάδας σε μία τιμή εξαγωγής ανάβει ακριβώς εκείνο το widget και σβήνει κάθε αδελφό

Δύο λεπτομέρειες από πραγματικές φόρμες διαμόρφωσαν το fix v2.752.3. Πρώτον, σε ένα κανονικό dictionary appearance επιτρέπεται να περιέχει μόνο το on state· το §12.7.4.2.3 κατονομάζει το off appearance Off αλλά τα εργαλεία συγγραφής συχνά παραλείπουν το stream του και αφήνουν τον viewer να μη σχεδιάσει τίποτα. Ο προηγούμενος κώδικας τα παρατούσε όταν το dictionary κρατούσε λιγότερες από δύο εγγραφές, οπότε εκείνα τα checkbox μονού κατάστασης κρατούσαν σιωπηλά το παλιό τους τικ. Ο έλεγχος είναι πλέον απλώς ότι το dictionary δεν είναι κενό, και το όνομα on-state παίρνεται ως το πρώτο κλειδί που δεν είναι Off. Δεύτερον, το όνομα on-state είναι ό,τι διάλεξε ο συγγραφέας. Πραγματικές φόρμες χρησιμοποιούν 2, Yes, On, ή τοπικοποιημένη λέξη, οπότε η σύγκριση γίνεται απέναντι στο πραγματικό κλειδί, χωρίς ευαισθησία πεζών/κεφαλαίων, ποτέ απέναντι σε hard-codeαρισμένο Yes. Τα radio buttons προσθέτουν μία ακόμα πτυχή, περιγεγραμμένη στο §12.7.4.2.4: η επιλογή ζει στο /V του γονικού πεδίου, ενώ τα μεμονωμένα kids κατέχουν τα widgets και συνήθως δεν έχουν δικό τους /V. Ο εμφωλευμένος helper InheritedButtonValue περπατάει λοιπόν προς τα πάνω την αλυσίδα /Parent, μέχρι 64 επίπεδα, μέχρι να βρει μη κενή τιμή, ώστε κάθε kid να συγκρίνεται απέναντι στην τιμή της ομάδας στην οποία ανήκει. Το πέταγμα του γονέα στην τιμή εξαγωγής ενός kid ανάβει ακριβώς εκείνο το kid και σβήνει κάθε αδελφό

// Checkbox: η τιμή εξαγωγής πρέπει να ταιριάζει το κλειδί on-state στο /AP /N
// (συχνά 'Yes', αλλά πραγματικές φόρμες χρησιμοποιούν '2', 'On', ή οτιδήποτε άλλο)
Pdf.SetFormFieldValue('Consent', 'Yes');

// Radio group: το /V γράφεται στον γονέα· κάθε kid widget παίρνει
// /AS ορισμένο στο δικό του όνομα εξαγωγής ή σε Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// Καθάρισμα checkbox: κάθε τιμή που δεν ταιριάζει κανένα on state δίνει /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

Πεδία choice: κρατώντας το /I συγχρονισμένο με το /V

Για combo box ή list box, το /V δεν είναι το μόνο μέρος όπου καταγράφεται επιλογή. Ο Πίνακας 231 στο §12.7.4.4 ορίζει το /I ως πίνακα δεικτών zero-based μέσα στο /Opt που ταυτοποιεί τα επιλεγμένα στοιχεία, και ένας viewer που βρίσκει το /I να δείχνει την επιλογή 0 ενώ το /V κατονομάζει την επιλογή 3 μπορεί να τονίσει τη λάθος γραμμή. Από το v2.754.1, το HPDFReconcileChoiceSelection τρέχει μέσα σε κάθε κλήση SetFormFieldValue και, όταν το κληρονομούμενο /FT είναι Ch, ξαναχτίζει το /I από τη νέα τιμή. Η σειρά των λειτουργιών είναι σκόπιμη. Η τοπική εγγραφή /I διαγράφεται πρώτη, χωρίς να αγγίξει τα περιεχόμενά της: αν ο παλιός πίνακας ήταν indirect object κοινόχρηστος με άλλο πεδίο, η μετάλλαξή του στη θέση του θα κατέστρεφε την επιλογή του άλλου πεδίου, οπότε η ρουτίνα πετάει την αναφορά και δημιουργεί φρέσκο direct πίνακα αντί αυτού. Έπειτα επιλύει το /Opt μέσω της αλυσίδας /Parent, αφού οι επιλογές choice μπορεί να κληρονομούνται, και σαρώνει τις εγγραφές. Γυμνή επιλογή string συγκρίνεται ευθέως· ζεύγος [export display] συγκρίνεται στο στοιχείο εξαγωγής του, και ζεύγος με λιγότερα από δύο στοιχεία παραλείπεται. Και οι δύο πλευρές περνάνε από το HPDFLoadedFormTextName, οπότε μια hex επιλογή UTF-16 ταιριάζει hex τιμή UTF-16 χωρίς εσύ να τις γράψεις πανομοιότυπα. Στο πρώτο ταιριάσμα γράφεται /I ενός στοιχείου και η σάρωση σταματά· κλιμακή τιμή αντικαθιστά πάντα οποιαδήποτε προηγούμενη πολλαπλή επιλογή, ανεξαρτήτως της σημαίας MultiSelect

Πώς το HotPDF κρατά συνεπές πεδίο choice: το HPDFReconcileChoiceSelection διαγράφει τον τοπικό πίνακα /I πριν τον αγγίξει, επιλύει το /Opt μέσω της αλυσίδας /Parent, συγκρίνει το μισό εξαγωγής κάθε επιλογής μέσω HPDFLoadedFormTextName, γράφει /I ενός στοιχείου στο πρώτο ταιριάσμα και δεν γράφει τίποτα όταν επεξεργάσιμη τιμή combo δεν έχει δείκτη
Γυμνή επιλογή string συγκρίνεται ευθέως και ζεύγος εξαγωγής-εμφάνισης στο στοιχείο εξαγωγής του, ενώ τιμή εκτός /Opt σωστά αφήνει κανέναν δείκτη — παρωχημένο /I που δείχνει τη λάθος γραμμή θα ήταν χειρότερο από κανένα

Όταν τίποτα δεν ταιριάζει, δεν γράφεται καθόλου /I. Είναι το σωστό αποτέλεσμα για επεξεργάσιμο combo box, όπου το §12.7.4.4 επιτρέπει στον χρήστη να πληκτρολογήσει τιμή έξω από τη λίστα επιλογών· μια τέτοια τιμή δεν έχει δείκτη, και παρωχημένος δείκτης θα ήταν χειρότερος από κανέναν. Είναι επίσης ό,τι παίρνεις αν περάσεις ετικέτα εμφάνισης αντί για τιμή εξαγωγής σε λίστα ζευγών επιλογών, οπότε όταν ένα combo box αρνείται να δείξει την επιλογή σου, έλεγξε ποιο μισό του ζεύγους παρέδωσες

// Το /Opt είναι [[US United States] [CA Canada] [MX Mexico]]:
// ταίριασμα πάνω στην τιμή εξαγωγής, και το /I γίνεται [1]
Pdf.SetFormFieldValue('Country', 'CA');

// Επεξεργάσιμο combo με τιμή εκτός /Opt: το /V γράφεται,
// το /I αφαιρείται, και κανένας δείκτης δεν κατασκευάζεται
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

Τιμή και appearance είναι δύο ξεχωριστές λειτουργίες

Το SetFormFieldValue δεν αγγίζει ποτέ το appearance stream πεδίου text ή choice. Μετά την κλήση, το /V κρατά το νέο κείμενο ενώ το /AP /N ζωγραφίζει ακόμα το παλιό, και ποιο από τα δύο δείχνει ένας viewer εξαρτάται από το αν το dictionary AcroForm κουβαλάει /NeedAppearances true κατά το §12.7.3.3 και το αν ο viewer το τηρεί. Αν θέλεις το αρχείο να αποδίδει τη νέα τιμή σε κάθε reader, συμπεριλαμβανομένων flatteners και γεννηττών thumbnail που αγνοούν τη σημαία, κάλεσε EnsureLoadedFieldAppearanceStream με τον δείκτη πεδίου. Χτίζει Form XObject από το κληρονομούμενο string /DA, το quadding /Q, τη διάταξη comb /MaxLen και την τιμή, επιλύει την κατονομαζόμενη γραμματοσειρά μέσω των πόρων /DR του AcroForm ώστε font Type0 να κρατά το δικό του descendant font αντί να υποβαθμιστεί σε Helvetica, και επιστρέφει True όταν τουλάχιστον ένα widget έλαβε stream. Το overload με όνομα του SetFormFieldValue δεν σου δίνει δείκτη πίσω, οπότε φέρε έναν μέσω GetFormField, που επιστρέφει THPDFLoadedFormField που κατέχεις και πρέπει να απελευθερώσεις. Η regression suite της αλλαγής v2.752.1 είναι ρητή για αυτόν τον διαχωρισμό: θέτει τιμή, καλεί EnsureLoadedFieldAppearanceStream, και μετά αποδίδει τη σελίδα και ελέγχει ότι τα pixels μέσα στο ορθογώνιο widget άλλαξαν ενώ τα pixels έξω από αυτό όχι. Η επαλήθευση ότι το /V άλλαξε δεν αποδεικνύει τίποτα για το τι θα δει ένας χρήστης

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // Ζωγράφισε τη νέα τιμή μέσα στο /AP ώστε viewers που αγνοούν
    // το /NeedAppearances να την δείχνουν ακόμα
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

Όρια που αξίζει να ξέρεις πριν χτίσεις πάνω σε αυτό

Το ReconcileLoadedButtonAppearanceStates ελέγχει το τοπικό /FT του dictionary που διευθύνθηκες, οπότε δρα στον γονέα radio ή σε checkbox που κουβαλάει δικό του /FT· kid widget που διευθύνθηκες μόνο του, με /FT μόνο στον γονέα του, δεν συμφιλιώνεται μέσω εκείνης της διαδρομής. Το HPDFReconcileChoiceSelection χειρίζεται μία κλιμακή τιμή και γράφει το πολύ έναν δείκτη· list boxes πολλαπλής επιλογής με αρκετές διαλεγμένες εγγραφές είναι έξω από ό,τι μοντελοποιεί το SetFormFieldValue. Καμία από τις δύο ρουτίνες δεν επικυρώνει την τιμή που περνάς απέναντι στο /Opt ή στα κλειδιά on-state, οπότε ένα τυπογραφικό λάθος παράγει checkbox Off ή combo χωρίς δείκτη αντί για εξαίρεση. Και το GetFormFieldValue επιστρέφει το αποθηκευμένο κείμενο /V όπως κάθεται στο dictionary, που για hex-κωδικοποιημένη τιμή σημαίνει τη hexadecimal ορθογραφία, όχι το αποκωδικοποιημένο κείμενο

Μόλις οι τιμές μπουν και οι εμφανίσεις ζωγραφιστούν, τα δύο φυσικά επόμενα βήματα κάθονται στις δύο πλευρές αυτής της λειτουργίας. Η ανταλλαγή δεδομένων πεδίων με εξωτερικά συστήματα μαζικά, αντί για μία κλήση SetFormFieldValue τη φορά, είναι ό,τι καλύπτει το XFDF import και export στο Delphi. Και όταν η γεμισμένη φόρμα είναι τελική και δεν πρέπει πια να είναι επεξεργάσιμη, το flattening πεδίων AcroForm και XFA στο Delphi ψήνει ακριβώς τις καταστάσεις /AS και τα appearance streams που περιγράφονται εδώ σε στατικό περιεχόμενο σελίδας, γι' αυτό το να τα βάλεις συνεπή πριν το flattening δεν είναι προαιρετικό

Το API επεξεργασίας loaded φορμών σε αυτό το άρθρο, συμπεριλαμβανομένων των SetFormFieldValue, EnsureLoadedFieldAppearanceStream και του incremental γραφήματος επανυπολογισμού, κυκλοφορεί ως μέρος του HotPDF Delphi Component για Delphi και C++Builder