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
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
// 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
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