Artículo técnico

FPDF_FORMFILLINFO versión 2 en Delphi: sigue el ABI del DLL

Una factura AcroForm con un puñado de campos de texto y sin contenido XFA se abre sin problemas a través de un pdfium.dll llano, y sin embargo el mismo archivo puede hacer que FPDFDOC_InitFormFillEnvironment devuelva NULL una vez que el proceso corre con el pdfium.v8.dll con XFA habilitado. Los bytes del documento son idénticos en ambas ejecuciones. Lo que difiere es un entero: las builds de PDFium con XFA exigen que FPDF_FORMFILLINFO.version sea 2, y un binding que escribe 1 para documentos sin XFA es rechazado antes de que PDFium mire un solo callback

¿Por qué FPDFDOC_InitFormFillEnvironment rechaza un PDF AcroForm normal?

El rechazo viene del binario, y el documento no tiene nada que decir. La cabecera de form-fill, fpdf_formfill.h en PDFium y su traducción Pascal en FPdfFormFill.pas, afirma que una build compilada con el módulo XFA exige la versión 2, y lo dice dos veces en el mismo bloque de comentarios. Una build sin XFA acepta 1 o 2. Con un binding que envía la versión 1 para archivos no XFA, solo falla una de las cuatro combinaciones de binario y documento: un registro de versión 1 entregado a pdfium.v8.dll junto con un archivo AcroForm ordinario. El DLL llano acepta ese registro. El DLL V8 acepta un registro de versión 2 para cualquier documento. Un formulario XFA real sobre el DLL V8 nunca recibe la versión 1 de un binding que derive la versión del documento, así que la celda que falla es precisamente la que nadie piensa en probar

Un binding escrito a mano ve ahí que FPDFDOC_InitFormFillEnvironment devuelve NULL y no tiene más información. TPdf en PDFium Component reportaría un entorno rechazado como un EPdfError durante Active := True, pero siempre escribe la versión 2, así que esa celda nunca aparece ahí. Caer en esa celda es fácil. Un host pone el EnableV8Engine global a True porque quiere JavaScript de AcroForm, y desde entonces cada PDF del proceso pasa por el binario V8. O el host nunca toca el flag, pero el primer archivo que abre tiene una entrada /XFA en su diccionario AcroForm o /NeedsRendering true en su catálogo: cuando la librería PDFium aún no está cargada, TPdf escanea el archivo en busca de esos dos marcadores y activa EnableV8Engine antes de cargar el DLL. En cualquier caso la elección se mantiene para el resto del proceso, y cada archivo AcroForm normal abierto después se encuentra con la build XFA. Qué binario distribuir en primer lugar está tratado en la guía para desplegar el DLL de PDFium y diagnosticar fallos de carga; todo lo que sigue asume que la build V8 ya está cargada

Diagrama de PDFium Component de las cuatro combinaciones del pdfium.dll normal y el pdfium.v8.dll con XFA frente a documentos AcroForm y XFA: un registro de versión 1 solo falla en la build V8 con un formulario normal, en FPDFDOC_InitFormFillEnvironment, mientras que un registro de versión 2 abre las cuatro
Atar la versión del ABI al documento significa que la elección del binario V8 para todo el proceso convierte cada PDF normal posterior en una inicialización de entorno fallida

Qué promete el campo version de FPDF_FORMFILLINFO

FPDF_FORMFILLINFO.version le dice a PDFium cuánto del registro puede leer. La versión 1 cubre la parte estable: Release, los callbacks de FFI_Invalidate a FFI_DoGoToAction, y el puntero m_pJsPlatform. La versión 2 añade una cola experimental. Una build sin XFA que recibe la versión 2 también llamará a los callbacks experimentales que encuentre ahí; una build con XFA no acepta nada menos. La cabecera nunca menciona el documento. Al escribir 2 usted promete que la memoria posterior a m_pJsPlatform existe y que cada hueco de ella contiene o bien una función cdecl válida o bien NULL

Esa cola empieza con xfa_disabled, un FPDF_BOOL que se ignora por debajo de la versión 2, seguido de diecisiete punteros de función desde FFI_DisplayCaret hasta FFI_DoURIActionWithKeyboardModifier. Quince de ellos, de FFI_DisplayCaret a FFI_PutRequestURL, están documentados como "Required for XFA, otherwise set to NULL". Los dos últimos, FFI_OnFocusChange y FFI_DoURIActionWithKeyboardModifier, son opcionales para cualquier host, y la cabecera añade que un embedder de versión 2 que deje FFI_DoURIActionWithKeyboardModifier sin asignar ve cómo sus clics de URI se redirigen a FFI_DoURIAction. En conjunto, NULL es el valor especificado para un host que no gobierna XFA. Un registro limpiado con FillChar y marcado con la versión 2 cumple el contrato en una build llana exactamente igual que un registro de versión 1, y es además el único registro que una build con XFA aceptará

Diagrama de PDFium Component del registro FPDF_FORMFILLINFO en Delphi: la versión 1 cubre los callbacks de FFI_Invalidate a FFI_DoGoToAction más m_pJsPlatform, la versión 2 añade xfa_disabled y diecisiete punteros de la era de FFI_DisplayCaret, FillChar limpia todos los bytes, y los huecos a NULL son el estado documentado para un host que no gobierna XFA
El registro Pascal es siempre el diseño completo de la versión 2, así que una build con XFA lo acepta y una build normal simplemente nunca llama a los huecos experimentales que se quedan a NULL

El lado de JavaScript sigue el mismo patrón. m_pJsPlatform no lo usan las builds sin V8; en una build V8 un puntero NULL impide que JavaScript corra mientras el documento se renderiza. El registro IPDF_JsPlatform al que apunta tiene su propio campo version, y la cabecera dice que ese actualmente debe ser 2

La trampa: dejar que el documento elija la versión del ABI

El error es un único condicional que, leído por separado, parece sensato. Un binding escrito a mano ya tiene que decidir si merece la pena cablear los callbacks XFA, y es tentador dejar que la misma prueba elija el número de versión

// Una forma tentadora para un binding escrito a mano: la versión sigue al 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;   // todo documento no XFA recibe 1

FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// en pdfium.v8.dll con un archivo AcroForm normal, FormHandle es nil aquí

La intención detrás de esa rama es defensiva: mantener la versión en 1 para que una build XFA no lea nunca huecos que el host no rellenó. Pero el FillChar de la línea de arriba ya los rellenó con NULL, que la cabecera define como el valor correcto para un host que no ejecuta XFA. La rama se defiende de un peligro que la declaración ya eliminó y camina directo hacia el que la cabecera advierte. Además no se puede cazar probando archivos XFA, porque esos toman siempre el camino de la versión 2

PDFium Component construye el registro al revés. Siempre que TPdf.FormFill es True, abrir un documento limpia el registro completo, escribe la versión 2 y xfa_disabled := 1, y solo entonces pregunta qué necesita el documento. Dicho como regla para cualquier binding: fije la versión a partir del registro que declaró, y no deje que nada más la toque. Ahí tiene esa forma para su propio código, con un wrapper THostFormFillInfo declarado más abajo

// FormInfo es un campo THostFormFillInfo de su objeto viewer, así que sobrevive
// al form handle; HostInvalidate, HostGetPage y el resto son callbacks cdecl
// suyos
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;

FormInfo.Info.version := 2;        // coincide con el diseño declarado, siempre
FormInfo.Info.xfa_disabled := 1;   // XFA sigue apagado salvo prueba de utilizable

FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... los demás callbacks de la versión 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;
  // ... los huecos de la versión 2 restantes
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);

Dónde pertenecen RuntimeReady y V8FeaturesAvailable

Las comprobaciones de capacidad deciden qué huecos se rellenan, y en PDFium Component nunca tocan el diseño. TPdf calcula la disponibilidad a partir de tres hechos: el documento informa de un tipo de formulario XFA a través de TPdf.XFA, el XfaFeaturesAvailable global encuentra los helpers de cadenas XFA exportados por el DLL cargado, y V8FeaturesAvailable encuentra las exportaciones V8. Los callbacks de la versión 1 se cablean para cada documento, porque AcroForm y XFA dependen ambos de ellos. Los diecisiete huecos de la versión 2 se asignan, y xfa_disabled se limpia, solo cuando se cumplen los tres hechos. Una vez existe el entorno, FPDF_LoadXFA se ejecuta solo para ese caso, y solo un retorno distinto de cero hace que TPdf.XfaRuntimeAvailable informe True. FPDF_LoadXFA se exporta incluso en builds sin el motor XFA y ahí simplemente devuelve false, que es por lo que la comprobación de la exportación por sí sola no basta

Cuando el documento es XFA pero falta el runtime, el registro se queda en versión 2 con xfa_disabled a 1 y la cola a NULL. El entorno se abre, el contenido XFA queda estático, y TPdf lanza OnXfaRuntimeMissing para que el host pueda sugerir reiniciar con pdfium.v8.dll. m_pJsPlatform sigue una tercera regla: se engancha siempre que V8FeaturesAvailable sea true, también para archivos AcroForm normales, que es lo que permite que los scripts a nivel de documento, los cálculos de campos y app.alert corran bajo la build V8. PageCount lee el árbol de páginas estático hasta que XFA repagina el documento a través de FFI_PageEvent; en ese momento el nuevo total se toma de FPDF_GetPageCount y se reporta mediante OnXfaPageCountChanged

Del lado del host, dos propiedades responden a dos preguntas distintas. XFA le dice qué contiene el archivo, desde FPDF_GetFormType, independientemente del binario. XfaRuntimeAvailable le dice si el motor arrancó de verdad para este documento. Lea la segunda cuando Active haya pasado a True, como hace el artículo sobre detectar formularios XFA y extraer paquetes XFA, y deje el campo version fuera del razonamiento por completo

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Se lanza mientras el documento se abre cuando es XFA pero el DLL cargado no
  // puede ejecutar el motor. El entorno de formulario sigue existiendo, con XFA desactivado
  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;   // los archivos AcroForm normales también se abren bajo pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaNotice;   // su propia rutina de UI
end;

Errores de diseño que rompen el registro antes de que se lea la versión

Un número de versión correcto solo sirve si los bytes detrás de él están donde el compilador de C los puso. Varios errores de binding producen un registro que PDFium malinterpreta sin importar qué versión lleve, y uno de ellos permanece invisible en Win32. Es declarar FPDF_FORMFILLINFO como packed record. En Win32 cada campo es de cuatro bytes, así que los diseños packed y alineados coinciden por casualidad. En Win64 el struct de C rellena cuatro bytes después de version para que Release caiga en un límite de 8 bytes, y vuelve a rellenar después de xfa_disabled; una declaración Pascal packed elimina ambos huecos y desplaza cada puntero de callback cuatro bytes. La declaración en FPdfFormFill.pas es un record llano por esa razón

El segundo error es un registro truncado: una traducción que se detiene en m_pJsPlatform pero escribe la versión 2. PDFium lee entonces la cola de la memoria que siga al registro. El tercero es el ciclo de vida. La cabecera dice que el registro pasado a FPDFDOC_InitFormFillEnvironment debe seguir válido hasta que se cierre el form handle devuelto, así que un registro en una variable local del procedimiento que abre el documento es un puntero colgante en cuanto ese procedimiento retorna. TPdf guarda su registro como campo del componente, envuelto en un registro externo que coloca el struct de PDFium en el offset cero y un back-pointer al TPdf dueño justo después, que es como cada callback estático encuentra de nuevo su objeto

type
  // Equivocado en Win64: el packing elimina el padding que el struct C tiene tras
  // version, así que Release queda en el offset 4 aquí y en el offset 8 en PDFium
  TBadFormFillInfo = packed record
    version: Integer;
    Release: Pointer;
    // ... campos restantes
  end;

  // Su propio wrapper: primero el registro de PDFium, después su back-pointer
  THostFormFillInfo = record
    Info: FPDF_FORMFILLINFO;   // offset 0, así que pThis vuelve a convertirse directo
    Owner: TObject;
  end;
  PHostFormFillInfo = ^THostFormFillInfo;

procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
  left, top, right, bottom: Double); cdecl;
begin
  try
    // TMyViewer e InvalidatePdfRect representan su propia clase viewer
    TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
      page, left, top, right, bottom);
  except
    // una excepción Pascal no debe desenrollarse por los frames C++ de PDFium
  end;
end;

Dos huecos más merecen cuidado. FFI_GetLocalTime devuelve un FPDF_SYSTEMTIME de 16 bytes por valor, y Delphi y MSVC discrepan en Win32 sobre dónde va el puntero de resultado oculto, así que una implementación Pascal corrompería el FPDF_FORMFILLINFO del llamador. PDFium actual marca el miembro como no usado, y la declaración Pascal lleva un comentario para dejarlo a nil; PDFium Component lo hace. Cada callback que el componente instala captura las excepciones Pascal y devuelve el valor de fallo documentado, porque el ABI de C no tiene forma de llevar una excepción de vuelta por el límite. El artículo sobre endurecer el ABI de PDFium en Delphi recorre esa disciplina usando FFI_OpenFile como ejemplo

Versión de protocolo y disponibilidad de funciones son dos ejes

Un campo de versión en una estructura de callbacks responde a qué tamaño tiene el registro y qué se puede leer de él. La detección de funciones responde a cuáles de esos huecos harán algo. Lo primero lo fijan el binario nativo y la declaración contra la que usted compiló; lo segundo varía por documento, por tabla de exportaciones del DLL y por ajuste del host. El caso XFA necesita ambas respuestas a la vez, lo que hace tentador fundirlas en un solo booleano, y esa fusión se rompe en cuanto una build impone una versión mínima. Los formularios XFA, que ISO 32000-1 §12.7.8 describe como una carga XML que viaja junto al diccionario AcroForm, son la función de esta historia. El diseño del registro es el protocolo, y PDFium tiene derecho a comprobar el diseño antes de leer el archivo

Diagrama de PDFium Component que separa los dos ejes que hay detrás de FPDF_FORMFILLINFO: la versión de protocolo, fijada por el diseño del registro y el binario nativo, y la disponibilidad de funciones, donde RuntimeReady condiciona xfa_disabled, los diecisiete huecos de la versión 2, FPDF_LoadXFA y m_pJsPlatform por documento y por host
Un campo de versión describe la memoria que la otra parte puede leer, las comprobaciones de capacidad deciden qué huecos hacen algo útil, y fundir ambos en un booleano rompe la build que impone un mínimo

La misma estructura aparece donde una librería C versione sus structs: FPDF_LIBRARY_CONFIG, IPDF_JsPlatform, cualquier tabla de callbacks con cabecera de tamaño o versión. Declare el diseño más nuevo que entienda, límpielo por completo, escriba la versión correspondiente de forma incondicional y rellene los huecos según lo que el documento y el DLL puedan hacer de verdad. Si un futuro fpdf_formfill.h introduce la versión 3, el trabajo es una declaración nueva y una asignación cambiada. Una rama condicionada al contenido del documento habría que revisarla para cada combinación de build y archivo, y la que equivoca suele ser la que nadie abrió

El manejo del registro descrito aquí es parte de PDFium Component para Delphi, Lazarus y C++Builder, con la misma declaración sirviendo para las builds Win32 y Win64. Una aplicación que ya distribuye pdfium.v8.dll para AcroForms dirigidos por JavaScript puede abrir el resto de su archivo, XFA o no, a través de ese único binario sin manejo especial para el entorno de formulario