Artículo técnico

Valores heredados de campos AcroForm y resets en Delphi

Un formulario de encuesta muestra dos listas desplegables en Acrobat, group.a y group.b. Recorra /AcroForm /Fields con código que trate cualquier nodo que cargue su propio /FT como un campo terminado, y el conteo regresa como 1: un solo campo llamado group con dos widgets. Pida group.b por nombre y la búsqueda falla. Abra el diccionario del hijo y no hay ningún /FT en él, ni /Ff, ni lista de opciones. Nada está roto. El tipo, los flags y las opciones se sientan un nivel arriba en el padre, y la especificación PDF permite explícitamente que vivan ahí

La disposición es legal y económica. Una herramienta de autor escribe un nodo de grupo con /FT /Ch, /Ff 131078 y un arreglo /Opt una sola vez, y luego cuelga debajo dos hijos con nombre, cada uno un diccionario fusionado de campo más widget que guarda solo /T, /Parent, /Rect y su propio /V. Leer semejante formulario correctamente toma tres cosas: seguir /Parent para cada clave heredable, decidir dónde termina realmente el árbol de campos, y asegurarse de que un reset escribe un valor del tipo PDF correcto. Esta pieza retoma donde fijar valores de campos de formulario en un PDF cargado con Delphi se detuvo

¿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. Los arreglos de opciones reciben aquí el mismo tratamiento, porque los productores que comparten una lista de elección entre hijos guardan /Opt en el grupo. Un lector que solo revisa el diccionario local reportará entonces el tipo equivocado, flags de 0 y un valor vacío para un hijo perfectamente válido

HotPDF Delphi Component responde cada una de esas lecturas de la misma forma. Busca la clave en el diccionario propio del campo y, si la clave está ahí, resuelve la referencia indirecta y se detiene. Si no, sigue /Parent y reintenta, hasta por 128 niveles. Ese tope no es decoración. Los punteros /Parent en archivos dañados pueden formar un ciclo que no tiene nada que ver con /Kids, y un lector que confíe en ellos incondicionalmente se cuelga con el primer archivo semejante que encuentra

Una regla de ese recorrido es fácil de tomar al revés: el primer diccionario que contiene la clave gana, aunque el valor guardado ahí sea un string vacío. Un /V () local en group.b es el autor diciendo "este hijo está en blanco", y el recorrido se detiene ahí en lugar de seguir subiendo hacia un valor que el padre quizá sostenga. Tratar lo vacío como "ausente" haría que un hijo limpiado muestre la selección de su padre, que es lo contrario de lo que el autor escribió

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 la búsqueda de atributos heredados 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

Los getters públicos se apoyan todos en esa búsqueda: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue, y los helpers de opciones GetLoadedFormFieldOptionCount y GetLoadedFormFieldOptions. Así que leer el hijo del grupo de la encuesta no necesita código especial:

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;

GetFormField devuelve un nuevo envoltorio THPDFLoadedFormField cada vez y lanza una excepción cuando el nombre es desconocido: deje la llamada fuera del bloque try del envoltorio y libere usted el envoltorio

Encontrar dónde termina el árbol de campos

Un /FT local es la prueba equivocada para un campo terminal, porque un padre puede suministrar el tipo y aun así poseer campos hijos con nombre. El recorrido tentador deja de descender apenas un nodo tiene su propio /FT o no tiene /Kids. En el formulario de encuesta, group tiene ambos /FT /Ch y /Kids, así que ese recorrido lo registra como un campo con dos anotaciones widget. Los nombres group.a y group.b desaparecen. El conteo de campos es 1. Una búsqueda por nombre de hijo falla, y una escritura de valor solo puede aterrizar en el padre compartido

HotPDF mira a los kids en su lugar. Un kid cuenta como campo hijo si tiene su propio /T, tiene su propio /Kids, o no es un diccionario /Subtype /Widget. Solo cuando ningún kid califica el nodo es terminal, y sus kids se tratan entonces como sus widgets. Ambos casos borde detrás de esa regla vienen de diccionarios fusionados, que §12.7.3.1 permite cuando un campo tiene exactamente un widget:

  • Un diccionario fusionado con nombre lleva /Subtype /Widget y sigue siendo un campo hijo. El subtype por sí solo no puede empujarlo a la lista de widgets anónimos del padre; el /T decide
  • Algunos productores repiten el /FT del padre en cada widget anónimo. Así que un /FT en un widget tampoco puede leerse como "aquí empieza un campo nuevo"

La misma clasificación maneja el índice de campos, FormFieldExists y RemoveFormField. Cada uno de esos recorridos también registra los diccionarios que ya visitó y se rinde pasado el nivel 128. Suponga que el grupo es el objeto 5 y se lista dos veces entre sus propios kids: /Kids [5 0 R 5 0 R 6 0 R 7 0 R]. Aun así reporta exactamente dos campos, los hijos 6 y 7. Sin recursión infinita, y sin un tercer campo fantasma por la autorreferencia

¿Cómo evita RemoveFormField eliminar campos hermanos?

RemoveFormField elimina solo el hijo que usted nombra porque descubrimiento y eliminación comparten una definición de campo terminal. El overload por nombre resuelve un índice de campo desde la lista de campos, luego recorre /AcroForm /Fields una segunda vez contando campos terminales para encontrar el diccionario a eliminar. Si el segundo recorrido usara la tentadora regla /FT mientras el primero usó la regla basada en kids, los índices discreparían: el recorrido de eliminación vería group como campo 0, y eliminar group.a se llevaría al padre, al hermano y a ambos widgets

Así que el recorrido de eliminación aplica la misma prueba de campo hijo y el mismo conjunto de visitados que el descubrimiento. Colecciona las anotaciones widget que pertenecen al hijo eliminado, quita solo esas de los /Annots de cada página, y elimina el padre solo cuando su arreglo /Kids termina vacío. Con group.b todavía listado, el padre se queda, y el tipo, los flags y las opciones que group.b hereda se quedan con él

Diagrama de supervivencia de hermanos en RemoveFormField de HotPDF: el recorrido de eliminación usa la misma prueba de campo hijo y el mismo conjunto de visitados que el descubrimiento, quita solo al hijo con nombre group.a de AcroForm /Fields y de los /Annots de la página, y conserva al padre compartido mientras su arreglo /Kids todavía sostiene al group.b sobreviviente
Descubrimiento y eliminación coinciden en qué es un campo terminal, así que eliminar un hijo con nombre deja el valor y la apariencia de su hermano intactos tras una reescritura completa o una actualización incremental
// Elimina un hijo con nombre; su hermano y el padre compartido sobreviven
Pdf.RemoveFormField('group.a');

// Chequeos de cordura que usted agregaría, no parte de la librería
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Tipo, flags y opciones siguen resolviéndose a través del padre
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);

Pdf.SaveLoadedDocument('survey-trimmed.pdf');

Cuando revise una eliminación en sus propios formularios, mire en tres lugares, porque cada uno atrapa un error distinto: los /Kids del padre, los /Annots de la página, y el valor y la apariencia del hermano sobreviviente. Hágalo una vez después de SaveLoadedDocument y otra después de SaveIncrementalUpdate, ya que un guardado incremental solo anexa los objetos cambiados y los /Kids del padre deben estar entre ellos

Qué copia ResetLoadedFormField de un /DV 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 default de un checkbox es un nombre como /Yes. El default de un list box multiselección es un arreglo de strings. Un default de texto puede ser un string UTF-16 hexadecimal como <FEFF...>. Pase cualquiera de esos por GetLoadedFormFieldDefaultValue y SetFormFieldValue y obtiene un string donde va un nombre, un string vacío donde había un arreglo, o dígitos hex literales

El reset entonces bifurca según lo que el /DV heredado realmente es:

  • Los campos de texto y de elección con default de string reciben un objeto string nuevo que conserva el flag hexadecimal del original
  • Los campos de elección con default de arreglo reciben un arreglo nuevo de strings nuevos; un arreglo que contenga algo que no sean strings lanza una excepción
  • Los checkboxes y los radio buttons reciben un objeto de nombre nuevo
  • Un default de cualquier otro tipo lanza una excepción, y /V e /I se quedan exactamente como estaban
Diagrama del reset tipado de HotPDF: ResetLoadedFormField bifurca según el tipo de objeto del /DV heredado, escribiendo un objeto de nombre fresco para un checkbox, un arreglo 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 existe /DV, y lanzando sin tocar /V ni /I ante un tipo que no coincide
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 pushbuttons más los campos de firma toman el camino de string plano

Copiar en lugar de referenciar es deliberado, y es la única opción segura. Un /V hijo que apuntara al arreglo /DV del padre compartiría un objeto entre "valor actual" y "valor por defecto"; el siguiente SetFormFieldValue sobre ese hijo reescribiría en silencio el default de cada hermano. Unos cuantos bytes extra por campo de reset es un precio barato por mantenerlos separados. Los pushbuttons, que no tienen valor (Tabla 226, bit 17), y los campos de firma se quedan en un camino plano que escribe una copia string de su propio /DV

Cuando no existe ningún /DV en toda la cadena, el método escribe un string vacío local, o /Off para un campo checkbox o radio. Borrar el /V del hijo se vería más ordenado y daría la respuesta equivocada. Si el padre sostiene un valor actual, quitar el override del hijo trae ese valor de vuelta directo, y el campo "reseteado" ahora muestra algo que el usuario nunca tecleó en él

var
  Field: THPDFLoadedFormField;
begin
  // El padre 'palette' es un list box MultiSelect con
  // /Opt [(b) (g) (r)] y /DV [(b) (r)]; el hijo guarda solo /T y /V
  Field := Pdf.GetFormField('palette.primary');
  try
    try
      Pdf.ResetLoadedFormField(Field.Index);
      // El hijo ahora tiene su propio /V [(b) (r)] y /I [0 2];
      // el arreglo /DV del padre queda intacto
    except
      on E: Exception do
        // Default malformado (tipo equivocado, ítem de arreglo que no es string):
        // /V e /I quedaron como estaban
        Writeln('Reset skipped: ', E.Message);
    end;
    // El getter escalar no puede representar un default de arreglo
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vacío
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Un reset de un solo campo también es cosa distinta de la acción ResetForm de §12.7.5.3, que un visor corre sobre un conjunto de campos cuando el usuario hace clic en un botón; esa acción está cubierta en construir campos y acciones AcroForm con HotPDF. ResetLoadedFormField edita un campo cargado, sigue su propia regla para el caso sin default, y marca el campo como cambiado para que el recálculo incremental lo levante

Mantener /V, /I y /AS de acuerdo

Un reset solo es correcto si el índice de selección y el estado de apariencia siguen al valor, y ResetLoadedFormField termina con los mismos dos pasos de seguimiento que SetFormFieldValue. Para campos de elección, quita el /I local del hijo, empareja cada valor contra la mitad de exportación de cada entrada /Opt, y escribe un /I nuevo ordenado. Resetear a [(b) (r)] contra opciones b, g, r da /I [0 2]. El /I heredado del padre, si existe, queda intacto

Los botones trabajan desde el tipo heredado. Un checkbox hijo cuyo /FT /Btn vive en el padre igual recibe su /AS actualizado. Del lado de la escritura, SetFormFieldValue y SetLoadedFormFieldDefaultValue guardan un objeto de nombre para un botón heredado que no es pushbutton aunque el hijo no tenga entrada local de donde copiar el tipo. Y cuando EnsureLoadedFieldAppearanceStream reconstruye las apariencias de botones, escribe /AS /Off salvo que el valor coincida con el estado encendido, y le da a cada stream de estado /Type /XObject, /Subtype /Form y un /BBox. Sin esa comprobación de /Off, regenerar las apariencias después de un reset mostraría la casilla marcada otra vez antes de que el archivo siquiera se guarde

Getters escalares y otros bordes que conviene planificar

Los getters de string se quedan escalares, y el código que compara su salida debería saber qué aplastan. GetFormFieldValue y GetLoadedFormFieldDefaultValue devuelven un string vacío para un valor de arreglo, vuelven números y booleanos 42 o true, y reportan un string codificado en hex en su grafía hexadecimal. Si usted necesita saber si el default de un list box guarda dos selecciones o ninguna, el getter de defaults no puede decírselo: ambos regresan como string vacío

Un ciclo de /Parent termina la búsqueda sin lanzar, así que un campo cuyo tipo se pierde dentro de un ciclo reporta lfftUnknown y flags de 0. Trate un lfftUnknown en un campo con nombre como señal de un archivo dañado que vale registrarse. SetFormFieldValue y ResetLoadedFormField siempre escriben el hijo que usted direcciona y jamás promueven un valor al padre compartido. Eso es correcto para hijos independientes como las dos listas desplegables de la encuesta. Los grupos de radio son distintos: la selección pertenece al campo que posee el grupo, así que direcciona ese campo en lugar de uno de sus kids

Cada llamada también confirma un campo por su cuenta. Un bucle de resets sobre veinte campos son veinte ediciones independientes; si la quinta lanza sobre un default malformado, las primeras cuatro ya están aplicadas. Para trabajo por lotes, lea primero el tipo y el default de cada objetivo, decida qué campos va a tocar, y solo entonces empiece a escribir

La búsqueda de atributos heredados, la clasificación del árbol de campos basada en kids y el reset tipado pertenecen todos a la API de formularios cargados del HotPDF Delphi Component, que también cubre proyectos Delphi y C++Builder que necesitan agregar campos nuevos a un documento existente, como se muestra en agregar campos AcroForm a un PDF cargado en Delphi