Artículo técnico

Valores heredados de campos AcroForm y resets en Delphi

HotPDF Delphi Component trata /FT, /Ff, /V y /DV sobre un campo AcroForm cargado como atributos heredables, que se resuelven recorriendo la cadena /Parent. Desde la v2.754.3 y la v2.754.4, un hijo con nombre cuyo tipo viene del padre sigue siendo direccionable individualmente, RemoveFormField deja tranquilos a sus hermanos, y ResetLoadedFormField copia el default heredado con su tipo de objeto PDF original. Antes de eso, una cantidad sorprendente de formularios corrientes se estaban leyendo mal

El formulario que expone todo esto no es nada exótico. Una herramienta de autor arma un nodo de grupo group que lleva /FT /Ch, los field flags y la lista de opciones una sola vez, y cuelga debajo dos hijos con nombre, a y b, cada uno un diccionario fusionado de campo más widget que no tiene nada más que /T, /Parent, /Rect y su propio /V. Es una manera perfectamente legal de compartir atributos, y es exactamente el caso que la sección Limits de asignar valores a campos de formulario en un PDF cargado en Delphi marcaba como no manejado: la reconciliación de botones solo miraba el /FT local. Este artículo retoma donde aquel se detuvo, cubriendo cómo se clasifica el árbol de campos, cómo se leen los valores heredados y qué tiene permitido escribir el reset de un solo campo

¿Qué entradas AcroForm puede heredar un campo de su padre?

ISO 32000-1 §12.7.3.1, Tabla 220, marca /FT, /Ff, /V y /DV como heredables, y la Tabla 229 en §12.7.4.3 hace lo mismo con el /MaxLen de un campo de texto, así que cualquier lector que mire solo el diccionario local reportará el tipo equivocado, los flags equivocados y un valor vacío para un hijo perfectamente válido. HotPDF canaliza todas esas lecturas por un resolver interno único, HPDFLoadedInheritedFieldObject, que chequea si el diccionario tiene la clave, resuelve una referencia indirecta si encuentra una, y si no sigue /Parent hasta un máximo de 128 niveles, porque los archivos malformados pueden armar ciclos de /Parent que no tienen nada que ver con /Kids. Los getters públicos se apoyan en él: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue y los helpers de opciones GetLoadedFormFieldOptionCount y GetLoadedFormFieldOptions, que también levantan un array /Opt guardado en el padre. Una regla del resolver es fácil de errar: el recorrido se detiene en el primer diccionario que contiene la clave, aunque el valor ahí sea un string vacío. Un /V () local es un override deliberado que tapa al padre, no un hueco que llenar desde más arriba en el árbol

Diagrama de atributos AcroForm heredados de HotPDF: un nodo de grupo lleva /FT, /Ff y /Opt una sola vez mientras los hijos con nombre group.a y group.b guardan solo /T, /Parent, /Rect y un /V local, mostrando a HPDFLoadedInheritedFieldObject recorrer /Parent hasta 128 niveles, donde gana el primer diccionario que tiene la clave y un valor local vacío tapa al padre
HotPDF resuelve /FT, /Ff, /V, /DV y /Opt con un único resolver que recorre los padres, así que un hijo con nombre sigue direccionable mientras un valor local vacío anula deliberadamente todo lo que lleva el grupo de arriba
var
  Pdf: THotPDF;
  Field: THPDFLoadedFormField;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    // 'group' lleva /FT /Ch, /Ff 131078 y /Opt; el hijo
    // 'group.b' lleva solo /T, /Parent, /Rect y su propio /V
    Field := Pdf.GetFormField('group.b');
    try
      if Pdf.GetFormFieldType(Field.Index) = lfftChoice then
      begin
        // 131078 = Combo (bit 18) + NoExport (bit 3) + Required (bit 2)
        Writeln(Pdf.GetLoadedFormFieldFlags(Field.Index));
        Writeln(Pdf.IsFormFieldRequired(Field.Index));    // TRUE
        Writeln(Pdf.GetLoadedFormFieldOptionCount(Field.Index));
        Writeln(Pdf.GetFormFieldValue(Field.Index));       // el /V local
      end;
    finally
      Field.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

¿Por qué un /FT local es el test equivocado para un campo terminal?

Porque un padre puede suministrar el tipo y aun así tener campos hijos con nombre, así que la presencia de /FT no dice nada sobre dónde termina el árbol de campos. El viejo recorrido declaraba terminal a un nodo en cuanto tenía su propio /FT o no tenía /Kids. En el formulario de arriba, group tiene tanto /FT /Ch como /Kids, así que se registraba como un solo campo llamado group con dos widgets, y los nombres totalmente calificados group.a y group.b simplemente desaparecían. GetFormFieldCount devolvía 1, una búsqueda por nombre de hijo fallaba, y SetFormFieldValue solo podía escribir el padre compartido. El test de reemplazo, HPDFLoadedFieldHasChildFields, mira a los hijos en lugar del padre: un kid es un campo hijo si tiene su propio /T, tiene su propio /Kids, o directamente no es un diccionario /Subtype /Widget. Solo cuando ningún kid califica el nodo es terminal, y sus kids se tratan como sus anotaciones de widget

Los dos casos borde que moldearon esa regla vienen ambos de diccionarios fusionados, que §12.7.3.1 permite cuando un campo tiene un solo widget. Un diccionario fusionado con nombre lleva /Subtype /Widget y aun así es un campo hijo, así que el subtype por sí solo no puede mandarlo a la lista de widgets anónimos del padre; gana el /T. Lo inverso también pasa: algunos productores repiten el /FT del padre en cada widget anónimo, así que el /FT tampoco puede usarse como evidencia de que un widget arranca un campo nuevo. La clasificación la comparten el caché de relaciones, FormFieldExists y RemoveFormField, y cada uno de esos recorridos ahora registra los diccionarios que ya visitó y se corta pasados los 128 niveles. Un archivo de regresión cuyo grupo se lista dos veces a sí mismo, /Kids [5 0 R 5 0 R 6 0 R 7 0 R], sigue reportando exactamente dos campos en lugar de recursar para siempre o contar dos veces el mismo nodo

¿Cómo evita RemoveFormField borrar campos hermanos?

RemoveFormField ahora borra solo el hijo que usted nombra, porque el descubrimiento y el borrado por fin coinciden en qué es un campo terminal. Ese acuerdo importa más de lo que parece. La sobrecarga por nombre resuelve un índice a través del caché de relaciones y después cuenta los campos terminales en un segundo recorrido sobre /AcroForm /Fields. Una vez arreglado el caché para ver group.a y group.b, un recorrido de borrado sin arreglar habría seguido tratando a group como un solo campo terminal, y el índice 0 habría removido al padre junto con cada hermano y todos sus widgets. El recorrido de borrado ahora usa el mismo test HPDFLoadedFieldHasChildFields y el mismo conjunto de visitados, recolecta las anotaciones de widget solo del hijo removido, las quita de los /Annots de cada página, y remueve al padre solo cuando su array /Kids queda vacío. La regresión chequea los tres lugares donde un error aparecería: los /Kids del padre, los /Annots de la página, y el valor y la apariencia del hermano sobreviviente, tanto tras una reescritura completa como tras una actualización incremental

Diagrama de supervivencia de hermanos en RemoveFormField de HotPDF: el recorrido de borrado reutiliza HPDFLoadedFieldHasChildFields y el conjunto de visitados del descubrimiento, quita solo el hijo con nombre group.a de AcroForm /Fields y de los /Annots de la página, y conserva al padre compartido mientras su array /Kids aún guarda al group.b sobreviviente
Descubrimiento y borrado por fin coinciden en qué es un campo terminal, así que remover un hijo con nombre deja intactos el valor y la apariencia de su hermano tras una reescritura completa o una actualización incremental
// Remover un hijo con nombre; su hermano y el padre compartido sobreviven
Pdf.RemoveFormField('group.a');

Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// El tipo, los flags y las opciones se siguen resolviendo vía el padre
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');

¿Qué escribe ResetLoadedFormField cuando el default es heredado?

ResetLoadedFormField escribe un /V local que es una copia fresca del /DV heredado con el mismo tipo de objeto PDF, y valida el default completo antes de tocar el campo. El tipo de objeto importa porque los getters escalares aplanan todo a texto. El default de un checkbox es un name como /Yes, el de un list box multiselección es un array de strings, y el de un texto puede ser un string hexadecimal UTF-16; copiar cualquiera de ellos vía GetLoadedFormFieldDefaultValue convertiría el name en string, el array en un string vacío y el string hex en sus dígitos literales. El reset entonces ramifica según el tipo heredado: los campos de texto y de elección reciben un objeto string nuevo que conserva el flag IsHexadecimal, los campos de elección con default de array reciben un array nuevo de strings nuevos, y los botones que no son pushbutton reciben un objeto name nuevo. Copiar, en lugar de apuntar a los objetos del padre, es deliberado: un /V que compartiera el array /DV del padre o su número de objeto cambiaría el default la próxima vez que alguien editara el valor. Un default de tipo equivocado, o un array de elección que contenga algo que no sean strings, levanta una excepción y deja /V y /I exactamente como estaban. Los pushbuttons, que no tienen valor (Tabla 226, bit 17), y los campos de firma caen al viejo camino de solo strings

Diagrama del reset tipado de HotPDF: ResetLoadedFormField ramifica según el tipo de objeto del /DV heredado, escribiendo un objeto name fresco para un checkbox, un array nuevo de strings nuevos para una elección multiselección, un string que conserva IsHexadecimal para texto hex, un string vacío o /Off cuando no hay /DV, y levantando sin tocar /V ni /I ante un tipo equivocado
Copiar en vez de apuntar a los objetos del padre evita que una edición posterior del valor cambie el default en silencio, y los pushbuttons junto con los campos de firma caen al viejo camino de solo strings

Cuando no existe ningún /DV en toda la cadena, el método mantiene su contrato de limpieza escribiendo un string vacío local, o /Off para un campo checkbox o radio. Borrar el /V local parecería más prolijo y sería un error: el padre puede tener un valor vigente, y quitar el override del hijo haría volver ese valor en silencio. Esta es también la razón por la que el reset de un solo campo no es la acción ResetForm de §12.7.5.3, que un visor ejecuta sobre un conjunto de campos cuando el usuario hace clic en un botón, como se describe en crear campos y acciones AcroForm con HotPDF. ResetLoadedFormField es una operación de edición sobre un campo cargado, con su propia regla para el caso sin default, y registra el campo vía NoteLoadedFormFieldDirty para que el recálculo incremental vea el cambio

var
  Field: THPDFLoadedFormField;
begin
  Field := Pdf.GetFormField('group.a');
  try
    // El padre guarda /DV [(b) (r)] en un list box MultiSelect: group.a recibe
    // su propio /V [(b) (r)] y un /I [0 2] fresco; el padre queda intacto
    Pdf.ResetLoadedFormField(Field.Index);
    // Los getters escalares no pueden representar el default de array
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vacío
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Mantener /V, /I y /AS en sintonía

Un reset solo es correcto si el índice de selección y el estado de apariencia siguen al valor, así que ResetLoadedFormField termina con los mismos dos reconciliadores que SetFormFieldValue. HPDFReconcileChoiceSelection ahora acepta un valor de array: borra el /I local sin mutarlo, matchea cada valor contra la mitad de exportación de cada entrada /Opt, y escribe un /I nuevo y ordenado, así que un reset a [(b) (r)] contra opciones b, g, r produce /I [0 2]. ReconcileLoadedButtonAppearanceStates ahora pide el tipo heredado, así que un checkbox hijo cuyo /FT /Btn vive en el padre por fin recibe su /AS puesto. Del lado de la escritura, SetFormFieldValue y SetLoadedFormFieldDefaultValue guardan un objeto name para un botón heredado que no es pushbutton incluso cuando el hijo no tiene entrada local de donde copiar el tipo. Y cuando EnsureLoadedFieldAppearanceStream reconstruye apariencias de botones, escribe /AS /Off salvo que el valor coincida con el estado encendido, y le da a cada stream de estado un /Type /XObject, /Subtype /Form y /BBox correctos; antes de la v2.754.4, regenerar la apariencia tras un reset podía volver a marcar la casilla antes de que se guardara el archivo

Límites que conviene conocer antes de apoyarse en esto

Los getters escalares siguen siendo escalares. GetFormFieldValue y GetLoadedFormFieldDefaultValue devuelven un string vacío para un valor de array, stringify números y booleanos como 42 o true, y reportan un string codificado en hex con su grafía hexadecimal. Un ciclo de /Parent termina el recorrido sin excepción, así que un campo cuyo tipo se pierde en un ciclo reporta lfftUnknown y flags en 0 en lugar de fallar. SetFormFieldValue y ResetLoadedFormField siempre escriben el hijo que usted direcciona y jamás promueven un valor al padre compartido, lo cual es correcto para hijos independientes pero significa que los grupos de radio deben direccionarse a través del campo dueño de la selección. Y cada llamada confirma un campo por su cuenta; nada de esto vuelve transaccional un lote de resets

La resolución de atributos heredados, la clasificación unificada del árbol de campos y el reset tipado descritos aquí son parte de la API de formularios cargados en el HotPDF Delphi Component para Delphi y C++Builder, junto con la creación de campos cubierta en agregar campos AcroForm a un PDF cargado en Delphi