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

Bug flatten checkbox PDF: τιμή πεδίου έναντι widget

Τα checkboxes και τα radio buttons γίνονται flatten ως μη επιλεγμένα γιατί η κατάσταση εμφάνισης /AS δεν είχε ποτέ συγχρονιστεί με την τιμή πεδίου /V. Το PDFium Component, το VCL και LCL στοιχείο βασισμένο σε PDFium για Delphi, C++Builder, και Lazarus, τώρα διαβάζει αυτή την τιμή με το FPDFAnnot_GetFormFieldValue, που επιλύει το γονικό field dictionary αντί για το widget annotation

Η αναφορά bug που οδήγησε εδώ είναι το είδος που δυσπιστείς αρχικά. Ένας πελάτης κάνει flatten μια υπογεγραμμένη φόρμα συναίνεσης, ανοίγει το αποτέλεσμα, και κάθε checkbox είναι κενό. Άνοιξε το αρχείο πηγής στο Acrobat και τα κουτάκια είναι ορατά τσεκαρισμένα. Διάβασε το αρχείο πηγής πίσω μέσω του ίδιου στοιχείου και οι τιμές πεδίων είναι σωστές. Μόνο η flatten έξοδος τις χάνει, και μόνο για checkboxes και radio buttons: τα πεδία κειμένου στην ίδια σελίδα βγαίνουν εντάξει

Γιατί τα checkboxes είναι μη επιλεγμένα μετά το flattening;

Γιατί το flattening ποτέ δεν κοιτάζει το /V. Το FPDFPage_Flatten ψήνει το appearance stream του widget μέσα στο περιεχόμενο σελίδας, και η εμφάνιση που επιλέγει είναι αυτή που ονομάζεται από το /AS. Αν το /AS ακόμα λέει /Off ενώ η τιμή πεδίου λέει ότι το κουτάκι είναι ενεργό, το flattening ψήνει πιστά την εμφάνιση off. Η τιμή δεν χάθηκε ποτέ· απλά δεν συμβουλεύτηκε ποτέ

Το ISO 32000-1 §12.5.5 ορίζει το dictionary εμφάνισης /AP με τρεις πιθανές καταχωρήσεις, /N, /R, και /D. Για ένα check box ή radio button η καταχώρηση /N δεν είναι stream αλλά υπο-dictionary του οποίου τα κλειδιά είναι ονόματα κατάστασης εμφάνισης, και το §12.5.2 κάνει το /AS τον απαιτούμενο επιλογέα όταν το /N είναι υπο-dictionary. Οπότε ένα checkbox φέρει δύο προκατασκευασμένες εμφανίσεις και έναν δείκτη. Κάνε λάθος τον δείκτη και η απόδοση είναι λάθος με τρόπο που καμία ποσότητα σωστού /V δεν θα διορθώσει. Αυτός είναι επίσης ο λόγος που ο τρόπος αποτυχίας διαφέρει από τα πεδία κειμένου, που δεν έχουν καμία προκατασκευασμένη εμφάνιση να επιλέξουν καθόλου: το /N ενός πεδίου κειμένου είναι ένα μοναδικό stream που πρέπει να αναγεννηθεί από την αρχή αφού αλλάξει η τιμή, οπότε το GenerateFormAppearances χειρίζεται τις δύο περιπτώσεις μέσω εντελώς ξεχωριστών μονοπατιών κώδικα και μόνο το μονοπάτι κουμπιών ήταν σπασμένο

Πού πραγματικά ζει η τιμή του checkbox;

Στο field dictionary, όχι στο widget. Το ISO 32000-1 §12.7.5.2 περιγράφει τα check boxes και τα radio buttons ως πεδία κουμπιού των οποίων το /V είναι αντικείμενο ονόματος που ονομάζει την τρέχουσα κατάσταση εμφάνισης, και το §12.7.3.1 τοποθετεί το /V ανάμεσα στις καταχωρήσεις κοινές σε όλα τα field dictionaries. Το widget annotation που ορίζεται στο §12.5.6.19 συνεισφέρει το /AS και το /AP. Τίποτα στο πρότυπο δεν υποχρεώνει ένα widget να φέρει /V

// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off

{ What the two objects look like when the field has several widgets:

  12 0 obj                          % field dictionary (the parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (a kid)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

Το FPDFAnnot_GetStringValue δεν είναι ελαττωματικό. Το συμβόλαιό του είναι ακριβώς αυτό που λέει το όνομά του: φέρε μια καταχώρηση string από το annotation dictionary που του έδωσες. Το να το ρωτήσεις για /V στο αντικείμενο 13 επιστρέφει τίποτα γιατί το αντικείμενο 13 γνησίως δεν έχει /V. Το ελάττωμα ήταν στον καλούντα, που υπέθεσε ένα επίπεδο μοντέλο αντικειμένων που το ISO 32000-1 ποτέ δεν υποσχέθηκε

Πότε μοιράζονται πεδίο και widget ένα dictionary;

Όποτε ένα πεδίο έχει ακριβώς ένα widget. Το §12.5.6.19 επιτρέπει το field dictionary και το μοναδικό widget annotation του να συγχωνευτούν σε ένα αντικείμενο, και τα περισσότερα εργαλεία δημιουργίας παίρνουν αυτή τη συντόμευση. Σε ένα συγχωνευμένο αντικείμενο τα /FT, /T, /V, /AS, και /AP κάθονται όλα πλάι-πλάι, οπότε μια ανάγνωση /V σε επίπεδο widget πετυχαίνει και ολόκληρο το bug παραμένει αόρατο

Τη στιγμή που ένα πεδίο κατέχει δύο ή περισσότερα widgets η συγχώνευση είναι αδύνατη, και το §12.7.3.1 απαιτεί τα widgets να γίνουν /Kids ενός ξεχωριστού field dictionary. Κάθε ομάδα radio βρίσκεται σε αυτό το σχήμα εκ κατασκευής. Το ίδιο και τα checkboxes συναίνεσης που επαναλαμβάνονται σε κεφαλίδα και υποσέλιδο, και κάθε πεδίο που ένα εργαλείο δημιουργίας έχει αντιγράψει σε δεύτερη σελίδα. Αυτή είναι ολόκληρη η εξήγηση για το γιατί το ελάττωμα επέζησε μιας σουίτας regression: το σώμα δοκιμών ήταν γεμάτο φόρμες μοναδικού widget και τα αρχεία πελάτη δεν ήταν. Αν διατρέχεις τα widgets μόνος σου αντί να βασίζεσαι στο στοιχείο, η ίδια ασυμμετρία εμφανίζεται στη σειρά απαρίθμησης, και οι σημειώσεις για την πλοήγηση πεδίων φόρμας PDF με το PDFium Component καλύπτουν πώς μια διάτρεξη annotation σε επίπεδο σελίδας σχετίζεται με το δέντρο πεδίων σε επίπεδο εγγράφου

Ανάγνωση της τιμής όπως το εννοεί το PDFium

Το FPDFAnnot_GetFormFieldValue είναι το σωστό API, και ήταν δεμένο στο στοιχείο εδώ και καιρό χωρίς το μονοπάτι checkbox να το χρησιμοποιεί. Παίρνει τη λαβή φόρμας μαζί με το annotation, που είναι το σήμα που μετράει: με το περιβάλλον form-fill διαθέσιμο, το PDFium επιλύει το annotation στο form control του και διαβάζει την τιμή από το αντικείμενο πεδίου, οπότε επιστρέφει τη σωστή απάντηση τόσο για συγχωνευμένες όσο και για διαχωρισμένες διατάξεις

FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP is prebuilt per state; only /AS has to be synchronised with /V.
    // FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
    // which is where ISO 32000-1 12.7.5.2 keeps the value.
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

Δύο λεπτομέρειες σε αυτό το απόσπασμα είναι εύκολο να μπερδέψεις. Το επιστρεφόμενο μήκος είναι μια μέτρηση bytes για κείμενο UTF-16 συμπεριλαμβανομένου του τερματιστή, οπότε ο αριθμός χαρακτήρων είναι buflen div 2 - 1 και μια τιμή 2 σημαίνει κενό string. Ο φύλακας buflen >= 4 επομένως σημαίνει τουλάχιστον έναν πραγματικό χαρακτήρα, που είναι αυτό που εμποδίζει ένα πεδίο χωρίς καθόλου /V από το να έχει το /AS του αντικατασταθεί με κενό όνομα

Σε τι πραγματικά συμφωνούν το /AS και το /AP /N

Συμφωνούν σε ένα όνομα, και το όνομα επιλέγεται από όποιον παρήγαγε το αρχείο. Το §12.7.5.2 απαιτεί η κατάσταση off να ονομάζεται /Off, και αφήνει την κατάσταση on εντελώς στον παραγωγό. Το /Yes είναι σύμβαση, όχι κανόνας. Το Acrobat γράφει /Yes, αλλά αρκετές γεννήτριες γράφουν /On, /1, /Choice1, ή μια τοπικοποιημένη λέξη, και μια ομάδα radio κανονικά δίνει σε κάθε παιδί ένα διακριτό όνομα κατάστασης on ώστε η ομάδα να μπορεί να εκφράσει ποιο κουμπί είναι επιλεγμένο. Αυτός είναι ακριβώς ο λόγος που η αντιγραφή του /V αυτούσιου στο /AS είναι η σωστή λειτουργία και όχι ένα κόλπο: για ένα επιλεγμένο control το PDFium αναφέρει το όνομα κατάστασης on που ορίζει το ίδιο το αρχείο, και για ένα μη επιλεγμένο αναφέρει Off, οπότε η τιμή που γράφεις στο /AS είναι εγγυημένο ότι είναι ένα κλειδί που υπάρχει σε εκείνο το υπο-dictionary widget /AP /N. Το hard-coding του /Yes θα δούλευε σε έξοδο Acrobat και θα έσπαγε σιωπηρά παντού αλλού

Σειρά λειτουργιών, και πού χρειάζεται ακόμα προσοχή

Η ακολουθία είναι σταθερή και ανελέητη: ενεργοποίησε το form fill, ανάθεσε τιμές, αναγέννησε εμφανίσεις, κάνε flatten, μετά αποθήκευσε. Παράλειψε το βήμα αναγέννησης και το FPDFPage_Flatten βρίσκει κενά ή μπαγιάτικα appearance streams και τα ψήνει χωρίς παράπονο, που είναι μια σιωπηρή απώλεια δεδομένων παρά μια επιστροφή σφάλματος

Pdf.FileName := FormPath;
Pdf.FormFill := True;          // required: FormHandle must exist
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // writes /V only

Pdf.GenerateFormAppearances;   // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

Δύο ειλικρινή όρια παραμένουν. Πρώτον, ο συγχρονισμός γράφει την τιμή πεδίου στο /AS κάθε widget εκείνου του πεδίου, που είναι σωστό για checkboxes αλλά κατά προσέγγιση για ομάδες radio των οποίων κάθε παιδί ορίζει το δικό του όνομα κατάστασης on· ένα παιδί του οποίου το /AP /N δεν έχει καταχώρηση που να ταιριάζει με το γραμμένο /AS δεν έχει εμφάνιση να επιλέξει κάτω από το §12.5.5, οπότε ένα μη επιλεγμένο κουμπί μπορεί να γίνει flatten σε τίποτα αντί για έναν κενό κύκλο. Ο έλεγχος μιας ομάδας radio με το FPDFAnnot_GetFormControlIndex πριν το flattening αξίζει τις λίγες γραμμές. Δεύτερον, τίποτα από αυτά δεν ισχύει για XFA, όπου η τιμή ζει σε ένα πακέτο δεδομένων XML αντί στα AcroForm dictionaries, ένας διαχωρισμός που καλύπτεται στις σημειώσεις για τα επεξεργασίες πεδίου XFA που δεν διατηρούνται. Το γενικό μάθημα αξίζει να κρατηθεί πέρα από αυτή τη μία διόρθωση: όποτε ένα API παίρνει τη λαβή φόρμας επιπλέον του annotation, σου λέει ότι θα επιλύσει την ιεραρχία πεδίων για σένα, και όποτε παίρνει μόνο το annotation θα διαβάσει ακριβώς το αντικείμενο που πέρασες. Αυτή η διάκριση διέπει επίσης την ανταλλαγή δεδομένων, αφού η εξαγωγή και εισαγωγή δεδομένων φόρμας XFDF λειτουργεί σε πλήρως προσδιορισμένα ονόματα πεδίων, ποτέ σε θέσεις widget

Το form flattening είναι ένα από εκείνα τα χαρακτηριστικά που μοιάζουν με μία μοναδική κλήση API και αποδεικνύονται συμβόλαιο ανάμεσα σε τρία dictionaries. Αν προτιμάς να δουλεύεις έναντι ενός στοιχείου που ήδη κωδικοποιεί αυτό το συμβόλαιο, το PDFium Component για Delphi και C++Builder διαθέτει την αναγέννηση εμφάνισης, το flattening, και την πρόσβαση πεδίων φόρμας που περιγράφονται εδώ ως συνηθισμένες ιδιότητες και μεθόδους