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 lleve su propio /FT como un campo terminado, y el recuento vuelve como 1: un único 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 están un nivel más arriba en el padre, y la especificación PDF permite explícitamente que vivan allí

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

¿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. Los arrays 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 mire el diccionario local reportará por tanto 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 manera. Busca la clave en el diccionario propio del campo y, si la clave está allí, resuelve la referencia indirecta y se detiene. Si no, sigue /Parent y prueba de nuevo, hasta 128 niveles como máximo. Ese tope no es decoración. Los punteros /Parent en archivos dañados pueden formar un bucle que no tiene nada que ver con /Kids, y un lector que confíe en ellos sin condiciones se cuelga con el primer archivo así que encuentra

Una regla de ese recorrido es fácil de entender al revés: gana el primer diccionario que contiene la clave, incluso cuando el valor guardado allí es una cadena vacía. Un /V () local en group.b es el autor diciendo "este hijo está en blanco", y el recorrido se detiene allí en lugar de seguir subiendo hacia un valor que el padre pueda sostener. Tratar el vacío como "ausente" haría que un hijo limado mostrara la selección del padre, que es justo lo contrario de lo que el autor escribió

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 la búsqueda de atributos heredados recorriendo /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 resolutor que camina por los padres, así que un hijo con nombre sigue siendo direccionable mientras que un valor local vacío eclipsa 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 ayudantes 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 el test equivocado 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 en cuanto un nodo tiene su propio /FT o no tiene /Kids. En el formulario de encuesta, group tiene a la vez /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 recuento de campos es 1. Una búsqueda por nombre de hijo falla, y una escritura de valor solo puede caer en el padre compartido

HotPDF mira en cambio a los kids. 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 cualifica es el nodo terminal, y sus kids se tratan entonces como sus widgets. Los dos casos límite 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 aun así es un campo hijo. El subtype por sí solo no puede empujarlo a la lista anónima de widgets 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 dirige el índice de campos, FormFieldExists y RemoveFormField. Cada uno de esos recorridos registra además los diccionarios que ya ha visitado 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]. Sigue reportando 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 borrar campos hermanos?

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

Así que el recorrido de borrado aplica el mismo test de campo hijo y el mismo conjunto de visitados que el descubrimiento. Recoge las anotaciones widget que pertenecen al hijo eliminado, retira solo esas del /Annots de cada página, y elimina el padre solo cuando su array /Kids acaba vacío. Con group.b todavía listado, el padre se queda, y con él el tipo, los flags y las opciones que group.b hereda

Diagrama de supervivencia de hermanos en RemoveFormField de HotPDF: el paseo de borrado usa el mismo test de campo hijo y el mismo conjunto de visitados que el 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 que sobrevive
Descubrimiento y borrado 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');

// Comprobaciones de cordura que añadiría usted, no forman parte de la biblioteca
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Tipo, flags y opciones se siguen resolviendo a través del padre
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);

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

Cuando compruebe una eliminación en sus propios formularios, mire en tres sitios, porque cada uno caza un error distinto: el /Kids del padre, el /Annots de la página, y el valor y la apariencia del hermano que sobrevive. Hágalo una vez tras SaveLoadedDocument y otra tras SaveIncrementalUpdate, ya que un guardado incremental solo anexa los objetos cambiados y el /Kids del padre tiene que 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 una casilla es un nombre como /Yes. El default de un list box multiselección es un array de cadenas. Un default de texto puede ser una cadena UTF-16 hexadecimal como <FEFF...>. Pase cualquiera de esos por GetLoadedFormFieldDefaultValue y SetFormFieldValue y obtiene una cadena donde va un nombre, una cadena vacía donde había un array, o dígitos hex literales

El reset ramifica por tanto según lo que el /DV heredado es de verdad:

  • Campos de texto y de elección con un default de cadena reciben un objeto cadena nuevo que conserva el flag hexadecimal del original
  • Campos de elección con un default de array reciben un array nuevo de cadenas nuevas; un array que contenga cualquier cosa que no sean cadenas lanza una excepción
  • Casillas y botones de radio reciben un objeto nombre nuevo
  • Un default de cualquier otro tipo lanza una excepción, y /V y /I se quedan exactamente como estaban
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 pushbuttons y los campos de firma toman el camino de cadena sencilla

Copiar en lugar de referenciar es deliberado, y es la única opción segura. Un /V hijo que apuntara al array /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 bytes extra por campo reseteado es un precio barato por mantener los dos separados. Los pushbuttons, que no tienen valor (Tabla 226, bit 17), y los campos de firma se quedan en un camino llano que escribe una copia de cadena de su propio /DV

Cuando no existe ningún /DV en toda la cadena hacia arriba, el método escribe una cadena vacía local, o /Off para un campo de casilla o de radio. Borrar el /V del hijo parecería más ordenado y daría la respuesta equivocada. Si el padre sostiene un valor actual, eliminar la anulación del hijo trae ese valor de vuelta directamente, y el campo "reseteado" muestra ahora algo que el usuario nunca escribió 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 solo lleva /T y /V
  Field := Pdf.GetFormField('palette.primary');
  try
    try
      Pdf.ResetLoadedFormField(Field.Index);
      // El hijo tiene ahora su propio /V [(b) (r)] y /I [0 2];
      // el array /DV del padre queda intacto
    except
      on E: Exception do
        // Default mal formado (tipo equivocado, elemento de array no cadena):
        // /V e /I se quedaron como estaban
        Writeln('Reset skipped: ', E.Message);
    end;
    // El getter escalar no puede representar un default de array
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vacío
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Un reset de un solo campo es también algo distinto de 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; esa acción se cubre 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 recoja

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, y ResetLoadedFormField termina con los mismos dos pasos de seguimiento que SetFormFieldValue. Para los campos de elección, borra el /I local del hijo, coteja cada valor contra la mitad de export de cada entrada /Opt, y escribe un /I nuevo y ordenado. Resetear a [(b) (r)] contra opciones b, g, r da /I [0 2]. El /I heredado del padre, si lo hay, queda intacto

Los botones trabajan a partir del tipo heredado. Una casilla hija cuyo /FT /Btn vive en el padre recibe también su /AS actualizado. En el 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 ninguna 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 activado, y da a cada flujo de estado /Type /XObject, /Subtype /Form y un /BBox. Sin esa comprobación del /Off, regenerar apariencias tras un reset volvería a mostrar la casilla marcada antes incluso de guardar el archivo

Getters escalares y otros bordes con los que planear

Los getters de cadena se quedan escalares, y el código que compare su salida debería saber qué aplanan. GetFormFieldValue y GetLoadedFormFieldDefaultValue devuelven una cadena vacía para un valor de array, convierten números y booleanos en 42 o true, y reportan una cadena codificada en hex en su grafía hexadecimal. Si necesita saber si el default de un list box guarda dos selecciones o ninguna, el getter de default no puede decírselo: ambas vuelven como cadena vacía

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 merece registrarse. SetFormFieldValue y ResetLoadedFormField escriben siempre el hijo al que usted apunta y nunca promueven un valor al padre compartido. Eso es lo 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 diríjase a ese campo y no a uno de sus kids

Cada llamada compromete además un campo por su cuenta. Un bucle de resets sobre veinte campos son veinte ediciones independientes; si la quinta lanza excepción por un default mal formado, las cuatro primeras 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 cubre también los proyectos Delphi y C++Builder que necesitan añadir campos nuevos a un documento existente, como se muestra en añadir campos AcroForm a un PDF cargado en Delphi