Artículo técnico

Bug de Flatten en checkbox PDF: valor de campo vs widget

Las casillas de verificación y los botones de radio se aplanan como sin marcar porque el estado de apariencia /AS nunca se sincronizó con el valor de campo /V. PDFium Component, el componente VCL y LCL basado en PDFium para Delphi, C++Builder y Lazarus, ahora lee ese valor con FPDFAnnot_GetFormFieldValue, que resuelve el diccionario de campo padre en lugar del widget de anotación

El informe de bug que llevó hasta aquí es del tipo que desconfías al principio. Un cliente aplana un formulario de consentimiento firmado, abre el resultado, y cada casilla está vacía. Abre el fichero de origen en Acrobat y las casillas están visiblemente marcadas. Lee de vuelta el fichero de origen a través del mismo componente y los valores de campo son correctos. Solo la salida aplanada los pierde, y solo para casillas de verificación y botones de radio: los campos de texto en la misma página salen bien

¿Por qué las casillas quedan sin marcar tras el aplanado?

Porque el aplanado nunca mira /V. FPDFPage_Flatten hornea el stream de apariencia del widget en el contenido de la página, y la apariencia que elige es la nombrada por /AS. Si /AS todavía dice /Off mientras el valor de campo dice que la casilla está activada, el aplanado hornea fielmente la apariencia de apagado. El valor nunca se perdió; nunca se consultó

ISO 32000-1 §12.5.5 define el diccionario de apariencia /AP con tres entradas posibles, /N, /R, y /D. Para una casilla de verificación o un botón de radio la entrada /N no es un stream sino un subdiccionario cuyas claves son nombres de estado de apariencia, y §12.5.2 hace de /AS el selector requerido cuando /N es un subdiccionario. Así que una casilla lleva dos apariencias preconstruidas y un puntero. Equivoca el puntero y el renderizado sale mal de una forma que ninguna cantidad de /V correcto puede reparar. Esto también explica por qué el modo de fallo difiere de los campos de texto, que no tienen ninguna apariencia preconstruida que seleccionar en absoluto: un /N de campo de texto es un único stream que debe regenerarse desde cero después de que cambie el valor, así que GenerateFormAppearances maneja los dos casos mediante rutas de código completamente separadas y solo la ruta de los botones estaba rota

¿Dónde vive realmente el valor de la casilla?

En el diccionario de campo, no en el widget. ISO 32000-1 §12.7.5.2 describe las casillas de verificación y los botones de radio como campos de botón cuyo /V es un objeto nombre que nombra el estado de apariencia actual, y §12.7.3.1 coloca /V entre las entradas comunes a todos los diccionarios de campo. El widget de anotación definido en §12.5.6.19 aporta /AS y /AP. Nada en la especificación obliga a un widget a llevar /V

// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off

{ What the two objects look like when the field has several widgets:

  12 0 obj                          % field dictionary (the parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (a kid)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

FPDFAnnot_GetStringValue no es defectuoso. Su contrato es exactamente lo que dice su nombre: obtener una entrada de cadena del diccionario de anotación que le pasaste. Preguntarle por /V en el objeto 13 no devuelve nada porque el objeto 13 genuinamente no tiene /V. El defecto estaba en el llamante, que asumía un modelo de objeto plano que ISO 32000-1 nunca prometió

¿Cuándo comparten campo y widget un único diccionario?

Siempre que un campo tenga exactamente un widget. §12.5.6.19 permite que el diccionario de campo y su única anotación widget se fusionen en un solo objeto, y la mayoría de las herramientas de autoría toman ese atajo. En un objeto fusionado /FT, /T, /V, /AS, y /AP están todos uno al lado del otro, así que una lectura de /V a nivel de widget tiene éxito y todo el bug permanece invisible

En el momento en que un campo posee dos o más widgets la fusión es imposible, y §12.7.3.1 exige que los widgets se conviertan en /Kids de un diccionario de campo separado. Cada grupo de radio tiene esta forma por construcción. También lo tienen las casillas de consentimiento repetidas en una cabecera y un pie de página, y cualquier campo que una herramienta de autoría haya copiado a una segunda página. Esa es toda la explicación de por qué el defecto sobrevivió a una batería de regresión: el corpus de tests estaba lleno de formularios de un solo widget y los ficheros del cliente no lo estaban. Si recorres los widgets tú mismo en lugar de depender del componente, la misma asimetría aparece en el orden de enumeración, y las notas sobre navegación de campos de formulario PDF con PDFium Component cubren cómo un recorrido de anotaciones a nivel de página se relaciona con el árbol de campos a nivel de documento

Leer el valor de la forma en que PDFium lo pretende

FPDFAnnot_GetFormFieldValue es la API correcta, y llevaba tiempo enlazada en el componente sin que la ruta de casillas la usara. Recibe el handle de formulario además de la anotación, que es la señal que importa: con el entorno de relleno de formulario disponible, PDFium resuelve la anotación hacia su control de formulario y lee el valor desde el objeto de campo, así que devuelve la respuesta correcta tanto para layouts fusionados como divididos

FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP is prebuilt per state; only /AS has to be synchronised with /V.
    // FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
    // which is where ISO 32000-1 12.7.5.2 keeps the value.
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

Dos detalles en ese fragmento son fáciles de equivocar. La longitud devuelta es un recuento de bytes para texto UTF-16 incluyendo el terminador, así que el recuento de caracteres es buflen div 2 - 1 y un valor de 2 significa una cadena vacía. La guarda buflen >= 4 por tanto significa al menos un carácter real, que es lo que evita que un campo sin /V en absoluto vea su /AS sobrescrito con un nombre vacío

En qué están realmente de acuerdo /AS y /AP /N

Están de acuerdo en un nombre, y el nombre lo elige quien produjo el fichero. §12.7.5.2 exige que el estado de apagado se llame /Off, y deja el estado de encendido enteramente al productor. /Yes es una convención, no una regla. Acrobat escribe /Yes, pero muchos generadores escriben /On, /1, /Choice1, o una palabra localizada, y un grupo de radio normalmente le da a cada hijo un nombre de estado de encendido distinto para que el grupo pueda expresar qué botón está seleccionado. Esto es precisamente por lo que copiar /V literalmente en /AS es la operación correcta y no un truco: para un control marcado PDFium reporta el nombre de estado de encendido que el propio fichero define, y para uno sin marcar reporta Off, así que el valor que escribes en /AS tiene garantizado ser una clave que existe en el subdiccionario /AP /N de ese widget. Codificar /Yes a fuego funcionaría en la salida de Acrobat y se rompería silenciosamente en cualquier otro sitio

Orden de operaciones, y dónde todavía hace falta cuidado

La secuencia es fija e inflexible: activar el relleno de formulario, asignar valores, regenerar apariencias, aplanar, después guardar. Sáltate el paso de regeneración y FPDFPage_Flatten encuentra streams de apariencia vacíos u obsoletos y los hornea sin quejarse, lo cual es una pérdida de datos silenciosa en lugar de un retorno de error

Pdf.FileName := FormPath;
Pdf.FormFill := True;          // required: FormHandle must exist
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // writes /V only

Pdf.GenerateFormAppearances;   // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

Quedan dos límites honestos. Primero, la sincronización escribe el valor de campo en el /AS de cada widget de ese campo, lo cual es correcto para casillas de verificación pero aproximado para grupos de radio cuyos hijos definen cada uno su propio nombre de estado de encendido; un hijo cuyo /AP /N no tiene ninguna entrada que coincida con el /AS escrito no tiene apariencia que seleccionar bajo §12.5.5, así que un botón no seleccionado puede aplanarse a nada en lugar de a un círculo vacío. Auditar un grupo de radio con FPDFAnnot_GetFormControlIndex antes de aplanar merece las pocas líneas que cuesta. Segundo, nada de esto se aplica a XFA, donde el valor vive en un paquete de datos XML en lugar de en los diccionarios AcroForm, una separación cubierta en las notas sobre ediciones de campo XFA que no se persisten. La lección general merece conservarse más allá de esta corrección concreta: siempre que una API reciba el handle de formulario además de la anotación, te está diciendo que resolverá la jerarquía de campos por ti, y siempre que reciba solo la anotación leerá exactamente el objeto que le pasaste. Esa distinción también gobierna el intercambio de datos, ya que exportar e importar datos de formulario XFDF trabaja con nombres de campo completamente cualificados, nunca con posiciones de widget

El aplanado de formularios es una de esas características que parece una única llamada a la API y resulta ser un contrato entre tres diccionarios. Si prefieres trabajar contra un componente que ya codifica ese contrato, el PDFium Component para Delphi y C++Builder incluye la regeneración de apariencias, el aplanado, y el acceso a campos de formulario descritos aquí como propiedades y métodos ordinarios