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

FPDF_FORMFILLINFO έκδοση 2: ακολούθησε το DLL ABI

Το PDFium Component θέτει πλέον FPDF_FORMFILLINFO.version σε 2 για κάθε form-fill περιβάλλον που αρχικοποιεί, επειδή η έκδοση που δέχεται ένα native PDFium build είναι ιδιότητα εκείνου του build, όχι του εγγράφου που ανοίγει. Ένα pdfium.v8.dll με XFA αρνείται την έκδοση 1 ολωσδιόλου, οπότε ένα σκέτο AcroForm PDF που άνοιγε μέσα από αυτό απέτυχε παλιά στο FPDFDOC_InitFormFillEnvironment χωρίς καθόλου XFA στον ορίζοντα. Το fix του v3.116.0 είναι μικρό, αλλά το λάθος πίσω του είναι γενικό και αξίζει να ονομαστεί: ένα πεδίο έκδοσης πρωτοκόλλου περιγράφει τη διάταξη μνήμης που περιμένει η άλλη πλευρά, και δεν πρέπει ποτέ να προκύπτει από το αν τυχαίνει να χρειάζεσαι τα features που εκείνη η διάταξη κουβαλάει

Γιατί το FPDFDOC_InitFormFillEnvironment αποτυγχάνει σε σκέτο PDF με pdfium.v8.dll;

Το περιβάλλον αποτυγχάνει επειδή ένα XFA-enabled PDFium build επαληθεύει το πεδίο version πριν κάνει οτιδήποτε άλλο, και η παλιά wrapper λογική του έδινε 1 κάθε φορά που το τρέχον έγγραφο δεν ήταν XFA form. Το σύμπτωμα σε έναν Delphi host είναι EPdfError που σηκώνεται από το TPdf.InitializeFormFill με μήνυμα Cannot initialize form fill environment, πεταμένο ενώ ανοίγει ένα συνηθισμένο τιμολόγιο ή φορολογική φόρμα που δεν έχει τίποτα παρά AcroForm text fields. Το ίδιο αρχείο ανοίγει μια χαρά απέναντι στο σκέτο pdfium.dll. Το ίδιο DLL ανοίγει μια χαρά ένα πραγματικό XFA έγγραφο. Μόνο ο συνδυασμός του V8 build και ενός μη-XFA εγγράφου σπάει, που είναι ακριβώς ο συνδυασμός στον οποίο καταλήγει ένας host αφού ανάψει το EnableV8Engine για να πάρει AcroForm JavaScript, ή αφού η αυτόματη επιλογή στο LoadDocument έχει ήδη δεσμεύσει τη διεργασία στο pdfium.v8.dll για ένα προγενέστερο XFA αρχείο. Εκείνη η δέσμευση είναι σε όλη τη διεργασία: το EnableV8Engine διαβάζεται πριν από το πρώτο LoadLibrary, και μόλις φορτωθεί το XFA build κάθε μεταγενέστερο σκέτο PDF περνά από το ίδιο setup περιβάλλοντος απέναντι στο ίδιο binary. Ο host δεν έκανε τίποτα λάθος· ο wrapper έθεσε τη λάθος ερώτηση όταν γέμιζε το record. Αν ακόμα αποφασίζεις ποιο binary να διανείμεις καθόλου, η σημείωσή μας για την ανάπτυξη του PDFium DLL και τη διάγνωση αποτυχιών φόρτωσης καλύπτει την επιλογή plain απέναντι σε V8, και το άρθρο αυτό προϋποθέτει ότι το V8 build βρίσκεται ήδη στη διεργασία

Διάγραμμα PDFium Component για τους τέσσερις συνδυασμούς plain pdfium.dll και XFA-enabled pdfium.v8.dll απέναντι σε έγγραφα AcroForm και XFA: record έκδοσης 1 έσπασε μόνο το V8 build με σκέτη φόρμα, EPdfError στο FPDFDOC_InitFormFillEnvironment, ενώ το διορθωμένο record έκδοσης 2 ανοίγει και τα τέσσερα
Μια προϋπόθεση έδεσε την έκδοση ABI με το έγγραφο, οπότε η επιλογή του V8 binary σε όλη τη διεργασία γύρνε κάθε μεταγενέστερο σκέτο PDF σε αποτυχημένη αρχικοποίηση περιβάλλοντος

Τι υπόσχεται πράγματι το πεδίο έκδοσης στο FPDF_FORMFILLINFO;

Το FPDF_FORMFILLINFO.version λέει στο PDFium ποια πεδία του record επιτρέπεται να διαβάσει, και το public header fpdf_formfill.h δένει τις αποδεκτές τιμές με το πώς μεταγλωττίστηκε η βιβλιοθήκη και όχι με το έγγραφο. Παραφράζοντας, το συμβόλαιο έχει τρία μέρη. Η έκδοση 1 καλύπτει τα σταθερά callbacks από FFI_Invalidate έως FFI_DoGoToAction συν τον pointer m_pJsPlatform. Ένα build χωρίς το XFA module δέχεται είτε 1 είτε 2, και με 2 θα καλεί επιπλέον τα πρόσθετα πειραματικά callbacks. Ένα build με το XFA module απαιτεί 2, τέλος συζήτησης, και το header επαναλαμβάνει εκείνη την απαίτηση δύο φορές σαν να περίμενε ότι ο κόσμος θα τη χάσουν. Πουθενά το συμβόλαιο δεν αναφέρει το έγγραφο. Η έκδοση είναι δήλωση για το record που δεσμεύεις: με 2 υπόσχεσαι ότι η μνήμη μετά το m_pJsPlatform υπάρχει και κρατά είτε έγκυρους function pointers είτε NULL

Η περιοχή έκδοσης 2 είναι εκεί όπου ζει όλη η μηχανή XFA. Ξεκινά με το xfa_disabled, ένα FPDF_BOOL που το header περιγράφει ως αγνοούμενο κάτω από την έκδοση 2 και νοηματικό μόνο όταν το XFA module είναι μεταγλωττισμένο μέσα, και συνεχίζει με δεκαεπτά function pointers, FFI_DisplayCaret έως FFI_DoURIActionWithKeyboardModifier. Καθένα από αυτά τεκμηριώνεται ως required για XFA και αλλιώς να τεθεί σε NULL. Εκείνη η διατύπωση είναι το κλειδί όλου του fix. Το NULL δεν είναι κατάσταση σφάλματος για εκείνα τα slots· είναι η τεκμηριωμένη κατάσταση για host που δεν οδηγεί XFA. Ένα record που σβήστηκε με FillChar και μετά μαρκαρίστηκε ως έκδοση 2 ικανοποιεί το συμβόλαιο σε non-XFA build εξίσου καλά με record έκδοσης 1, και είναι το μόνο record που θα δεχτεί ένα XFA build

Διάγραμμα PDFium Component για το record FPDF_FORMFILLINFO στο Delphi: η έκδοση 1 καλύπτει τα callbacks FFI_Invalidate έως FFI_DoGoToAction συν m_pJsPlatform, η έκδοση 2 προσθέτει xfa_disabled και δεκαεπτά pointers εποχής FFI_DisplayCaret, το FillChar σβήνει κάθε byte, και τα slots NULL είναι η τεκμηριωμένη κατάσταση για host που δεν οδηγεί XFA
Το Pascal record είναι πάντα η πλήρης διάταξη έκδοσης 2, οπότε ένα XFA-enabled build το δέχεται και ένα σκέτο build απλώς δεν καλεί ποτέ τα πειραματικά slots που μένουν NULL

Η παλιά επιλογή έδενε το ABI με το έγγραφο

Το ελάττωμα ήταν μία προϋπόθεση που έμοιαζε λογική απομονωμένη. Το TPdf.InitializeFormFill υπολογίζει flag RuntimeReady από τρία γεγονότα: το έγγραφο αναφέρει τύπο XFA form μέσω TPdf.XFA, οι string helpers XFA λύθηκαν μέσω XfaFeaturesAvailable, και τα exports V8 λύθηκαν μέσω V8FeaturesAvailable. Πριν το v3.116.0 το ίδιο flag διάλεγε και την έκδοση

// v3.115.0 και νωρίτερα: η έκδοση ABI ακολουθούσε το έγγραφο
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;

if RuntimeReady then
  FFormFillInfo.Info.version := 2
else
  FFormFillInfo.Info.version := 1;

// ... και το branch runtime-missing το καρφώθηκε ξανά
else if XFA then
begin
  FFormFillInfo.Info.version := 1;
  FFormFillInfo.Info.xfa_disabled := 1;
  if Assigned(FOnXfaRuntimeMissing) then
    FOnXfaRuntimeMissing(Self);
end;

Διάβασέ το με το header στο χέρι και η αποτυχία είναι φανερή. Το RuntimeReady είναι False για κάθε σκέτο AcroForm έγγραφο, οπότε κάθε σκέτο έγγραφο ανακοίνωνε έκδοση 1. Στο pdfium.dll αυτό πάει μια χαρά. Στο pdfium.v8.dll, που είναι το XFA-enabled build, το PDFium τεστάρει το πεδίο, το βρίσκει κάτω από το απαιτούμενο 2, και γυρνά null FPDF_FORMHANDLE, που το CheckPdf γυρνά στο exception παραπάνω. Η πρόθεση του παλιού κώδικα ήταν αμυντική: κράτα έκδοση 1 ώστε ένα XFA build να μην διαβάσει ποτέ τα μη ανατεθειμένα slots έκδοσης 2. Αμυνόταν απέναντι σε πρόβλημα που το header το εξαιρεί ήδη και δημιούργησε ένα που το header προειδοποιεί ρητά. Ο διορθωμένος κώδικας αποφασίζει την έκδοση μία φορά, μπροστά, από το τι είναι το record φυσικά

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // sentinel: χρησιμοποίησε το static page tree
  if not FormFill then
    Exit;

  FillChar(FFormFillInfo, SizeOf(FFormFillInfo), 0);
  FFormFillInfo.Pdf := Self;

  // Το πλήρες record έκδοσης 2 δεσμεύτηκε και σβήστηκε παραπάνω. Το PDFium
  // δέχεται έκδοση 2 χωρίς XFA και την απαιτεί σε κάθε XFA-enabled
  // build, ακόμα και όταν το έγγραφο αυτό δεν περιέχει XFA form.
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // Το RuntimeReady μπαρώνει τα XFA callbacks και το xfa_disabled, ποτέ την έκδοση.
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

Πού ανήκει ακόμα το RuntimeReady: στα callbacks και στο xfa_disabled

Το RuntimeReady κρατά τη δουλειά του ως πύλη για συμπεριφορά XFA· απλώς δεν αγγίζει πια τη διάταξη του record. Τα callbacks έκδοσης 1, FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction, και το υπόλοιπο εκείνου του block, συνδέονται απεριόριστα επειδή AcroForm και XFA εξαρτώνται κι οι δύο από αυτά. Οι δεκαεπτά pointers έκδοσης 2 ανατέθεις μόνο μέσα στο branch RuntimeReady, μαζί με xfa_disabled := 0. Όταν το έγγραφο είναι XFA αλλά το runtime δεν υπάρχει, το record μένει στην έκδοση 2 με xfa_disabled στο 1 και τα slots έκδοσης 2 σε NULL, και ο wrapper σηκώνει OnXfaRuntimeMissing ώστε ο host να μπορεί να προτείνει επανεκκίνηση πάνω στο pdfium.v8.dll. Μετά την ύπαρξη του περιβάλλοντος, το FPDF_LoadXFA καλείται μόνο όταν το RuntimeReady ήταν True, και μόνο αληθής επιστροφή θέτει το FXfaRuntimeUsable, που είναι ό,τι αναφέρει το TPdf.XfaRuntimeAvailable

  if RuntimeReady then
  begin
    FFormFillInfo.Info.xfa_disabled := 0;   // 0 = XFA ενεργοποιημένο
    FFormFillInfo.Info.FFI_DisplayCaret := FormFillDisplayCaret;
    FFormFillInfo.Info.FFI_GetCurrentPageIndex := FormFillGetCurrentPageIndex;
    FFormFillInfo.Info.FFI_SetCurrentPage := FormFillSetCurrentPage;
    FFormFillInfo.Info.FFI_GotoURL := FormFillGotoURL;
    FFormFillInfo.Info.FFI_GetPageViewRect := FormFillGetPageViewRect;
    FFormFillInfo.Info.FFI_PageEvent := FormFillPageEvent;
    FFormFillInfo.Info.FFI_PopupMenu := FormFillPopupMenu;
    FFormFillInfo.Info.FFI_OpenFile := FormFillOpenFile;
    FFormFillInfo.Info.FFI_EmailTo := FormFillEmailTo;
    // ... FFI_UploadTo έως FFI_DoURIActionWithKeyboardModifier
  end
  else if XFA then
  begin
    // Runtime μη διαθέσιμο: κράτα έκδοση 2, άσε XFA απενεργοποιημένο, πες στον host.
    if Assigned(FOnXfaRuntimeMissing) then
      FOnXfaRuntimeMissing(Self);
  end;

  FFormHandle := FPDFDOC_InitFormFillEnvironment(FDocument, FFormFillInfo.Info);
  CheckPdf(FFormHandle <> nil, 'Cannot initialize form fill environment');
  if RuntimeReady then
    FXfaRuntimeUsable := FPDF_LoadXFA(FDocument) <> 0;

Δύο λεπτομέρειες σε εκείνο το block είναι εύκολο να κάνεις λάθος όταν γράφεις το δικό σου binding. Το FXfaPageCountOverride επαναφέρεται στο -1 ως sentinel πριν συμβεί οτιδήποτε άλλο, οπότε το PageCount πέφτει πίσω στο static page tree μέχρι το FFI_PageEvent να αναφέρει επαναδιαμέριση· ένα μηδέν εκεί θα ισχυριόταν σιωπηλά κενό έγγραφο. Και καθεμιά από τις callbacks έκδοσης 2 είναι static ρουτίνα cdecl που ανακτά το owning TPdf από το record και καταπίνει οποιοδήποτε Pascal exception πριν επιστρέψει στο PDFium, που είναι η πειθαρχία που αρθρογραφεί η σημείωσή μας για την ενίσχυση του PDFium ABI στο Delphi για το FFI_OpenFile. Τίποτα στην αλλαγή έκδοσης δεν χαλαρώνει κανέναν από τους δύο κανόνες

Είναι ασφαλής η έκδοση 2 όταν το DLL δεν έχει XFA module;

Ναι, και η αιτία είναι στο record, όχι σε υπόσχεση της βιβλιοθήκης. Σε non-XFA build το header λέει ότι η έκδοση 2 κάνει και τα πειραματικά callbacks να καλούνται, οπότε το ερώτημα είναι τι βρίσκει το PDFium όταν κοιτάζει. Το TPdfFormFillInfo είναι packed record του οποίου το μέλος Info είναι το πλήρες FPDF_FORMFILLINFO συμπεριλαμβανομένου κάθε πεδίου έκδοσης 2, και το InitializeFormFill σβήνει το σύνολο με FillChar πριν αγγίξει έστω ένα byte. Οπότε σε σκέτο pdfium.dll με σκέτο έγγραφο η βιβλιοθήκη βλέπει έκδοση 2, xfa_disabled στραμμένο, και NULL σε κάθε πειραματικό slot, που είναι ακριβώς η κατάσταση που προδιαγράφει το header για host που δεν υλοποιεί XFA. Δεν υπάρχει πεκομένο record για να διαβάσει η βιβλιοθήκη παραπέρα, γιατί το record δεν υπήρξε ποτέ κοντότερο από την έκδοση 2 εξαρχής. Η παλιά λογική αμυνόταν απέναντι σε ασυμφωνία διάταξης που η Pascal δήλωση είχε ήδη εξαλείψει

Το όριο που αξίζει να δηλωθεί τίμια είναι εκείνο που το record δεν μπορεί να καλύψει. Έκδοση 2 πάνω σε σκέτο έγγραφο δεν ανάβει JavaScript, XFA scripting, ή οποιοδήποτε από τα host events πίσω από εκείνα τα callbacks. Το m_pJsPlatform επισυνάπτεται μόνο όταν το V8FeaturesAvailable είναι αληθές, το XFA μένει απενεργοποιημένο εκτός αν το RuntimeReady ήταν αληθές, και το TPdf.XFA εξακολουθεί να αναφέρει τον τύπο φόρμας από το FPDF_GetFormType ανεξαρτήτως τι διαπραγμάτευσε το περιβάλλον. Ένας host που θέλει να ξέρει αν δυναμικό XFA θα αποδοθεί πράγματι πρέπει να συνεχίζει να διαβάζει το XfaRuntimeAvailable αφού το Active γίνει αληθές, όπως συνιστά η σημείωσή μας για την ανίχνευση XFA φορμών και την εξαγωγή XFA packets, και όχι να συλλογίζεται οτιδήποτε από το πεδίο έκδοσης

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Πυροδοτείται από InitializeFormFill όταν το έγγραφο είναι XFA αλλά το
  // φορτωμένο pdfium.dll δεν μπορεί να τρέξει το engine. Το form environment
  // ανοίγει ακόμα, γιατί η έκδοση 2 περάστηκε έτσι κι αλλιώς· μόνο το XFA runtime είναι κλειστό.
  StatusBar.SimpleText :=
    'XFA form detected; restart with pdfium.v8.dll to enable dynamic rendering';
end;

procedure TMainForm.OpenDocument(const FileName: string);
begin
  Pdf.Active := False;
  Pdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  Pdf.FormFill := True;
  Pdf.FileName := FileName;
  Pdf.Active := True;   // δεν πετάει πια exception σε σκέτο PDF κάτω από pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaWarning;
end;

Έκδοση πρωτοκόλλου και διαθεσιμότητα features είναι δύο διαφορετικοί άξονες

Ο γενικός κανόνας που πέφτει από αυτό το fix είναι ότι ένα πεδίο έκδοσης σε δομή callbacks απαντά στην ερώτηση «πόσο μεγάλο είναι αυτό το record και τι επιτρέπεται να διαβάσεις από αυτό», ενώ η ανίχνευση features απαντά στο «ποια από εκείνα τα slots θα κάνουν οτιδήποτε χρήσιμο». Το πρώτο είναι καρφωμένο από το native binary και από τη Pascal δήλωση πάνω στην οποία μεταγλωττίστηκες. Το δεύτερο μεταβάλλεται ανά έγγραφο, ανά πίνακα exports του DLL, και ανά ρύθμιση host. Το να καταρρεύσουν τα δύο σε ένα boolean δελεάζει επειδή η περίπτωση XFA τυχαίνει να χρειάζεται και τα δύο, αλλά τη στιγμή που ένα build επιβάλλει ελάχιστη έκδοση η κατάρρευση σπάει για κάθε έγγραφο που δεν χρειάζεται το feature. Οι XFA φόρμες, περιγεγραμμένες στο ISO 32000-1 §12.7.8 ως payload XML που ζει δίπλα στο dictionary AcroForm, είναι εδώ το feature· η διάταξη του record είναι το πρωτόκολλο, και το PDFium δικαιούται να επιμένει στη διάταξη πριν κοιτάξει ποτέ το αρχείο. Το ίδιο σχήμα εμφανίζεται οπουδήποτε μια βιβλιοθήκη C εκδίδει εκδόσεις στις δομές της: block viewer-info, record render-options, πίνακας platform callbacks. Το ασφαλές pattern είναι εκείνο που ακολουθεί το διορθωμένο InitializeFormFill. Δήλωσε τη νεότερη διάταξη που καταλαβαίνεις, σβήσε την ολότελα, θέσε την έκδοση να ταιριάζει εκείνη τη διάταξη απεριόριστα, και μετά άφησε τους ελέγχους δυνατοτήτων να αποφασίσουν ποια slots να γεμίσουν. Αν ένα μελλοντικό header PDFium προσθέσει έκδοση 3, η αλλαγή πάει στη δήλωση και σε εκείνη τη μία ανάθεση, όχι σε branch εξαρτώμενο από έγγραφο που θα ήταν λάθος για όποιον συνδυασμό δεν τεστάρισε κανείς

Διάγραμμα PDFium Component που χωρίζει τους δύο άξονες πίσω από το FPDF_FORMFILLINFO: η έκδοση πρωτοκόλλου καρφωμένη από τη διάταξη του record και το native binary, και η διαθεσιμότητα features όπου το RuntimeReady μπαρώνει xfa_disabled, δεκαεπτά slots έκδοσης 2, FPDF_LoadXFA και m_pJsPlatform ανά έγγραφο και ανά host
Ένα πεδίο έκδοσης περιγράφει τη μνήμη που επιτρέπεται να διαβάσει η άλλη πλευρά, οι έλεγχοι δυνατοτήτων αποφασίζουν ποια slots κάνουν οτιδήποτε χρήσιμο, και η κατάρρευση των δύο σε ένα boolean σπάει το build που επιβάλλει ελάχιστο

Η διορθωμένη αρχικοποίηση form-fill κυκλοφορεί μέσα στο PDFium Component για Delphi, Lazarus και C++Builder, και εφαρμόζεται σε Win32 και Win64 το ίδιο αφού και τα δύο builds μοιράζονται την ίδια δήλωση record. Αν η εφαρμογή σου επιλέγει ήδη pdfium.v8.dll για AcroForms με JavaScript, αυτή είναι η αλλαγή που της επιτρέπει να ανοίξει το υπόλοιπο PDF archive σου μέσα από το ίδιο binary χωρίς ειδικές περιπτώσεις στο form environment