Artículo técnico

Error al aplanar checkbox PDF: valor de campo vs widget

Las casillas de verificación y 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 reporte de error 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 archivo fuente en Acrobat y las casillas están visiblemente marcadas. Lee el archivo fuente de vuelta 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 después de aplanar

Porque aplanar nunca mira /V. FPDFPage_Flatten hornea el flujo 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, aplanar 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 botón de radio, la entrada /N no es un flujo 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 está mal de una manera que ninguna cantidad de /V correcto reparará. Esto también es 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 solo flujo que debe regenerarse desde cero después de que el valor cambia, así que GenerateFormAppearances maneja los dos casos mediante rutas de código completamente separadas y solo la ruta de 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 de nombre que nombra el estado de apariencia actual, y §12.7.3.1 coloca a /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 está defectuoso. Su contrato es exactamente lo que dice su nombre: obtener una entrada de cadena del diccionario de anotación que le entregaste. Pedirle /V sobre el objeto 13 no devuelve nada porque el objeto 13 genuinamente no tiene /V. El defecto estaba en quien invocaba, que asumió un modelo de objeto plano que ISO 32000-1 nunca prometió

Cuándo comparten campo y widget un solo diccionario

Siempre que un campo tenga exactamente un widget. §12.5.6.19 permite que el diccionario de campo y su único widget de anotación se fusionen en un solo objeto, y la mayoría de las herramientas de creación toman ese atajo. En un objeto fusionado /FT, /T, /V, /AS, y /AP están todos lado a lado, así que una lectura de /V a nivel de widget tiene éxito y todo el error 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 son las casillas de consentimiento repetidas en un encabezado y un pie de página, y cualquier campo que una herramienta de creación 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 pruebas estaba lleno de formularios de un solo widget y los archivos 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 la navegación de campos de formulario PDF con PDFium Component cubren cómo se relaciona un recorrido de anotaciones a nivel de página 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 había estado enlazada en el componente durante algún tiempo 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 diseños 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 conteo de bytes para texto UTF-16 incluyendo el terminador, así que la cantidad de caracteres es buflen div 2 - 1 y un valor de 2 significa una cadena vacía. La protección buflen >= 4, por lo tanto, significa al menos un carácter real, que es lo que evita que un campo sin ningún /V tenga 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 haya producido el archivo. §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 cuál botón está seleccionado. Esto es precisamente por qué copiar /V textualmente hacia /AS es la operación correcta en lugar de un truco: para un control marcado, PDFium reporta el nombre de estado de encendido que el propio archivo define, y para uno sin marcar reporta Off, así que el valor que escribes en /AS está garantizado de 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 en silencio en cualquier otro lugar

Orden de las operaciones, y dónde todavía necesita cuidado

La secuencia es fija y no perdona: habilitar el relleno de formulario, asignar valores, regenerar apariencias, aplanar, luego guardar. Sáltate el paso de regeneración y FPDFPage_Flatten encuentra flujos de apariencia vacíos u obsoletos y los hornea sin quejarse, que es una pérdida de datos silenciosa en lugar de un error de retorno

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 cada uno define su propio nombre de estado de encendido; un hijo cuyo /AP /N no tiene ninguna entrada que coincida con el /AS escrito no tiene ninguna apariencia que seleccionar bajo §12.5.5, así que un botón no seleccionado puede aplanarse hacia nada en lugar de un círculo vacío. Auditar un grupo de radio con FPDFAnnot_GetFormControlIndex antes de aplanar vale la pena por las pocas líneas que cuesta. Segundo, nada de esto aplica a XFA, donde el valor vive en un paquete de datos XML en lugar de en los diccionarios de AcroForm, una separación cubierta en las notas sobre ediciones de campo XFA que no se persisten. La lección general vale la pena conservarla más allá de esta corrección: siempre que una API recibe 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 recibe 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 funciona con nombres de campo completamente calificados, nunca con posiciones de widget

Aplanar formularios es una de esas características que parece una sola 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