Artículo técnico

Valores heredados de campos AcroForm y resets en Delphi

HotPDF Delphi Component trata /FT, /Ff, /V y /DV de un campo AcroForm cargado como atributos heredables, que se resuelven recorriendo la cadena de /Parent. Desde la v2.754.3 y la v2.754.4, un hijo con nombre cuyo tipo viene del padre sigue siendo direccionable por separado, RemoveFormField deja en paz 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 leían mal

El formulario que saca todo esto a la luz no es exótico. Una herramienta de autoría construye un nodo grupo group que lleva /FT /Ch, los flags de campo 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 más que /T, /Parent, /Rect y su propio /V. Es una forma perfectamente legal de compartir atributos, y es exactamente el caso que la sección de límites de fijar valores de campos de formulario en un PDF cargado con Delphi marcaba como no soportado: la reconciliación de botones solo miraba el /FT local. Este artículo retoma donde aquel se paró: cómo se clasifica el árbol de campos, cómo se leen los valores heredados y qué tiene permitido escribir un 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 de §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, HPDFLoadedInheritedFieldObject, que comprueba si el diccionario tiene la clave, resuelve una referencia indirecta si la encuentra, y si no sigue /Parent como máximo 128 niveles, porque los archivos malformados pueden construir ciclos de /Parent que no tienen nada que ver con /Kids. Encima se apoyan los getters públicos: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue y los helpers de opciones GetLoadedFormFieldOptionCount y GetLoadedFormFieldOptions, que también recogen un array /Opt guardado en el padre. Una regla del resolver es fácil de torcer: el paseo se para en el primer diccionario que contiene la clave, aunque el valor ahí sea una cadena vacía. Un /V () local es una sobreescritura deliberada que enmascara al padre, no un hueco que rellenar más arriba en el árbol

Diagrama de atributos AcroForm heredados de HotPDF: un nodo 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 enmascara al padre
HotPDF resuelve /FT, /Ff, /V, /DV y /Opt con un único resolver que camina por los padres, así que un hijo con nombre sigue siendo direccionable mientras que un valor local vacío sobreescribe 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í poseer 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 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 completamente calificados group.a y group.b sencillamente desaparecían. GetFormFieldCount devolvía 1, una búsqueda por nombre de hijo fallaba, y SetFormFieldValue solo podía escribir al 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, si tiene su propio /Kids, o si 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 límite 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 anónima de widgets del padre; el /T manda. Al revés también pasa: algunos producers repiten el /FT del padre en cada widget anónimo, así que el /FT tampoco sirve como evidencia de que un widget arranca un campo nuevo. La clasificación la comparten la caché de relaciones, FormFieldExists y RemoveFormField, y cada uno de esos paseos registra ahora los diccionarios que ya visitó y se pasa de 128 niveles. Un archivo de regresión cuyo grupo se lista a sí mismo dos veces, /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 el mismo nodo dos veces

¿Cómo evita RemoveFormField borrar campos hermanos?

RemoveFormField ahora borra solo el hijo que usted nombra, porque descubrimiento y 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 vía la caché de relaciones y luego cuenta los campos terminales en un segundo paseo sobre /AcroForm /Fields. Cuando la caché ya veía group.a y group.b, un paseo de borrado sin corregir habría seguido tratando a group como un único campo terminal, y el índice 0 habría eliminado al padre junto con todos los hermanos y todos sus widgets. El paseo de borrado usa ahora el mismo test HPDFLoadedFieldHasChildFields y el mismo conjunto de visitados, recolecta las anotaciones de widget solo del hijo eliminado, las quita del /Annots de cada página, y elimina al padre solo cuando su array /Kids acaba vacío. La regresión comprueba los tres sitios donde un error se notaría: el /Kids del padre, el /Annots de la página, y el valor y la apariencia del hermano superviviente, tanto tras una reescritura completa como tras una actualización incremental

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

Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Tipo, flags y 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 una casilla es un nombre como /Yes, el de un list box multiselección es un array de cadenas, y el de un texto puede ser una cadena UTF-16 hexadecimal; copiar cualquiera de ellos vía GetLoadedFormFieldDefaultValue convertiría el nombre en cadena, el array en cadena vacía y la cadena hex en sus dígitos literales. El reset ramifica por tanto según el tipo heredado: los campos de texto y de elección reciben un objeto cadena nuevo que conserva el flag IsHexadecimal, los campos de elección con default de array reciben un array nuevo de cadenas nuevas, y los botones que no son pushbutton reciben un objeto nombre nuevo. Copiar, en vez 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 cadenas, lanza una excepción y deja /V e /I exactamente como estaban. Los pushbutton, que no tienen valor (Tabla 226, bit 17), y los campos de firma caen al viejo camino de solo cadenas

Diagrama del reset tipado de HotPDF: ResetLoadedFormField ramifica según el tipo de objeto del /DV heredado, escribiendo un objeto nombre fresco para una casilla, un array nuevo de cadenas nuevas para una elección multiselección, una cadena que conserva IsHexadecimal para texto hex, una cadena vacía o /Off cuando no hay /DV, y lanzando excepción sin tocar /V ni /I ante un tipo equivocado
Copiar en lugar de apuntar a los objetos del padre evita que una edición posterior del valor cambie el default en silencio, y los pushbutton y los campos de firma caen al viejo camino de solo cadenas

Cuando no existe ningún /DV en toda la cadena hacia arriba, el método mantiene su contrato de limpieza escribiendo una cadena vacía local, o /Off para un campo de casilla o de radio. Borrar el /V local parecería más ordenado y estaría mal: el padre puede sostener un valor actual, y eliminar la sobreescritura del hijo haría volver ese valor en silencio. Esta es también la razón por la que un 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 pulsa un botón, como se describe en construir 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 lleva /DV [(b) (r)] en un list box MultiSelect: group.a recibe
    // su propio /V [(b) (r)] y un /I [0 2] nuevo; 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 acuerdo

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 acepta ahora un valor de array: borra el /I local sin mutarlo, coteja cada valor contra la mitad de export de cada entrada /Opt, y escribe un /I nuevo y ordenado, así que un reset a [(b) (r)] contra opciones b, g, r da /I [0 2]. ReconcileLoadedButtonAppearanceStates pide ahora el tipo heredado, así que una casilla hija cuyo /FT /Btn vive en el padre por fin consigue su /AS escrito. Del lado de escritura, SetFormFieldValue y SetLoadedFormFieldDefaultValue guardan un objeto nombre 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 on, 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 el archivo se guardara

Límites que conviene conocer antes de montar sobre esto

Los getters escalares se quedan escalares. GetFormFieldValue y GetLoadedFormFieldDefaultValue devuelven una cadena vacía para un valor de array, convierten números y booleanos a 42 o true, y reportan una cadena hex-encoded en su grafía hexadecimal. Un ciclo de /Parent termina el paseo sin excepción, así que un campo cuyo tipo se pierde en un ciclo reporta lfftUnknown y flags de 0 en lugar de fallar. SetFormFieldValue y ResetLoadedFormField escriben siempre al hijo que usted direcciona y nunca promueven un valor al padre compartido, que es lo correcto para hijos independientes pero significa que los grupos de radio deben direccionarse por el campo que posee la selección. Y cada llamada compromete un campo por su cuenta; nada de esto hace 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 del HotPDF Delphi Component para Delphi y C++Builder, junto a la creación de campos cubierta en añadir campos AcroForm a un PDF cargado en Delphi