Artículo técnico

Campos PDF multiselección en round trips FDF y XFDF (Delphi)

Un valor de export de list box formado por Deep, un salto de línea y Blue es PDF perfectamente legal, y también lo es una segunda opción llamada Red. Escoja ambas, aplane la selección en una sola cadena separada por LF para un archivo de intercambio, y el otro lado recibe Deep\nBlue\nRed: dos selecciones o tres, y nada en los bytes dice cuál. Los valores multiselección vuelven intactos de FDF o XFDF solo cuando se mantienen como arrays todo el camino, desde el momento en que salen del PDF hasta que se escriben de vuelta en él

¿Por qué una unión con saltos de línea nunca puede transportar un valor multiselección?

Una cadena unida pierde los bordes entre valores, y como un valor puede contener el separador, ningún import puede volver a ponerlos. ISO 32000-1 §12.7.4.4 permite que la entrada /V de un campo de elección sea una única cadena de texto o un array de cadenas de texto; un list box con el flag MultiSelect (bit 22 de /Ff) usa la forma de array en cuanto se escoge más de una opción. La misma sección define /I, un array de índices de opción empezando en cero y en orden ascendente, que es como un visor distingue dos opciones que comparten valor de export. Ninguna de las dos estructuras cabe por una API tipada como cadena. GetFormFieldValue en HotPDF devuelve un AnsiString y lee la forma de cadena de /V, así que cualquier export construido sobre él solo puede escribir un escalar por campo

El lado del import tiene el problema especular. XFDF escribe cada selección como su propio elemento <value>, y un import que los entregue a un setter escalar tiene que pegarlos antes, normalmente con LF. Con la opción Deep/Blue de arriba, ese pegamento es indistinguible de los datos. HotPDF mantiene por tanto los escalares fuera del centro del round trip: ExportLoadedFormToFDF, ExportLoadedInterchangeToFDF y ExportLoadedFormToXFDF leen el array directamente del campo, y los métodos de import correspondientes construyen un array nuevo sin pasar nunca por una cadena unida

Round trip multiselección ingenuo donde dos opciones de list box escogidas, una con un salto de línea incrustado, se aplanan por un camino escalar GetFormFieldValue en la única cadena Deep, line feed, Blue, line feed, Red, que los lectores aguas abajo pueden parsear como dos selecciones o como tres
Unir valores multiselección en una cadena destruye los bordes entre valores, y un valor de export que en sí contiene un salto de línea vuelve ambigua la forma aplanada

Qué contienen los archivos FDF y XFDF exportados

En disco los bordes se quedan visibles. En FDF un campo multiselección se convierte en un array tipado, y cada elemento conserva la grafía que tenía en el PDF de origen: las cadenas hexadecimales salen como hex, las cadenas literales salen como literales con CR y LF escapados como \r y \n. En el archivo que escribe ExportLoadedFormToFDF, un /RV de texto enriquecido o un /Ff local distinto de cero en el campo viajan en el mismo diccionario de campo. En XFDF el elemento raíz declara xml:space="preserve", que ISO 19444-1 exige y que convierte cada carácter de espacio en blanco dentro de un elemento de texto en dato. Eso es una trampa para el pretty-printing. HotPDF escribe la etiqueta de apertura, el texto escapado y la etiqueta de cierre de cada <value> en una línea, deja la indentación fuera del elemento, y codifica CR, LF y TAB como referencias de carácter para que un parser XML que aplique normalización de fines de línea no pueda reescribir un par CR LF como un LF solo

Formas de export que HotPDF escribe para un list box multiselección: FDF lleva un array tipado por campo con /V [(Deep salto de línea Blue) (Red)] y un valor de región en hex, mientras que XFDF lleva un elemento value por selección bajo xml:space preserve para que el espacio en blanco cuente como dato
Los bordes se quedan visibles en disco: FDF guarda cada selección como su propio elemento del array y XFDF escribe cada una en un elemento value aparte, así que ningún import tiene que adivinar
<!-- FDF: un array tipado por campo -->
<< /T (options) /V [(Deep\nBlue) (Red)] >>
<< /T (region) /V [<45553132>] >>

<!-- XFDF: un <value> por selección -->
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
  <fields>
    <field name="options">
      <value>Deep&#xA;Blue</value>
      <value>Red</value>
    </field>
  </fields>
</xfdf>

Dos casos límite del export importan cuando escriba el código que llama. ExportLoadedFormToFDF construye el cuerpo FDF completo en memoria y solo entonces crea el archivo de destino, así que un valor que no se puede exportar (un array que lleva algo que no sean cadenas, por ejemplo) lanza excepción mientras el .fdf anterior en disco sigue intacto. El segundo caso es un hueco genuino del propio XFDF: una selección vacía en un list box que además ofrece un valor de export de cadena vacía. <value/> podría significar "nada seleccionado" o "la opción vacía seleccionada", y el formato no tiene una tercera grafía. ExportLoadedFormToXFDF lanza excepción en esa situación, antes de abrir el archivo de destino. FDF no tiene ese problema, porque /V [] y /V [()] son objetos distintos. Los tres exporters se saltan los terminales de solo widget que no tienen nombre /T, ya que ningún import podría volver a emparejar semejante entrada con un campo

var
  Pdf: THotPDF;
  Written: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    begin
      // Los list box multiselección se escriben como /V [(...) (...)]
      Written := Pdf.ExportLoadedFormToFDF('order-form.fdf');
      try
        Pdf.ExportLoadedFormToXFDF('order-form.xfdf');
      except
        on E: Exception do
          // Selección vacía más una opción de export vacía: XFDF no distingue
          // entre ellas, y el archivo .xfdf existente queda intacto
          ShowMessage('XFDF export refused: ' + E.Message);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

¿Cómo se coteja una selección importada contra /Opt?

HotPDF acepta un array entrante solo cuando el destino es un campo de elección con el flag MultiSelect puesto y cada valor coincide con un valor de export del array /Opt del campo (para un par de opción de dos elementos, el primero). Cada hueco de opción puede usarse una vez. Una lista con dos opciones que exportan ambas b acepta por tanto [<62> <62>] como dos selecciones distintas y rechaza un tercer b, porque no queda hueco libre para él. El /I reconstruido sigue el orden de /Opt sea cual sea el orden en que llegaron los valores, lo que mantiene los índices ascendentes como exige §12.7.4.4. Los nuevos /V y /I se construyen como objetos sueltos y se asignan solo después de que cada valor haya pasado, así que un valor rechazado no puede dejar medio array ni índices obsoletos en el campo

Tras la asignación el campo recibe un flujo de apariencia nuevo. Ese paso es fácil de olvidar en un import hecho a mano, y el síntoma es confuso: los visores dibujan el /AP almacenado, no /V, así que la selección antigua sigue en pantalla y en la impresión aunque los datos hayan cambiado. El list box se une además al conjunto de campos cambiados, lo que significa que una llamada sin parámetros a RecalculateLoadedFormFieldsIncremental vuelve a ejecutar exactamente los cálculos que lo leen. Las grafías hex que llegan de FDF se quedan hex durante el guardado. Si solo necesita fijar un valor escalar, fijar el valor de un campo de formulario en un PDF cargado cubre ese camino, que por diseño no maneja selecciones múltiples

Validación de import de valores multiselección en HotPDF: el destino debe ser un campo de elección con MultiSelect puesto en /Ff, cada valor entrante debe coincidir con un valor de export de /Opt usando cada hueco una vez, /I se reconstruye ascendente en orden de /Opt, y el /V y /I sueltos se asignan solo cuando todos los valores pasan
Cada valor entrante se coteja contra las opciones del campo antes de escribir nada, así que un valor rechazado nunca deja medio array ni índices de selección obsoletos

Un patrón de entrada merece tratamiento especial. Un PDF puede almacenar un valor de export ASCII plano como cadena hex sin byte order mark, <416272> para Abr, y un exporter que copie el cuerpo de la cadena PDF tal cual produce XFDF cuyo texto de elemento son los dígitos en bruto 416272 en lugar del texto decodificado. Un XFDF que lleve un valor de export codificado en hex como sus dígitos en bruto no puede coincidir con /Opt por grafía: una comparación estricta no encuentra ninguna opción llamada 416272 y el import falla. Cuando el import XFDF de HotPDF recibe un archivo así, reintenta una vez el valor sin coincidencia decodificándolo como payload hex. El reintento es deliberadamente estrecho. Solo se ejecuta para un valor sin ninguna coincidencia exacta; cada coincidencia exacta se reserva primero, así que una grafía rescatada nunca puede ocupar un hueco de opción que necesite un valor posterior escrito exactamente; y el elemento /V almacenado es la propia cadena PDF de la opción, de modo que /V e /I describen la misma selección. Es la cantidad justa de tolerancia: se dispara solo donde el import habría lanzado excepción de otro modo, y un valor que ya coincidía nunca se reinterpreta. La propia comparación pasa por un único decodificador Unicode que entiende PDFDocEncoding, UTF-16 con cualquiera de los dos byte order marks y UTF-8, así que el mismo texto lógico coincide con la misma opción incluso en documentos que mezclan encodings

Dos trampas al parsear FDF: terminadores hex y números de objeto ajenos

La primera trampa es léxica. En << /T (region) /V <416273>>> el primer > cierra la cadena hex y los dos siguientes cierran el diccionario. Un scanner que solo busque pares >> termina el diccionario un carácter antes y pierde el campo. El import FDF a nivel de archivo de HotPDF lleva la cuenta de si está dentro de una cadena hex o de un literal mientras trocea el array /Fields, y el lector basado en stream tras ImportLoadedInterchangeFromFDF tokeniza el archivo entero como sintaxis PDF, así que ambos ven tres corchetes de cierre donde un barrido ingenuo ve dos

La segunda trampa va de referencias. Un archivo FDF es un documento pequeño en sintaxis PDF con numeración de objetos propia (ISO 32000-1 §12.7.7), así que /V [11 0 R] significa el objeto 11 del archivo FDF, nunca el objeto 11 del PDF que se está rellenando. Resolverlo contra el documento de destino traería en silencio el objeto que casualmente lleve ese número allí. En compilaciones con Delphi 2009 y posteriores, el lector de intercambio decodifica el FDF como un grafo de objetos completo, sigue la referencia dentro de ese grafo, valida las cadenas resultantes como cualquier array inline, y lanza excepción si el FDF no contiene el objeto 11. El ImportLoadedFormFromFDF a nivel de archivo lee cada diccionario de campo por su cuenta sin la tabla de objetos del FDF; un array de referencias no pasa su comprobación de solo cadenas, y ese campo se salta

Elegir una ruta de import: saltar, lanzar o revertir

Las tres rutas de import validan los valores multiselección de la misma manera. Difieren en lo que pasa cuando un valor falla. ImportLoadedFormFromFDF se salta cualquier campo que falle y devuelve el número de campos que sí aplicó, así que un recuento menor que el número de entradas enviadas es la única señal de problema. ImportLoadedFormFromXFDF e ImportLoadedInterchangeFromFDF lanzan excepción en el primer campo rechazado, y cada llamada se ejecuta como una transacción sobre el documento cargado: cuando la excepción sale del método, cada campo (y, en la ruta de intercambio, cada anotación) que la llamada ya había cambiado se revierte. Un import de intercambio que devuelve False también se revierte, con Status y el record de información limpiados

Para datos que una persona revisó y devolvió, use una de las rutas que lanzan excepción. Un formulario de pedido aplicado a medias es peor que uno rechazado, porque nada en el PDF guardado muestra qué list boxes vinieron del archivo revisado y cuáles conservaron sus valores antiguos. El comportamiento de saltar y contar de ImportLoadedFormFromFDF encaja con otro trabajo: un prerrelleno de mejor esfuerzo desde un FDF que pudo generarse para una revisión ligeramente distinta del formulario

var
  Pdf: THotPDF;
  Source: TMemoryStream;
  Status: AnsiString;
  Info: THPDFFDFInterchangeInfo;
begin
  Pdf := THotPDF.Create(nil);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile('order-form-reviewed.fdf');
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    try
      // Solo campos. Un valor fuera de /Opt, o varios valores dirigidos a una
      // lista de selección única, lanza excepción y revierte el import entero
      if Pdf.ImportLoadedInterchangeFromFDF(Source, True, False, Status, Info) then
      begin
        // Vuelve a ejecutar solo los cálculos que leen los campos importados
        Pdf.RecalculateLoadedFormFieldsIncremental;
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
      end;
    except
      on E: Exception do
        ShowMessage('Import rejected, document unchanged: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

Enlazar su propio almacén de datos a los callbacks de array XFDF

La unit HPDFXFDF, que convierte entre XFDF y cualquier almacén de campos que pueda describir con callbacks, lleva el soporte de arrays en un record aparte, THPDFXFDFArrayAccess, y en overloads extra de HPDFXFDFExportFields y HPDFXFDFImportFields. Añadir slots nuevos al final del record THPDFXFDFAccess existente habría sido más corto y erróneo. Quien llama suele declarar ese record como variable local y rellenar solo los slots que conoce, así que un puntero a función nuevo al final contendría basura de stack que la librería tomaría por un callback. Con el record aparte, el código existente conserva su layout y sus overloads, y esos overloads pasan internamente un record de arrays todo nil

Los dos overloads de import se comportan distinto ante elementos <value> repetidos. El overload escalar original sigue uniéndolos con LF y llama a SetFormFieldValue, porque quienes ya lo usan dependen de esa convención. El overload consciente de arrays nunca une: entrega la lista a SetFormFieldValueArray, recurre al setter escalar solo para un único valor que su callback rechace, y lanza excepción cuando su callback rechaza una lista de dos o más. En el export, parta de Default(THPDFXFDFArrayAccess) para que los slots que no asigne (incluido el opcional SetFormFieldRV) se queden en nil. Devuelva True desde GetFormFieldValueArray para cualquier campo con valor de lista, incluido uno sin nada seleccionado, y False para dejar que el callback escalar se ocupe del campo. Un array vacío sale como un único <value/> vacío, así que un almacén que ofrezca una opción de cadena vacía debería rechazar ese caso dentro del callback, igual que hace ExportLoadedFormToXFDF

uses HPDFXFDF;

// Puntero de función llano, no "of object": Context lleva su propio almacén.
// TFormStore y sus métodos son código suyo, no forman parte de HotPDF
function StoreGetSelections(Context: Pointer; FieldIndex: Integer;
  out Values: THPDFXFDFValueArray): Boolean;
begin
  Result := TFormStore(Context).IsListField(FieldIndex);
  if Result then
    Values := TFormStore(Context).Selections(FieldIndex);
end;

procedure ExportStore(Store: TFormStore; out Bytes: TBytes);
var
  Access: THPDFXFDFAccess;
  ArrayAccess: THPDFXFDFArrayAccess;
begin
  Access := MakeStoreAccess(Store);             // sus bindings escalares existentes
  ArrayAccess := Default(THPDFXFDFArrayAccess); // cada slot sin usar es nil
  ArrayAccess.GetFormFieldValueArray := StoreGetSelections;
  HPDFXFDFExportFields(Access, ArrayAccess, Bytes);
end;

Todo lo anterior asume list boxes que ya existen y ya tienen el bit MultiSelect puesto en /Ff. Crear campos de elección y fijar sus bits de flag se cubre en añadir campos ListBox y demás AcroForm a un PDF cargado, y el markup de comentarios que viaja por la parte <annots> de XFDF se cubre en import y export de anotaciones XFDF en HotPDF. La referencia de métodos de cada llamada mostrada aquí, más una compilación de prueba para ejecutarlas contra sus propios formularios, está en la página del componente PDF HotPDF para Delphi