Artículo técnico

XFA dinámico en PDFium Component: el recuento es un delta

Cuando un formulario XFA dinámico en un visor Delphi añade o quita páginas, PDFium Component reporta el total nuevo por medio de TPdf.PageCount y TPdf.OnXfaPageCountChanged desde v3.126.1, porque el evento de página nativo lleva un delta de añadidas/quitadas en lugar de un total. Las bibliotecas Windows V8 de v3.126.1 también mueven las áreas de pulsación con los campos reubicados, y v3.126.2 recarga los manejadores de página obsoletos tras volver del callback de layout. El reporte de bug que inició esto era un formulario de nota de gastos: pulse Add Row dos veces, el formulario crece a dos páginas, y el indicador de página presume orgullosamente de un 1 de 1. Teclee en un campo que se mudó a la página 2 y las teclas caen en algún sitio invisible. Nada de eso aparecía con los formularios de muestra de longitud fija con los que todo el mundo prueba primero, y las razones valen la pena si usted incrusta un visor de formularios

¿Qué pasa cuando un formulario XFA dinámico se repagina?

Un formulario XFA dinámico no tiene lista de páginas fija, así que su recuento de páginas es una salida del layout y puede cambiar cada vez que el usuario edita datos. XFA 3.3 describe el formulario como un árbol de subforms; un subform repetitivo lo controla un instanceManager, y un script como _Row.addInstance() clona una fila más. El procesador de layout entonces vuelve a fluir el contenido hacia las áreas de página, lo que puede añadir una página, quitar una o empujar campos existentes a otra página. ISO 32000-1 §12.7.8 solo define cómo viajan los paquetes XFA dentro del PDF; todo lo que pasa después pertenece al motor XFA, que en PDFium Component es el layout XFA del propio PDFium corriendo en el proceso anfitrión. Un visor Delphi trata por tanto con un documento cuyo recuento de páginas, tamaños de página y posiciones de widget son todo estado vivo. Tres cosas salen mal cuando el anfitrión asume otra cosa:

  • El recuento de páginas que el anfitrión cachea para navegación, rangos de scroll e indicadores de página caduca, o peor, se actualiza con el número equivocado
  • Los campos que se reubican muestran su borde en la posición nueva mientras el editor y el área de pulsación del ratón quedan en las coordenadas viejas
  • El visor conserva un manejador de página que el layout ha reemplazado, así que los clics y los pintados van a una página que ya no existe en ese formulario

Persistir las ediciones de filas entre guardado y reapertura es un problema aparte con reglas propias; este artículo se queda con lo que pasa en ejecución dentro del visor

¿Qué runtime de PDFium necesita el XFA dinámico?

El XFA dinámico en PDFium Component requiere la compilación V8/XFA de la biblioteca nativa, seleccionada por la variable global EnableV8Engine de la unidad PDFium antes de que cargue el primer documento. El proceso se compromete con una DLL la primera vez que cualquier TPdf carga la biblioteca, y una compilación PDFium plana no puede ejecutar el motor XFA para nada. Al abrir un documento, TPdf sí espía el archivo en busca de marcadores XFA y cambia a la compilación V8 automáticamente, pero solo si ninguna biblioteca plana se ha cargado aún en ese proceso. Cuando el compromiso ya fue por el camino equivocado, TPdf.OnXfaRuntimeMissing se dispara una vez para que el anfitrión pueda decirle al usuario que reinicie. Fijar el flag explícitamente al arrancar elimina las conjeturas. La estructura de callbacks FPDF_FORMFILLINFO que lleva los eventos XFA también debe corresponder con la DLL; el trasfondo está en FPDF_FORMFILLINFO versión 2 y el ABI de callbacks XFA, y detectar formularios XFA y leer sus paquetes cubre cómo distinguir los tipos de formulario antes de abrir un visor

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // Decida antes de que el primer TPdf cargue la biblioteca nativa:
  // el proceso no puede cambiar de pdfium.dll a pdfium.v8.dll después
  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;

¿Por qué PageCount reportaba 1 para un formulario de dos páginas?

Antes de v3.126.1, PDFium Component guardaba el argumento page_count del evento de página nativo como total del documento, y ese argumento es en realidad la diferencia absoluta entre el recuento de páginas nuevo y el viejo. PDFium lanza FFI_PageEvent cuando termina una pasada de layout con un tipo de evento de página añadida o página quitada; internamente actualiza primero su recuento de páginas guardado y después pasa abs(new - old). En el layout inicial el recuento viejo es cero, así que el delta equivale al total, y una muestra estática de tres páginas reporta tres páginas como se espera. Es exactamente por eso que los formularios de prueba de longitud fija jamás destaparon el bug. La primera vez que un formulario dinámico crece de una página a dos, el delta es 1, y el wrapper ponía tanto TPdf.PageCount como el parámetro NewCount de OnXfaPageCountChanged a 1. Quitar una fila de un formulario de tres páginas producía el mismo tipo de disparate en la otra dirección

Acumular el delta sobre el valor anterior tampoco es una reparación segura. El orden de los callbacks de inicialización y layout significa que el wrapper no siempre puede fiarse de su recuento anterior como línea base, así que una suma en marcha puede desviarse. Desde v3.126.1, el callback ignora el argumento como recuento y llama a FPDF_GetPageCount sobre el documento, que lee el total del layout que acaba de completarse. Después limpia las escenas de página cacheadas, guarda ese total como el override de recuento de páginas XFA detrás de TPdf.PageCount, y solo entonces lanza OnXfaPageCountChanged. Para cuando corre su handler, NewCount y FPdf.PageCount coinciden

Diagrama de XFA dinámico de PDFium Component donde añadir una fila repagina un formulario de una página a dos y FFI_PageEvent pasa abs(nuevo menos viejo) como delta, así que el wrapper antiguo reportaba TPdf.PageCount 1 mientras v3.126.1 lee FPDF_GetPageCount y reporta el total correcto
El evento de página nativo reporta un delta de añadidas o quitadas, no un total, así que v3.126.1 ignora el argumento y lee el layout completado antes de lanzar OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+: NewCount es el total del layout completado, jamás un delta.
  // Esto corre dentro del callback de layout de PDFium: actualice solo el estado de UI del anfitrión,
  // no cierre el documento ni recargue páginas desde aquí
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // Se dispara tras cada recarga de página, incluido el refresco XFA diferido
  PageSpin.Value := PdfView1.PageNumber;
end;

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

El evento solo se dispara para formularios Full XFA cuyo layout cambia en ejecución. Los documentos Static XFA y AcroForm jamás lo lanzan, así que un visor que maneje ambos puede dejar asignado el mismo handler. Dejarlo sin asignar también es seguro; el override detrás de TPdf.PageCount se aplica de todos modos, y el evento existe para que el anfitrión pueda refrescar lo que haya cacheado

¿Por qué el cuadro de entrada se queda en la página vieja cuando un campo se muda?

El borde se movió y el editor no porque el notificador XFA nativo comparaba un rectángulo consigo mismo. Cuando el layout cambia la geometría de un widget ya cargado, se supone que PDFium se percata del rectángulo nuevo y llama a PerformLayout sobre el widget, que recoloca el editor de texto y su área de pulsación. La comprobación comparaba GetWidgetRect() con RecacheWidgetRect(). Ambas funciones devuelven una referencia const al mismo miembro, y el recache sobrescribe ese miembro in situ, así que la comparación siempre veía dos valores idénticos y los widgets cargados se saltaban su relayout

El síntoma afloró cuando una prueba cambiaba la altura de un subform para que campos existentes cruzaran a la página siguiente. En ambas arquitecturas V8, el borde del campo se dibujaba en su posición nueva mientras el texto tecleado y el área de pulsación del ratón se quedaban en la coordenada Y anterior. Un relayout explícito no lo arreglaba, y recargar la página tampoco, porque el widget seguía creyendo que su geometría estaba al día. Las bibliotecas Windows V8 que vienen con v3.126.1 copian el rectángulo viejo por valor antes de recachear y comparan esa copia, así que los widgets mudados hacen relayout y el valor editado aparece exactamente donde está el borde. Este es un arreglo nativo: viaja con las DLLs, así que actualizar las unidades Pascal manteniendo una pdfium.v8.dll más vieja deja las áreas de pulsación descuidadas donde estaban. La comprobación de regresión que lo impulsó edita primero una fila superviviente a un valor no por defecto y luego exige ese valor en la ubicación nueva del campo, porque una fila reconstruida con valores por defecto parecería un pase

Diagrama de relayout de widget de PDFium Component contrastando la autocomparación antigua donde GetWidgetRect y RecacheWidgetRect devolvían un miembro compartido así que los widgets mudados se saltaban PerformLayout, con la comprobación de copia por valor de Windows V8 de v3.126.1 que recoloca el editor y el área de pulsación del ratón sobre el borde redibujado
Comparar un rectángulo consigo mismo nunca falla, así que el borde se movía mientras el texto tecleado y los clics se quedaban atrás hasta que la comprobación guardó primero una copia por valor

¿Cómo recarga páginas TPdfView sin arrancarle un manejador a PDFium por debajo?

Desde v3.126.2, TPdfView difiere la recarga de página que sigue a un cambio de layout XFA hasta que la pila de llamadas nativa se ha desenrollado. El evento de página suele dispararse mientras PDFium sigue procesando entrada: el usuario pulsó un botón Add Row, el clic ejecutó un script, el script cambió el recuento de instancias, y el layout terminó dentro de esa misma llamada nativa. Cerrar y reabrir el manejador de página en ese momento liberaría un objeto que el llamador sigue usando. Antes de v3.126.2, el visor solo se invalidaba a sí mismo, así que el manejador de página mostrado podía seguir apuntando a estado previo al layout, y si el usuario estaba en la última página cuando desapareció, el número de página seleccionado quedaba fuera de rango

El refresco diferido funciona en unos pocos pasos pequeños, y explican el comportamiento que usted ve desde el anfitrión:

  1. El callback de evento de página marca la vista como con un refresco de layout XFA pendiente y publica un mensaje de ventana privado; los eventos repetidos antes de que llegue el mensaje se fusionan en un solo refresco
  2. Una vista que aún no tiene manejador de ventana conserva el flag pendiente y publica el mensaje desde CreateWnd, mientras que cambiar de documentos, desactivar la vista o destruirla limpia el flag
  3. Cuando llega el mensaje, la vista limpia la selección de texto, el resaltado de búsqueda y el índice del campo enfocado, porque los tres se referían al layout viejo
  4. La página seleccionada se acota al PageCount nuevo; un número de página cambiado pasa por el cambio de página normal, si no se recarga la página actual, y el modo de ajuste se vuelve a aplicar
  5. Si el layout no deja páginas en absoluto, la vista descarga su manejador de página viejo en lugar de pintar una página que ya no existe
Diagrama de refresco XFA diferido de TPdfView de PDFium Component donde un evento de página dentro de la pila de llamadas nativa de layout solo marca un refresco pendiente y publica un mensaje de ventana, que después limpia el estado de selección obsoleto, acota la página al PageCount nuevo y recarga o descarga el manejador de página
La recarga espera a que la pila de llamadas nativa se desenrolle: un mensaje publicado fusiona los eventos repetidos, luego la vista acota la página, la recarga y lanza OnPageChange

La misma restricción aplica a su propio código. OnXfaPageCountChanged corre dentro de ese callback de layout nativo, así que trátelo como una notificación: actualice etiquetas, rangos de spinner y estado de la barra de herramientas allí, y encole todo lo más pesado, como cerrar el documento o abrir otro, con un mensaje publicado para que corra tras volver del callback. TPdfView.OnPageChange le dice entonces cuándo la vista ha recargado de verdad la página, y leer PdfView1.PageNumber en ese punto le da el valor acotado. El recorrido con la tecla Tab y las comprobaciones FormType que un visor de formularios ejecuta al abrir están tratadas en navegación de campos de formulario PDF con PDFium Component

¿Por qué pulsar un campo Full XFA lanza «Cannot open text page»?

Las páginas Full XFA no tienen página de texto PDF, y antes de v3.126.2 la selección de texto por defecto del visor y la detección de enlaces intentaban cargar una de todos modos. Con TPdfView.AllowUserTextSelection en su valor por defecto True, el hover preguntaba a la capa de texto por un carácter bajo el ratón, y un clic de soltar ratón corría una sonda automática de URL sobre el texto de la página. En una página Full XFA la página de texto no puede abrirse, así que un clic corriente en un campo podía acabar en una excepción Cannot open text page. Desde v3.126.2, ambos caminos internos no devuelven resultado cuando TPdf.FormType es ftXfaFull y el runtime XFA está disponible, así que los ajustes por defecto funcionan y la entrada en campos sigue disponible

Apagar AllowUserTextSelection para documentos Full XFA sigue siendo una decisión de UI razonable, porque no hay texto de página que seleccionar y los gestos de arrastre no deberían arrancar un modo de selección. No es un sustituto de actualizar, eso sí: en versiones anteriores la sonda de URL al pulsar no dependía de esa propiedad, así que un visor podía toparse con la misma excepción con la selección desactivada

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormType lee el documento abierto, así que llame esto tras FPdf.Active := True
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // No existe capa de texto PDF en las páginas Full XFA; los campos siguen editables
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

Teclear necesitó su propia reparación en v3.126.2. El editor de texto XFA nativo no reemplaza una selección cuando recibe un carácter: FORM_OnChar inserta en el cursor, y Backspace borra un solo carácter, así que seleccionar un valor y teclear encima producía texto viejo y nuevo uno al lado del otro. PDFium Component recuerda ahora que el clic aterrizó en un campo de texto XFA y enruta los caracteres tecleados, Backspace y Delete por FORM_ReplaceSelection siempre que exista una selección y el documento conceda permiso de rellenar formularios o de modificar. Si un campo XFA de solo lectura puede cambiar lo sigue decidiendo el editor nativo, así que un campo marcado de solo lectura en el formulario conserva su valor incluso en un documento que por lo demás permite rellenar. Poner TPdfView.AllowFormEvents a False también detiene este enrutado de teclado, lo que mantiene de solo lectura un visor de solo lectura

Referencia rápida: XFA dinámico en un visor Delphi

SíntomaCausaArreglado en
El recuento muestra 1 tras crecer el formulario a dos páginasEl evento de página nativo pasa un delta de añadidas/quitadas, no un totalv3.126.1 (wrapper)
El borde del campo se mueve, el texto tecleado y el área de pulsación se quedan atrásEl widget cargado se saltaba el relayout tras una autocomparaciónv3.126.1 (bibliotecas Windows V8)
El visor pinta o enruta entrada a estado de página previo al layoutManejador de página no recargado tras la repaginaciónv3.126.2 (refresco diferido)
Pulsar en un campo lanza Cannot open text pageSelección de texto y sonda de URL en páginas sin capa de textov3.126.2
Teclear sobre un valor seleccionado añade en lugar de reemplazarEl editor XFA nativo inserta en el cursorv3.126.2
  • Ponga EnableV8Engine a True antes de que cargue cualquier documento, y maneje OnXfaRuntimeMissing para el caso en que la biblioteca plana se cargó primero
  • Lea el total de TPdf.PageCount o del parámetro NewCount de OnXfaPageCountChanged; nunca sume ni reste recuentos de páginas usted mismo
  • Mantenga ligero el handler de OnXfaPageCountChanged, porque corre dentro del callback de layout nativo
  • Sincronice el indicador de página actual en TPdfView.OnPageChange, que se dispara tras la recarga diferida acotar el número de página
  • Despliegue las DLL Windows V8 de v3.126.1 o posterior junto con las unidades; el arreglo de relayout de widgets vive en código nativo
  • Pruebe con un formulario que de verdad cambie su recuento de páginas y mueva un campo editado a través de un salto de página, porque las muestras de longitud fija esconden todos los bugs de esta lista

El XFA dinámico convierte el recuento de páginas y la geometría de campos en valores vivos, y un visor solo se mantiene correcto si los toma del layout completado y recarga páginas en un momento seguro. PDFium Component maneja ambas cosas dentro de TPdf y TPdfView, así que al anfitrión solo le queda escuchar. Detalles y descargas están en la página de producto de PDFium Component para Delphi