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

Dynamic XFA στο PDFium Component: το page count είναι delta

Όταν μια dynamic XFA φόρμα σε Delphi viewer προσθέτει ή αφαιρεί σελίδες, το PDFium Component αναφέρει το νέο σύνολο μέσω TPdf.PageCount και TPdf.OnXfaPageCountChanged από το v3.126.1, επειδή το native page event κουβαλά delta προστέθηκαν/αφαιρέθηκαν και όχι σύνολο. Οι Windows V8 βιβλιοθήκες στο v3.126.1 μετακινούν επίσης hit areas εισόδου μαζί με μετατοπισμένα fields, και το v3.126.2 ξαναφορτώνει παρωχημένα page handles μετά την επιστροφή του layout callback. Το bug report που ξεκίνησε αυτό ήταν φόρμα δήλωσης εξόδων: πάτα Add Row δύο φορές, η φόρμα μεγαλώνει σε δύο σελίδες, και ο δείκτης σελίδας καμαρώνει ότι γράφει 1 από 1. Πληκτρολόγησε σε field που μετακινήθηκε στη σελίδα 2 και τα πλήκτρα προσγειώνονται κάπου αόρατο. Τίποτα από αυτά δεν φάνηκε με τις fixed-length δείγματα φόρμες που όλοι τεστάρουν πρώτα, και οι λόγοι αξίζουν να τους ξέρεις αν ενσωματώνεις form viewer

Τι συμβαίνει όταν μια dynamic XFA φόρμα κάνει repagination;

Μια dynamic XFA φόρμα δεν έχει σταθερή λίστα σελίδων, οπότε το page count της είναι έξοδος του layout και μπορεί να αλλάξει κάθε φορά που ο χρήστης επεξεργάζεται δεδομένα. Το XFA 3.3 περιγράφει τη φόρμα ως δέντρο subforms· ένα επαναλαμβανόμενο subform ελέγχεται από instanceManager, και ένα script όπως το _Row.addInstance() κλωνοποιεί ακόμα μία σειρά. Ο layout processor μετά ρέει το περιεχόμενο ξανά μέσα στις page areas, που μπορεί να προσθέσει σελίδα, να ρίξει σελίδα ή να σπρώξει υπάρχοντα fields σε άλλη σελίδα. Το ISO 32000-1 §12.7.8 ορίζει μόνο πώς τα XFA packets ταξιδεύουν μέσα στο PDF· όλα όσα συμβαίνουν μετά ανήκουν στη XFA engine, που στο PDFium Component είναι το δικό του XFA layout του PDFium τρεχούμενο στη διεργασία του host. Ένας Delphi viewer αντιμετωπίζει άρα έγγραφο του οποίου το page count, τα page sizes και οι θέσεις widgets είναι όλα live state. Τρία πράγματα πάνε στραβά όταν ο host υποθέτει το αντίθετο:

  • Το page count που ο host κρατά στην cache για navigation, scroll ranges και page spinners παλιώνει, ή χειρότερα, ενημερώνεται με λάθος αριθμό
  • Fields που μετακομίζουν δείχνουν το περίγραμμά τους στη νέα θέση ενώ ο editor και το hit area του ποντικιού μένουν στις παλιές συντεταγμένες
  • Ο viewer κρατά page handle που το layout έχει αντικαταστήσει, οπότε κλικ και ζωγραφιές πηγαίνουν σε σελίδα που δεν υπάρχει πια σε εκείνη τη φόρμα

Το να επιβιώσουν οι επεξεργασίες σειρών σε save και reopen είναι ξεχωριστό πρόβλημα με δικούς του κανόνες· αυτό το άρθρο μένει σε ό,τι συμβαίνει runtime μέσα στον viewer

Ποιο PDFium runtime χρειάζεται το dynamic XFA;

Το dynamic XFA στο PDFium Component απαιτεί το V8/XFA build της native βιβλιοθήκης, που διαλέγει η καθολική μεταβλητή EnableV8Engine στη μονάδα PDFium πριν φορτώσει το πρώτο έγγραφο. Η διεργασία δεσμεύεται σε μία DLL την πρώτη φορά που οποιοδήποτε TPdf φορτώνει τη βιβλιοθήκη, και σκέτο PDFium build δεν μπορεί να τρέξει καθόλου τη XFA engine. Όταν ανοίγει έγγραφο, το TPdf κοιτάζει όντως το αρχείο για XFA markers και αλλάζει αυτόματα στο V8 build, αλλά μόνο αν καμία σκέτη βιβλιοθήκη δεν έχει φορτωθεί ακόμα σε εκείνη τη διεργασία. Όταν η δέσμευση έχει ήδη πάει ανάποδα, το TPdf.OnXfaRuntimeMissing ανάβει μία φορά ώστε ο host να πει στον χρήστη να κάνει επανεκκίνηση. Ορίστε τη σημαία ρητά στην εκκίνηση και η μαντεψιά φεύγει. Η δομή callbacks FPDF_FORMFILLINFO που κουβαλά τα XFA events πρέπει επίσης να ταιριάζει με τη DLL· το background είναι στο FPDF_FORMFILLINFO version 2 και το XFA callback ABI, και το ανίχνευση XFA φορμών και διάβασμα των packets τους καλύπτει πώς ξεχωρίζεις τους τύπους φορμών πριν ανοίξεις viewer

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // Αποφάσισε πριν το πρώτο TPdf φορτώσει τη native βιβλιοθήκη:
  // η διεργασία δεν μπορεί να αλλάξει από pdfium.dll σε pdfium.v8.dll αργότερα
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

Γιατί το PageCount ανέφερε 1 για φόρμα δύο σελίδων;

Πριν το v3.126.1, το PDFium Component αποθήκευε το όρισμα page_count του native page event ως σύνολο του εγγράφου, και εκείνο το όρισμα είναι στην πραγματικότητα η απόλυτη διαφορά ανάμεσα στους νέους και παλιούς page counts. Το PDFium σηκώνει FFI_PageEvent μετά το τέλος pass layout με τύπο event σελίδα προστέθηκε ή σελίδα αφαιρέθηκε· εσωτερικά ενημερώνει πρώτα το αποθηκευμένο page count του και μετά περνά abs(new - old). Στο αρχικό layout ο παλιός μετρητής είναι μηδέν, οπότε το delta ισούται με το σύνολο, και ένα static δείγμα τριών σελίδων αναφέρει τρεις σελίδες όπως περιμένουμε. Ακριβώς γι’ αυτό τα fixed-length test φόρμες δεν exposed ποτέ το bug. Την πρώτη φορά που μια dynamic φόρμα μεγαλώνει από μία σελίδα σε δύο, το delta είναι 1, και ο wrapper έβαλε και το TPdf.PageCount και την παράμετρο NewCount του OnXfaPageCountChanged σε 1. Η αφαίρεση σειράς από φόρμα τριών σελίδων παρήγαγε το ίδιο είδος nonsense προς την άλλη κατεύθυνση

Το να αθροίσεις το delta πάνω στην προηγούμενη τιμή δεν είναι ασφαλής επισκευή ούτε αυτή. Η σειρά initialization και layout callbacks σημαίνει ότι ο wrapper δεν μπορεί πάντα να εμπιστευτεί το προηγούμενο count του ως βάση, οπότε τρεχούμενο άθροισμα μπορεί να παρεκκλίνει. Από το v3.126.1, το callback αγνοεί το όρισμα ως count και καλεί FPDF_GetPageCount στο έγγραφο, που διαβάζει το σύνολο από το layout που μόλις ολοκληρώθηκε. Μετά καθαρίζει τα cached page scenes, αποθηκεύει εκείνο το σύνολο ως XFA page-count override πίσω από το TPdf.PageCount, και μόνο μετά σηκώνει OnXfaPageCountChanged. Όσον αφορά ο handler σας να τρέχει, το NewCount και το FPdf.PageCount συμφωνούν

Διάγραμμα dynamic XFA PDFium Component όπου προσθήκη σειράς κάνει repagination φόρμας μίας σελίδας σε δύο και το FFI_PageEvent περνά abs(new μείον old) ως delta, οπότε ο παλιός wrapper ανέφερε TPdf.PageCount 1 ενώ το v3.126.1 διαβάζει FPDF_GetPageCount και αναφέρει το σωστό σύνολο
Το native page event αναφέρει delta προστέθηκαν-αφαιρέθηκαν, όχι σύνολο, οπότε το v3.126.1 αγνοεί το όρισμα και διαβάζει το ολοκληρωμένο layout πριν σηκώσει OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+: το NewCount είναι το σύνολο του ολοκληρωμένου layout, ποτέ delta.
  // Αυτό τρέχει μέσα στο layout callback του PDFium: ενημέρωσε μόνο host UI state,
  // μην κλείσεις το έγγραφο ή ξαναφορτώσεις σελίδες από εδώ
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // Ανάβει μετά από κάθε page reload, συμπεριλαμβανομένου του deferred XFA refresh
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

Το event ανάβει μόνο για Full XFA φόρμες των οποίων το layout αλλάζει runtime. Static XFA και AcroForm έγγραφα δεν το σηκώνουν ποτέ, οπότε viewer που χειρίζεται και τα δύο μπορεί να αφήσει τον ίδιο handler ανατεθειμένο. Το να μείνει ανατεθειμένο σε τίποτα είναι επίσης ασφαλές· το override πίσω από το TPdf.PageCount εφαρμόζεται ούτως ή άλλως, και το event υπάρχει ώστε ο host να φρεσκάρει ό,τι είχε στην cache

Γιατί το input box μένει στην παλιά σελίδα όταν μετακομίζει field;

Το περίγραμμα μετακόμισε και ο editor όχι επειδή ο native XFA notifier σύγκρινε ορθογώνιο με τον εαυτό του. Όταν το layout αλλάζει τη γεωμετρία widget που είναι ήδη φορτωμένη, το PDFium υποτίθεται ότι προσέχει το νέο ορθογώνιο και καλεί PerformLayout στο widget, που μετατοπίζει τον text editor και το hit area του. Ο έλεγχος σύγκρινε GetWidgetRect() με RecacheWidgetRect(). Και οι δύο συναρτήσεις επιστρέφουν const reference στο ίδιο μέλος, και το recache ξαναγράφει εκείνο το μέλος στη θέση του, οπότε η σύγκριση έβλεπε πάντα δύο πανομοιότυπες τιμές και τα φορτωμένα widgets προσπερνούσαν το relayout τους

Το σύμπτωμα βγήκε στην επιφάνεια όταν ένα test άλλαξε το ύψος subform ώστε υπάρχοντα fields πέρασαν στην επόμενη σελίδα. Και στις δύο V8 αρχιτεκτονικές, το περίγραμμα του field σχεδιαζόταν στη νέα του θέση ενώ το πληκτρολογημένο κείμενο και το hit area του ποντικιού μένανε στην προηγούμενη συντεταγμένη Y. Ένα ρητό relayout δεν το έφτιαξε, και ούτε η επαναφόρτωση της σελίδας, επειδή το widget εξακολουθούσε να πιστεύει ότι η γεωμετρία του ήταν τρέχουσα. Οι Windows V8 βιβλιοθήκες που ήρθαν με το v3.126.1 αντιγράφουν το παλιό ορθογώνιο by value πριν το recache και συγκρίνουν εκείνο το αντίγραφο, οπότε τα μετακινημένα widgets κάνουν relayout και η επεξεργασμένη τιμή εμφανίζεται ακριβώς εκεί που είναι το περίγραμμα. Αυτή είναι native διόρθωση: ταξιδεύει με τις DLLs, οπότε το να ενημερώσεις τις Pascal μονάδες κρατώντας παλαιότερο pdfium.v8.dll αφήνει τα παραταγμένα hit areas στη θέση τους. Το regression check που το έτρεξε επεξεργάζεται επιζώσα σειρά σε μη προεπιλεγμένη τιμή πρώτα και μετά απαιτεί εκείνη την τιμή στη νέα θέση του field, επειδή σειρά ξαναχτισμένη με προεπιλεγμένες τιμές αλλιώς θα έμοιαζε με πέρασμα

Διάγραμμα widget relayout PDFium Component που αντιπαραθέτει την παλιά αυτοσύγκριση όπου GetWidgetRect και RecacheWidgetRect επέστρεφαν ένα μοιρασμένο μέλος ώστε τα μετακινημένα widgets προσπερνούσαν PerformLayout, με τον έλεγχο copy-by-value των Windows V8 του v3.126.1 που μετατοπίζει τον editor και το hit area του ποντικιού πάνω στο ξαναζωγραφισμένο περίγραμμα
Σύγκριση ορθογωνίου με τον εαυτό του δεν αποτυγχάνει ποτέ, οπότε το περίγραμμα μετακινήθηκε ενώ πληκτρολογημένο κείμενο και κλικ μένανε πίσω έως ότου ο έλεγχος σώσει αντίγραφο by value πρώτα

Πώς ξαναφορτώνει το TPdfView σελίδες χωρίς να τραβήξει handle κάτω από το PDFium;

Από το v3.126.2, το TPdfView αναβάλλει το page reload που ακολουθεί XFA layout αλλαγή μέχρι το native call stack να ξετυλιχτεί. Το page event συνήθως ανάβει ενώ το PDFium εξακολουθεί να επεξεργάζεται είσοδο: ο χρήστης πάτησε κουμπί Add Row, το κλικ έτρεξε script, το script άλλαξε το instance count, και το layout τελείωσε μέσα στην ίδια native κλήση. Το να κλείσεις και να ξανοίξεις το page handle εκείνη τη στιγμή θα ελευθέρωνε object που ο καλών εξακολουθεί να χρησιμοποιεί. Πριν το v3.126.2, ο viewer μόνο ακύρωνε τον εαυτό του, οπότε το εμφανιζόμενο page handle μπορούσε να συνεχίσει να δείχνει pre-layout state, και αν ο χρήστης βρισκόταν στην τελευταία σελίδα όταν εκείνη έλειψε, ο επιλεγμένος αριθμός σελίδας ήταν εκτός εύρους

Το αναβλημένο refresh δουλεύει σε λίγα μικρά βήματα, και εξηγούν τη συμπεριφορά που βλέπετε από τον host:

  1. Το callback page-event σημαίνει την προβολή ως έχουσα pending XFA layout refresh και στέλνει ιδιωτικό window message· επαναλαμβανόμενα events πριν φτάσει το μήνυμα συγχωνεύονται σε ένα refresh
  2. View χωρίς ακόμα window handle κρατά τη pending σημαία και στέλνει το μήνυμα από CreateWnd, ενώ αλλαγή εγγράφων, απενεργοποίηση της προβολής ή καταστροφή της καθαρίζει τη σημαία
  3. Όταν φτάσει το μήνυμα, η προβολή καθαρίζει την επιλογή κειμένου, το search highlight και το index εστιασμένου field, επειδή και τα τρία αναφέρονταν στο παλιό layout
  4. Η επιλεγμένη σελίδα περιορίζεται με clamp στο νέο PageCount· αλλαγμένος αριθμός σελίδας περνά από την κανονική αλλαγή σελίδας, αλλιώς η τρέχουσα σελίδα ξαναφορτώνεται, και το fit mode εφαρμόζεται ξανά
  5. Αν το layout δεν αφήνει καθόλου σελίδες, η προβολή ξεφορτώνει το παλιό της page handle αντί να ζωγραφίσει σελίδα που δεν υπάρχει πια
Διάγραμμα αναβλημένου XFA refresh TPdfView PDFium Component όπου page event μέσα στο native layout call stack μόνο σημαίνει pending refresh και στέλνει window message, που αργότερα καθαρίζει παρωχημένο selection state, περιορίζει τη σελίδα στο νέο PageCount και ξαναφορτώνει ή ξεφορτώνει το page handle
Το reload περιμένει μέχρι το native call stack να ξετυλιχτεί· ένα σταλμένο μήνυμα συγχωνεύει επαναλαμβανόμενα events, μετά η προβολή περιορίζει τη σελίδα, την ξαναφορτώνει και σηκώνει OnPageChange

Ο ίδιος περιορισμός ισχύει και για τον δικό σας κώδικα. Το OnXfaPageCountChanged τρέχει μέσα σε εκείνο το native layout callback, οπότε μεταχειριστείτε το όπως ειδοποίηση: ενημερώστε ετικέτες, εύρη spinners και κατάσταση toolbar εκεί, και βάλτε σε ουρά ό,τι βαρύτερο, όπως κλείσιμο εγγράφου ή άνοιγμα άλλου, με σταλμένο μήνυμα ώστε να τρέξει αφού επιστρέψει το callback. Το TPdfView.OnPageChange μετά σας λέει πότε η προβολή ξαναφόρτωσε όντως τη σελίδα, και διάβασμα PdfView1.PageNumber σε εκείνο το σημείο σας δίνει την περιορισμένη τιμή. Η διάβαση με Tab και οι έλεγχοι FormType που τρέχει ένας form viewer στο άνοιγμα καλύπτονται στο navigation πεδίων φορμών PDF με PDFium Component

Γιατί κλικ σε field Full XFA σηκώνει «Cannot open text page»;

Οι Full XFA σελίδες δεν έχουν PDF text page, και πριν το v3.126.2 η προεπιλεγμένη επιλογή κειμένου και ανίχνευση συνδέσμων του viewer προσπαθούσε ούτως ή άλλως να φορτώσει μία. Με το TPdfView.AllowUserTextSelection στην προεπιλογή του True, το hovering ρωτούσε το text layer για χαρακτήρα κάτω από το ποντίκι, και κλικ mouse-up έτρεχε αυτόματο URL probe πάνω στο κείμενο της σελίδας. Σε Full XFA σελίδα το text page δεν μπορεί να ανοίξει, οπότε συνηθισμένο κλικ σε field μπορούσε να καταλήξει σε exception Cannot open text page. Από το v3.126.2, και οι δύο εσωτερικές διαδρομές επιστρέφουν χωρίς αποτέλεσμα όταν το TPdf.FormType είναι ftXfaFull και η XFA runtime είναι διαθέσιμη, οπότε οι προεπιλεγμένες ρυθμίσεις δουλεύουν και η είσοδος field μένει διαθέσιμη

Το κλείσιμο του AllowUserTextSelection για Full XFA έγγραφα εξακολουθεί να είναι λογική επιλογή UI, επειδή δεν υπάρχει κείμενο σελίδας να διαλέξεις και τα drag gestures δεν πρέπει να ξεκινάνε λειτουργία επιλογής. Δεν είναι όμως υποκατάστατο της αναβάθμισης: σε προγενέστερες εκδόσεις το URL probe στο κλικ δεν εξαρτιόταν από εκείνη την ιδιότητα, οπότε viewer μπορούσε να πιάσει το ίδιο exception με την επιλογή κλειστή

procedure TClaimForm.ConfigureViewerForForm;
begin
  // Το FormType διαβάζει το ανοιχτό έγγραφο, οπότε κάλεσέ το μετά FPdf.Active := True
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // Δεν υπάρχει PDF text layer στις Full XFA σελίδες· τα fields μένουν επεξεργάσιμα
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

Η πληκτρολόγηση χρειαζόταν δική της επισκευή στο v3.126.2. Ο native XFA text editor δεν αντικαθιστά επιλογή όταν λαμβάνει χαρακτήρα: το FORM_OnChar εισάγει στο caret, και το Backspace διαγράφει έναν χαρακτήρα, οπότε το να διαλέξεις τιμή και να πληκτρολογήσεις από πάνω παρήγαγε παλιό και νέο κείμενο δίπλα δίπλα. Το PDFium Component τώρα θυμάται ότι το κλικ προσγειώθηκε σε XFA text field και δρομολογεί πληκτρολογημένους χαρακτήρες, Backspace και Delete μέσω FORM_ReplaceSelection όποτε υπάρχει επιλογή και το έγγραφο παραχωρεί fill-forms ή modify άδεια. Το αν μπορεί να αλλάξει read-only XFA field αποφασίζει ακόμα ο native editor, οπότε field σημαδεμένο read-only στη φόρμα κρατά την τιμή του ακόμα και σε έγγραφο που αλλιώς επιτρέπει συμπλήρωση. Το να ορίσεις TPdfView.AllowFormEvents σε False σταματά επίσης εκείνη τη δρομολόγηση πληκτρολογίου, που κρατά read-only viewer read-only

Σύντομη αναφορά: dynamic XFA σε Delphi viewer

ΣύμπτωμαΑιτίαΔιορθώθηκε σε
Το page count δείχνει 1 αφού η φόρμα μεγαλώσει σε δύο σελίδεςΤο native page event περνά delta προστέθηκαν/αφαιρέθηκαν, όχι σύνολοv3.126.1 (wrapper)
Το περίγραμμα field μετακινείται, πληκτρολογημένο κείμενο και hit area μένουν πίσωΦορτωμένο widget προσπέρασε relayout μετά από αυτοσύγκρισηv3.126.1 (Windows V8 libraries)
Ο viewer ζωγραφίζει ή δρομολογεί είσοδο σε pre-layout state σελίδαςPage handle δεν ξαναφορτώθηκε μετά από repaginationv3.126.2 (deferred refresh)
Κλικ σε field σηκώνει Cannot open text pageΕπιλογή κειμένου και URL probe σε σελίδες χωρίς text layerv3.126.2
Πληκτρολόγηση πάνω σε διαλεγμένη τιμή προσαρτά αντί να αντικαταστήσειΟ native XFA editor εισάγει στο caretv3.126.2
  • Ορίστε EnableV8Engine σε True πριν φορτώσει οποιοδήποτε έγγραφο, και χειριστείτε OnXfaRuntimeMissing για την περίπτωση που η σκέτη βιβλιοθήκη φορτώθηκε πρώτη
  • Διαβάστε το σύνολο από TPdf.PageCount ή την παράμετρο NewCount του OnXfaPageCountChanged· μην προσθέτετε ή αφαιρείτε ποτέ page counts μόνοι σας
  • Κρατήστε ελαφρύ τον handler OnXfaPageCountChanged, επειδή τρέχει μέσα στο native layout callback
  • Συγχρονίστε τον δείκτη τρέχουσας σελίδας στο TPdfView.OnPageChange, που ανάβει αφού το αναβλημένο reload περιορίσει τον αριθμό σελίδας
  • Στείλτε τις Windows V8 DLLs v3.126.1 ή νεότερες μαζί με τις μονάδες· η διόρθωση widget relayout ζει σε native κώδικα
  • Τεστάρετε με φόρμα που αλλάζει όντως το page count της και μετακινεί επεξεργασμένο field πάνω από αλλαγή σελίδας, επειδή τα fixed-length δείγματα κρύβουν κάθε bug σε αυτή τη λίστα

Το dynamic XFA κάνει το page count και τη γεωμετρία fields live τιμές, και ο viewer μένει σωστός μόνο όταν τις παίρνει από το ολοκληρωμένο layout και ξαναφορτώνει σελίδες σε ασφαλής στιγμή. Το PDFium Component χειρίζεται και τα δύο μέσα στο TPdf και το TPdfView, οπότε στον host μένει μόνο να ακούει. Λεπτομέρειες και λήψεις στη σελίδα προϊόντος PDFium Component for Delphi