Una fattura AcroForm con qualche campo testo e nessun contenuto XFA si apre bene attraverso un pdfium.dll normale, eppure lo stesso file può far restituire NULL a FPDFDOC_InitFormFillEnvironment una volta che il processo gira con la pdfium.v8.dll abilitata XFA. I byte del documento sono identici nelle due esecuzioni. A cambiare è un intero: le build XFA di PDFium pretendono FPDF_FORMFILLINFO.version pari a 2, e un binding che scrive 1 per i documenti senza XFA viene rimandato indietro prima che PDFium guardi una sola callback
Perché FPDFDOC_InitFormFillEnvironment rifiuta un PDF AcroForm semplice?
Il rifiuto arriva dal binario, e il documento non c'entra. L'header del form fill, fpdf_formfill.h in PDFium e la sua traduzione Pascal in FPdfFormFill.pas, dichiara che una build compilata col modulo XFA richiede la versione 2, e lo dice due volte nello stesso blocco di commento. Una build senza XFA accetta 1 o 2. Sotto un binding che manda la versione 1 per i file non-XFA, una sola delle quattro combinazioni di binario e documento fallisce: un record versione 1 consegnato a pdfium.v8.dll insieme a un file AcroForm ordinario. La DLL semplice accetta quel record. La DLL V8 accetta un record versione 2 per qualsiasi documento. Una forma XFA vera sulla DLL V8 non riceve mai la versione 1 da un binding che decide la versione in base al documento, quindi la cella che fallisce è precisamente quella a cui nessuno pensa di fare test
Un binding scritto a mano vede FPDFDOC_InitFormFillEnvironment restituire NULL a quel punto e non ha più nulla su cui ragionare. TPdf in PDFium Component segnalerebbe un ambiente rifiutato come EPdfError durante Active := True, ma scrive sempre la versione 2, quindi quella cella non si presenta mai lì. Finirci dentro è facile. Un host mette il globale EnableV8Engine a True perché vuole JavaScript AcroForm, e da quel momento ogni PDF del processo passa per il binario V8. Oppure l'host non tocca il flag, ma il primo file che apre ha una voce /XFA nel suo dizionario AcroForm o /NeedsRendering true nel catalogo: quando la libreria PDFium non è ancora stata caricata, TPdf scansiona il file alla ricerca di quei due marcatori e accende EnableV8Engine prima di caricare la DLL. In entrambi i casi la scelta vale per il resto del processo, e ogni file AcroForm semplice aperto dopo incontra la build XFA. Quale binario distribuire in primo luogo è trattato nella guida al deploy della DLL PDFium e alla diagnosi dei fallimenti di caricamento; tutto quello che segue presuppone che la build V8 sia già caricata
Cosa promette il campo version in FPDF_FORMFILLINFO
FPDF_FORMFILLINFO.version dice a PDFium quanto del record può leggere. La versione 1 copre la parte stabile: Release, le callback da FFI_Invalidate a FFI_DoGoToAction, e il puntatore m_pJsPlatform. La versione 2 aggiunge una coda sperimentale. Una build senza XFA che riceve la versione 2 chiamerà anche le callback sperimentali che trova lì; una build con XFA non accetta meno di così. L'header non menziona mai il documento. Scrivendo 2 prometti che la memoria dopo m_pJsPlatform esiste e che ogni slot in essa contiene o una funzione cdecl valida o NULL
Quella coda comincia con xfa_disabled, un FPDF_BOOL ignorato sotto la versione 2, seguito da diciassette puntatori a funzione da FFI_DisplayCaret a FFI_DoURIActionWithKeyboardModifier. Quindici di loro, da FFI_DisplayCaret a FFI_PutRequestURL, sono documentate come "Required for XFA, otherwise set to NULL". Le ultime due, FFI_OnFocusChange e FFI_DoURIActionWithKeyboardModifier, sono opzionali per ogni host, e l'header aggiunge che un embedder versione 2 che lascia FFI_DoURIActionWithKeyboardModifier non impostato vede i suoi click URI rediretti a FFI_DoURIAction. Mescolando il tutto, NULL è il valore specificato per un host che non guida XFA. Un record azzerato con FillChar e timbrato versione 2 rispetta il contratto su una build semplice esattamente come un record versione 1, ed è anche l'unico record che una build XFA accetterà
Il lato JavaScript segue lo stesso schema. m_pJsPlatform non è usato dalle build senza V8; su una build V8 un puntatore NULL ferma l'esecuzione di JavaScript mentre il documento viene renderizzato. Il record IPDF_JsPlatform a cui punta ha il suo campo version, e l'header dice che quello attualmente deve essere 2
La trappola: lasciare che sia il documento a scegliere la versione ABI
L'errore è un condizionale che da solo sembra sensato. Un binding scritto a mano deve già decidere se le callback XFA valgono la pena di essere cablate, ed è allettante lasciare che sia lo stesso test a scegliere il numero di versione
// Una forma allettante per un binding scritto a mano: la versione segue il documento
FillChar(FormInfo, SizeOf(FormInfo), 0);
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
if IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable then
FormInfo.version := 2
else
FormInfo.version := 1; // ogni documento non-XFA prende 1
FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// su pdfium.v8.dll con un file AcroForm semplice, FormHandle è nil qui
L'intenzione dietro quel ramo è difensiva: tenere la versione a 1 così una build XFA non legge mai slot che l'host non ha riempito. Ma FillChar alla riga sopra li ha già riempiti con NULL, che l'header definisce il valore corretto per un host che non gira XFA. Il ramo protegge da un pericolo che la dichiarazione ha già eliminato e cammina dritto in quello su cui l'header mette in guardia. Non può nemmeno essere beccato testando i file XFA, dato che quelli prendono il percorso versione 2 ogni volta
PDFium Component costruisce il record al contrario. Ogni volta che TPdf.FormFill è True, l'apertura di un documento azzera l'intero record, scrive la versione 2 e xfa_disabled := 1, e solo dopo chiede cosa serve al documento. Enunciato come regola per qualunque binding: imposta la versione dal record che hai dichiarato, e non lasciare che nessun altro la tocchi. Ecco quella forma per il tuo codice, con un wrapper THostFormFillInfo dichiarato più avanti
// FormInfo è un campo THostFormFillInfo del tuo oggetto viewer, quindi sopravvive
// al form handle; HostInvalidate, HostGetPage e gli altri sono tue
// callback cdecl
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;
FormInfo.Info.version := 2; // combacia col layout dichiarato, sempre
FormInfo.Info.xfa_disabled := 1; // XFA resta spenta finché non si prova che è utilizzabile
FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... le altre callback di versione 1
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
RuntimeReady := IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable;
if RuntimeReady then
begin
FormInfo.Info.xfa_disabled := 0;
FormInfo.Info.FFI_DisplayCaret := HostDisplayCaret;
FormInfo.Info.FFI_PageEvent := HostPageEvent;
// ... gli altri slot di versione 2
end;
FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo.Info);
if FormHandle = nil then
raise Exception.Create('Cannot initialize form fill environment');
XfaRunning := RuntimeReady and (FPDF_LoadXFA(Doc) <> 0);
Dove stanno RuntimeReady e V8FeaturesAvailable
I controlli di capacità decidono quali slot vengono riempiti, e in PDFium Component non toccano mai il layout. TPdf calcola la prontezza da tre fatti: il documento dichiara un tipo di form XFA attraverso TPdf.XFA, il globale XfaFeaturesAvailable trova gli helper per stringhe XFA esportati dalla DLL caricata, e V8FeaturesAvailable trova le esportazioni V8. Le callback versione 1 vengono cablate per ogni documento, perché AcroForm e XFA contano entrambe su di loro. Tutti i diciassette slot versione 2 vengono assegnati, e xfa_disabled azzerato, solo quando tutti e tre i fatti reggono. Dopo che l'ambiente esiste, FPDF_LoadXFA gira solo per quel caso, e solo un ritorno non nullo fa sì che TPdf.XfaRuntimeAvailable dichiari True. FPDF_LoadXFA è esportata anche dalle build senza l'engine XFA e lì semplicemente restituisce false, ecco perché il controllo sull'esportazione da solo non basta
Quando il documento è XFA ma il runtime manca, il record resta a versione 2 con xfa_disabled a 1 e la coda lasciata NULL. L'ambiente si apre, il contenuto XFA resta statico, e TPdf solleva OnXfaRuntimeMissing così l'host può suggerire di riavviare su pdfium.v8.dll. m_pJsPlatform segue una terza regola: viene attaccato ogni volta che V8FeaturesAvailable è true, anche per i file AcroForm semplici, ed è ciò che fa girare gli script a livello documento, i calcoli dei campi e app.alert sotto la build V8. PageCount legge l'albero pagine statico finché XFA non reimpagina il documento attraverso FFI_PageEvent; a quel punto il nuovo totale viene preso da FPDF_GetPageCount e riportato attraverso OnXfaPageCountChanged
Sul lato host, due proprietà rispondono a due domande diverse. XFA ti dice cosa contiene il file, da FPDF_GetFormType, indipendentemente dal binario. XfaRuntimeAvailable ti dice se l'engine è davvero partito per questo documento. Leggi la seconda dopo che Active è diventato True, come fa l'articolo sul rilevamento delle form XFA e l'estrazione dei pacchetti XFA, e togli del tutto il campo version dal tuo ragionamento
procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
// Scatenato mentre il documento si apre quando è XFA ma la DLL caricata non
// riesce a far girare l'engine. L'ambiente form esiste ancora, con XFA disabilitata
StatusBar.SimpleText :=
'XFA form detected; restart with pdfium.v8.dll for 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; // anche i file AcroForm semplici si aprono sotto pdfium.v8.dll
if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
ShowStaticXfaNotice; // la tua routine UI
end;
Errori di layout che rompono il record prima che la versione venga letta
Un numero di versione corretto serve solo se i byte dietro di lui stanno dove il compilatore C li ha messi. Diversi errori di binding producono un record che PDFium legge male a prescindere dalla versione che porta, e uno di loro resta invisibile su Win32. È dichiarare FPDF_FORMFILLINFO come packed record. Su Win32 ogni campo è di quattro byte, quindi i layout packed e allineati per caso coincidono. Su Win64 la struct C aggiunge quattro byte di padding dopo version così che Release cada su un confine di 8 byte, e aggiunge padding anche dopo xfa_disabled; una dichiarazione Pascal packed butta via entrambi i varchi e sposta di quattro byte ogni puntatore a callback. La dichiarazione in FPdfFormFill.pas è un record semplice per quello
Il secondo errore è un record troncato: una traduzione che si ferma a m_pJsPlatform ma scrive la versione 2. PDFium allora legge la coda da qualunque memoria segua il record. Il terzo è il lifetime. L'header dice che il record passato a FPDFDOC_InitFormFillEnvironment deve restare valido finché il form handle restituito non viene chiuso, quindi un record tenuto in una variabile locale della procedura che apre il documento è un puntatore pendente nel momento esatto in cui quella procedura ritorna. TPdf tiene il suo record come campo del componente, avvolto in un record esterno che mette la struct PDFium a offset zero e un back-pointer al TPdf proprietario subito dopo, ed è così che ogni callback statica ritrova il suo oggetto
type
// Sbagliato su Win64: il packing toglie il padding che la struct C ha dopo
// version, quindi Release sta a offset 4 qui e a offset 8 in PDFium
TBadFormFillInfo = packed record
version: Integer;
Release: Pointer;
// ... gli altri campi
end;
// Il tuo wrapper: prima il record PDFium, dopo il tuo back-pointer
THostFormFillInfo = record
Info: FPDF_FORMFILLINFO; // offset 0, quindi pThis si ricasta dritto
Owner: TObject;
end;
PHostFormFillInfo = ^THostFormFillInfo;
procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
left, top, right, bottom: Double); cdecl;
begin
try
// TMyViewer e InvalidatePdfRect stanno per la tua classe viewer
TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
page, left, top, right, bottom);
except
// un'eccezione Pascal non deve fare unwind attraverso i frame C++ di PDFium
end;
end;
Altri due slot meritano attenzione. FFI_GetLocalTime restituisce un FPDF_SYSTEMTIME di 16 byte per valore, e Delphi e MSVC su Win32 non sono d'accordo su dove vada il puntatore del risultato nascosto, quindi un'implementazione Pascal corromperebbe la FPDF_FORMFILLINFO del chiamante. Il PDFium attuale marca il membro come unused, e la dichiarazione Pascal porta un commento che invita a lasciarlo nil; PDFium Component lo fa. Ogni callback che il componente installa davvero intercetta le eccezioni Pascal e restituisce il valore di fallimento documentato, perché l'ABI C non ha alcun modo di riportare un'eccezione al di là del confine. Il pezzo sull'hardening dell'ABI PDFium in Delphi percorre quella disciplina usando FFI_OpenFile come esempio
Versione del protocollo e disponibilità delle funzionalità sono due assi
Un campo version in una struttura di callback risponde a quanto è grande il record e a cosa se ne può leggere. Il feature detection risponde a quali di quegli slot faranno qualcosa. Il primo è fissato dal binario nativo e dalla dichiarazione contro cui hai compilato; il secondo varia per documento, per tabella di esportazione della DLL e per impostazione dell'host. Il caso XFA serve entrambe le risposte in una volta, il che rende allettante ripiegarle in un booleano, e quel ripiegamento si rompe nel momento in cui una build impone una versione minima. Le form XFA, che ISO 32000-1 §12.7.8 descrive come un payload XML trasportato accanto al dizionario AcroForm, sono la funzionalità di questa storia. Il layout del record è il protocollo, e PDFium ha il diritto di controllare il layout prima di leggere il file
La stessa struttura riappare ovunque una libreria C versioni le sue struct: FPDF_LIBRARY_CONFIG, IPDF_JsPlatform, qualsiasi tabella di callback con un header di size o versione. Dichiara il layout più nuovo che capisci, azzeralo del tutto, scrivi la versione corrispondente incondizionatamente, e riempi gli slot in base a ciò che documento e DLL sanno fare davvero. Se un futuro fpdf_formfill.h introduce la versione 3, il lavoro è una nuova dichiarazione e un assegnamento cambiato. Un ramo deciso dal contenuto del documento andrebbe rivisto per ogni combinazione di build e file, e quella su cui sbaglia è di solito quella che nessuno ha mai aperto
La gestione del record descritta qui fa parte di PDFium Component per Delphi, Lazarus e C++Builder, con la stessa dichiarazione al servizio delle build Win32 e Win64. Un'applicazione che distribuisce già pdfium.v8.dll per AcroForm guidati da JavaScript può aprire il resto del suo archivio, XFA o non XFA, attraverso quel solo binario senza gestione speciale per l'ambiente form