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

Δείκτης widget έναντι δείκτη annotation στις φόρμες PDFium Delphi

Στο PDFium Component, το VCL/LCL στοιχείο βασισμένο σε PDFium για Delphi, C++Builder, και Lazarus, ένας δείκτης πεδίου φόρμας δεν είναι δείκτης annotation. Μια σελίδα φέρει annotations Link, Text, και Ink δίπλα στα widgets της, οπότε η απαρίθμηση πεδίων πρέπει να φιλτράρει στο FPDFAnnot_GetSubtype και να εκθέτει έναν λογικό δείκτη με βάση το μηδέν, αντιστοιχισμένο πίσω σε μια πραγματική θέση annotation μόνο στην native κλήση

Το bug που εκθέτει αυτό είναι αλάνθαστο μόλις το έχεις δει. Ένας δοκιμαστής πατά Tab σε μια συμπληρωμένη φόρμα τιμολογίου και ο δρομέας εξαφανίζεται, γιατί η εστίαση πήγε σε έναν υπερσύνδεσμο στο υποσέλιδο. Ή χειρότερα, δεν συμβαίνει τίποτα καθόλου: ο κώδικάς σου καταγράφει το πεδίο 3 ως εστιασμένο, το panel UI ενημερώνεται, και το FORM_SetFocusedAnnot σιωπηρά επέστρεφε false όλη την ώρα. Και τα δύο συμπτώματα προέρχονται από το ίδιο σχεδιαστικό λάθος, και το ένα από αυτά έχει μια δεύτερη ριζική αιτία κρυμμένη από κάτω

Οι δύο χώροι δεικτών που σου δίνει το PDFium

Το PDFium εκθέτει δύο σχήματα αρίθμησης πάνω στην ίδια σελίδα, και συμπίπτουν μόνο σε έγγραφα που τυχαίνει να μην περιέχουν τίποτα άλλο εκτός από widgets φόρμας. Το πρώτο είναι ο δείκτης annotation: μια θέση στον πίνακα σελίδας /Annots, που είναι αυτό που μετρά το FPDFPage_GetAnnotCount και αυτό που παίρνει το FPDFPage_GetAnnot (ISO 32000-1 §12.5.2). Το δεύτερο είναι ο λογικός δείκτης πεδίου που ένα API σε επίπεδο εφαρμογής θα έπρεπε να προσφέρει, τρέχοντας από το μηδέν πάνω στα διαδραστικά πεδία που ένας χρήστης μπορεί πράγματι να φτάσει. Το ISO 32000-1 §12.5.6.19 ορίζει τα widget annotations ως την οπτική αναπαράσταση διαδραστικών πεδίων φόρμας, και το §12.7 ορίζει την ίδια τη φόρμα. Οτιδήποτε άλλο στη σελίδα είναι διαφορετικός υπότυπος με διαφορετική σημασιολογία: ένα annotation Link έχει προορισμό, ένα annotation Ink έχει λίστα πινελιάς, ένα annotation Text είναι κολλητικό σημείωμα. Κανένα από αυτά δεν ανήκει σε μέτρηση πεδίων, και κανένα δεν μπορεί να δεχτεί εστίαση φόρμας. Ωστόσο στον πίνακα /Annots κάθονται εναλλάξ με τα widgets με όποια σειρά τα έγραψε η εφαρμογή παραγωγής, που συχνά δεν είναι η σειρά που υποδεικνύει οτιδήποτε άλλο για το έγγραφο

Γιατί το Tab προσγειώνεται σε υπερσύνδεσμο αντί για το επόμενο πεδίο;

Γιατί η μέτρηση πεδίων ήταν στην πραγματικότητα μέτρηση annotations. Η αρχική υλοποίηση επέστρεφε απευθείας το FPDFPage_GetAnnotCount από το FormFieldCount, ενώ ο προσπελαστής πληροφοριών πεδίου, ο βοηθός σειράς tab, και ο βοηθός εστίασης αντιμετώπιζαν όλοι τον ίδιο ακέραιο ως θέση widget. Σε μια καθαρή σελίδα AcroForm με έξι widgets και τίποτα άλλο, το έξι ισούται με έξι και κάθε δοκιμή περνά. Πρόσθεσε έναν υπερσύνδεσμο στο υποσέλιδο και ένα σχόλιο κριτή στο περιθώριο, και η μέτρηση αναφέρει οκτώ πεδία, οι δείκτες 6 και 7 επιλύονται σε αντικείμενα εκτός φόρμας, και το Tab περπατά κατευθείαν μέσα τους

Η διόρθωση στην πλευρά απαρίθμησης είναι να μετράς υποτύπους αντί για annotations. Άνοιξε κάθε annotation, ρώτησε για τον υπότυπό του, κράτησε τα widgets, και κλείσε τη λαβή σε μπλοκ finally, γιατί το FPDFPage_GetAnnot επιστρέφει μια λαβή που κατέχεται και πρέπει να επιστραφεί μέσω του FPDFPage_CloseAnnot

function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
  Count, I: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := 0;
  if Page = nil then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);   // every annotation, not just fields
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
        Inc(Result);
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

Πρόσεξε τι σκόπιμα δεν κάνει αυτό. Δεν ρωτά τίποτα το περιβάλλον form-fill, και δεν χρειάζεται λαβή φόρμας, γιατί ο υπότυπος ζει στο annotation dictionary και είναι αναγνώσιμος από μόνη τη σελίδα. Αυτό μετράει για τη σειρά: η μέτρηση είναι διαθέσιμη πριν αποφασίσεις αν το έγγραφο αξίζει καν ένα περιβάλλον form-fill, κάτι που το άρθρο για AcroForm JavaScript και host events καλύπτει ως απόφαση ασφάλειας παρά ως ζήτημα ευκολίας

Αντιστοίχιση του λογικού δείκτη πίσω στο native όριο

Ο κανόνας που εμποδίζει τους δύο χώρους από το να διαρρέουν ο ένας στον άλλο είναι απλός: ο λογικός δείκτης είναι ο μόνος αριθμός που διασχίζει το δημόσιο API σου, και μετατρέπεται σε δείκτη annotation στην τελευταία συνάρτηση πριν από την native κλήση. Ένας βοηθός αντιστοίχισης, που χρησιμοποιείται εξίσου από πληροφορίες πεδίου, εστίαση, θέτες σημαιών, και σειρά tab, είναι αυτό που κάνει αυτόν τον κανόνα εφαρμόσιμο

function AnnotationIndexForField(Page: FPDF_PAGE;
  FieldIndex: Integer): Integer;
var
  Count, I, Current: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := -1;
  if (Page = nil) or (FieldIndex < 0) then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);
  Current := 0;
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
      begin
        if Current = FieldIndex then
          Exit(I);        // real /Annots position: native calls only
        Inc(Current);
      end;
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

Δύο ιδιότητες αυτού του βοηθού αξίζει να δηλωθούν καθαρά. Είναι μια γραμμική σάρωση, οπότε ένας αφελής βρόχος πάνω σε κάθε πεδίο κοστίζει τετραγωνικό αριθμό ανοιγμάτων annotation σε μια σελίδα με εκατοντάδες widgets· αν απαριθμείς ολόκληρη τη σελίδα, διάτρεξε τα annotations μία φορά και συλλέγε τις λαβές widget καθώς προχωράς αντί να καλείς τον αντιστοιχιστή ανά πεδίο. Και επιστρέφει -1 αντί να σηκώνει εξαίρεση, κάτι που αφήνει τον καλούντα να αποφασίσει αν ένας μπαγιάτικος δείκτης είναι σφάλμα προγραμματισμού που αξίζει μια εξαίρεση ή αγώνας δρόμου που αξίζει να αγνοηθεί, για παράδειγμα αφού μια επεξεργασία αφαίρεσε ένα annotation στο οποίο μια cached λίστα UI ακόμα αναφέρεται

Γιατί το FORM_SetFocusedAnnot αποτυγχάνει σε headless σελίδα;

Γιατί το PDFium αρνείται να εστιάσει ένα widget του οποίου η προβολή σελίδας ποτέ δεν σημαδεύτηκε ως έγκυρη. Το FORM_SetFocusedAnnot επιλύει το annotation σε προβολή σελίδας μέσα στο περιβάλλον form-fill, και αν αυτή η προβολή σελίδας δεν υπάρχει επιστρέφει false χωρίς κανένα διαγνωστικό. Η διόρθωση μόνο της αντιστοίχισης δεικτών επομένως διορθώνει το Tab που προσγειώνεται σε υπερσύνδεσμο αλλά αφήνει το δεύτερο σύμπτωμα ανέγγιχτο: η λογική εγγραφή εστίασής σου λέει πεδίο 3, το native εστιασμένο widget είναι ακόμα τίποτα, και κάθε προσπελαστής χτισμένος πάνω στη native εστίαση, εστιασμένο κείμενο, εστιασμένη τιμή, κατάσταση επιλογής choice, συνεχίζει να επιστρέφει κενό. Η προβολή σελίδας δημιουργείται από το FORM_OnAfterLoadPage και καταστρέφεται από το FORM_OnBeforeClosePage. Σε έναν viewer χτισμένο γύρω από ένα οπτικό control αυτές οι κλήσεις συμβαίνουν ως μέρος της εμφάνισης μιας σελίδας, γι' αυτό η αποτυχία τόσο συχνά μοιάζει με bug μόνο-headless: ο ίδιος κώδικας που δουλεύει στο demo GUI αποτυγχάνει στο εργαλείο batch. Ο κύκλος ζωής ανήκει στο αντικείμενο εγγράφου, όχι στον viewer, οπότε το PDFium Component τώρα εκδίδει και τις δύο κλήσεις όποτε μια σελίδα φορτώνεται ή αποφορτώνεται με παρούσα λαβή φόρμας. Η υπογραφή C παίρνει τη σελίδα πρώτη και τη λαβή φόρμας δεύτερη, κάτι που είναι εύκολο να αντιστραφεί όταν γράφεις το binding με το χέρι

procedure ReportFirstField(const FileName: string);
var
  Pdf: TPdf;
  Idx: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FormFill := True;      // form-fill environment, before Active
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Pdf.PageNumber := 1;       // page load also runs FORM_OnAfterLoadPage

    Idx := Pdf.FocusNextFormField;   // logical index, 0-based over widgets
    if Idx < 0 then
      Exit;                    // page holds no widget annotations

    Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
      string(Pdf.FocusedFormFieldValue));   // reads the native focused widget
  finally
    Pdf.Free;                  // page unload runs FORM_OnBeforeClosePage
  end;
end;

Ο έλεγχος που αποδεικνύει τη διόρθωση είναι αυτός που συγκρίνει τις δύο πλευρές. Κάλεσε το FocusFormField με έναν λογικό δείκτη, μετά διάβασε μια τιμή μέσω ενός προσπελαστή που περνά από το native εστιασμένο widget αντί από τη δική σου εγγραφή, όπως το FocusedFormFieldValue ή το FocusedFormOptionSelected. Αν ο λογικός δείκτης κάνει σωστό round-trip αλλά ο native προσπελαστής επιστρέφει κενό, λείπει η προβολή σελίδας, όχι η αντιστοίχιση

Τι δεν υπόσχεται ο λογικός δείκτης πεδίου

Ένας δείκτης πεδίου με βάση το μηδέν είναι ευκολία, όχι σημασιολογική ταυτότητα, και τέσσερα όρια προκύπτουν από αυτό. Είναι ανά σελίδα, όχι ανά έγγραφο, οπότε ο δείκτης 0 στη σελίδα 2 είναι διαφορετικό widget από τον δείκτη 0 στη σελίδα 1 και η σύγκρισή τους δεν έχει νόημα. Είναι θεσιακός, οπότε η εισαγωγή ή διαγραφή ενός annotation ακυρώνει κάθε cached δείκτη πάνω από την αλλαγή· αντιμετώπισε έναν αποθηκευμένο δείκτη ως έγκυρο μόνο όσο η σελίδα παραμένει φορτωμένη και χωρίς επεξεργασία

Το τρίτο όριο είναι αυτό που εκπλήσσει τον κόσμο που ανασκοπεί μια λίστα πεδίων. Ο δείκτης απαριθμεί widgets, όχι πεδία. Μια ομάδα radio είναι ένα πεδίο με αρκετά widget παιδιά, οπότε μια ομάδα τριών κουμπιών συνεισφέρει τρεις διαδοχικούς δείκτες που όλοι αναφέρουν το ίδιο Name. Η εγγραφή TPdfFormFieldInfo φέρει GroupCount και GroupIndex ακριβώς για αυτή την περίπτωση, και ένα UI λίστας που τα αγνοεί δείχνει το ίδιο πεδίο τρεις φορές. Το τέταρτο όριο αφορά τη σειρά διάτρεξης: η σειρά tab που εκτίθεται εδώ είναι η σειρά απαρίθμησης widget, που ακολουθεί τον πίνακα /Annots, όχι την καταχώρηση σελίδας /Tabs (ISO 32000-1 §7.7.3.3) και όχι το δέντρο πεδίων AcroForm. Για τους περισσότερους παραγωγούς αυτά συμφωνούν· για μια φόρμα διατεταγμένη σε δύο στήλες από μια γεννήτρια που εξέπεμψε πρώτα τη δεξιά στήλη, δεν συμφωνούν, και το μονοπάτι πληκτρολογίου που περιγράφεται στο άρθρο πλοήγησης πεδίων φόρμας θα φανεί λάθος παρόλο που κάθε δείκτης είναι σωστός. Όταν ένα αρχείο πελάτη συμπεριφέρεται περίεργα, ξεφόρτωσε και τους δύο χώρους δεικτών δίπλα-δίπλα πριν θεωρητικολογήσεις: η προβολή annotation και η προβολή πεδίου της ίδιας σελίδας, τυπωμένες μαζί, συνήθως κάνουν την αιτία προφανή με μία ματιά

procedure DumpIndexSpaces(Pdf: TPdf);
var
  I: Integer;
  Info: TPdfFormFieldInfo;
begin
  for I := 0 to Pdf.AnnotationCount - 1 do
    Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));

  for I := 0 to Pdf.FormFieldCount - 1 do
  begin
    Info := Pdf.FormFieldInfo[I];
    Writeln('field ', I, ': ', string(Info.Name),
      ' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
  end;
end;

Μια μέτρηση annotation πολύ πάνω από τη μέτρηση πεδίων σημαίνει ότι η σελίδα αναμειγνύει υποτύπους, που είναι φυσιολογικό σε αναθεωρημένα έγγραφα και είναι ακριβώς η κατάσταση για την οποία υπάρχει η αντιστοίχιση· το άρθρο ροής εργασίας ανασκόπησης annotation κοιτάζει την ίδια σελίδα από την πλευρά της σήμανσης. Ίσες μετρήσεις σε κάθε αρχείο δοκιμής, από την άλλη, σημαίνουν ότι τα fixtures σου δεν μπορούν να ανιχνεύσουν καθόλου αυτή την κατηγορία bug, και η ειλικρινής απάντηση είναι να προσθέσεις ένα fixture φόρμας που φέρει έναν σύνδεσμο και ένα κολλητικό σημείωμα

Τα APIs απαρίθμησης πεδίων, εστίασης, και annotation που περιγράφονται εδώ διατίθενται με το PDFium Component για Delphi, C++Builder, και Lazarus, του οποίου η σελίδα προϊόντος φέρει την πλήρη αναφορά πεδίου φόρμας συμπεριλαμβανομένης της εγγραφής πληροφοριών πεδίου και των προσπελαστών εστίασης