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