Artículo técnico

Orden de tabulación de formularios PDF y /Tabs en Delphi

Desde la versión 3.223.0, PDFium Component para Delphi ordena la navegación de campos de formulario por la política /Tabs declarada de cada página: R para orden por filas, C por columnas, S por estructura, más los valores A y W de PDF 2.0. TPdf.FormFieldTabOrder devuelve ese orden, y su sobrecarga con estado reporta si la política declarada se aplicó o se usó un fallback

La queja que motiva todo esto es siempre la misma. Un usuario llena un formulario de seguro de dos columnas, presiona Tab después del campo de nombre, y el cursor salta al fondo de la columna derecha. Nada está roto en los índices. El productor simplemente escribió la columna derecha primero en /Annots y declaró /Tabs /R en la página para arreglar el orden visual, así que un visor que recorre /Annots e ignora la declaración aterriza en el lugar equivocado. El artículo sobre índice de widget contra índice de anotación cubre cómo los ordinales de widget se relacionan con el arreglo /Annots. En PDFium Component la política declarada gana siempre que pueda establecerse, y cuando no puede, el componente le dice por qué

¿Qué declara en realidad la entrada /Tabs?

/Tabs es un nombre opcional en el objeto de página (ISO 32000-2 §7.7.3.3; Tabla 30 en ISO 32000-1) que le dice a un procesador en qué orden visitar las anotaciones de esa página, y su semántica está detallada bajo anotaciones en §12.5.1. PDF 1.5 introdujo tres valores, y PDF 2.0 agregó dos más:

  • /R, orden por filas: filas de arriba abajo, y dentro de cada fila la dirección de lectura del visor
  • /C, orden por columnas: columnas en la dirección de lectura, y de arriba abajo dentro de cada columna
  • /S, orden por estructura: el orden en que el árbol de estructura lógica referencia las anotaciones
  • /A, orden del arreglo de anotaciones: el orden del propio /Annots (PDF 2.0)
  • /W, orden de widgets: primero los widgets en orden de arreglo, luego las demás anotaciones (PDF 2.0)

El detalle que tropieza a las implementaciones escritas a mano es la herencia. /Tabs pertenece solo al diccionario de página. ISO 32000-2 §7.7.3.4 limita los atributos de página heredables a Resources, MediaBox, CropBox y Rotate, así que una clave /Tabs puesta en un nodo /Pages no significa nada, y un lector que sube por la cadena de padres para encontrar una está inventando una política que el autor nunca puso. PDFium Component lee /Tabs de la página misma y de ningún otro lado

Leer el orden declarado con FormFieldTabOrder

TPdf.FormFieldTabOrder devuelve los campos de formulario de la página actual como ordinales de widget base 0 en el orden de tabulación declarado, y la sobrecarga con un parámetro out Status: TPdfFormTabOrderStatus explica cómo se produjo ese orden. El mismo par existe en TPdfView, y la sobrecarga simple usa la política idéntica mientras descarta el estado

uses
  PDFium;

const
  ModeNames: array[TPdfFormTabOrderMode] of string =
    ('unspecified', 'R', 'C', 'S', 'A', 'W', 'unsupported');

procedure DumpTabOrder(const FileName: string; PageNo: Integer);
var
  Pdf: TPdf;
  Order: TPdfIntegerArray;
  Status: TPdfFormTabOrderStatus;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.FormFill := True;          // ya es el valor por defecto; requerido para campos
    Pdf.Active := True;
    if not Pdf.Active or (Pdf.FormType <> ftAcroForm) then
      Exit;
    Pdf.PageNumber := PageNo;
    Order := Pdf.FormFieldTabOrder(Status);
    Writeln('requested ', ModeNames[Status.RequestedMode],
      ', applied ', ModeNames[Status.EffectiveMode]);
    if Status.UsedFallback then
      Writeln('fallback: ', Status.ErrorMessage);
    if Status.UnstructuredWidgetCount > 0 then
      Writeln(Status.UnstructuredWidgetCount, ' widgets not in the structure tree');
    for I := 0 to High(Order) do
      Writeln(I, ': field ', Order[I], ' ',
        string(Pdf.FormFieldInfo[Order[I]].Name));
  finally
    Pdf.Free;
  end;
end;

El registro de estado es lo que convierte una decisión de orden silenciosa en algo que puede registrar. RequestedMode es lo que la página declaró, EffectiveMode es lo que se aplicó, y UsedFallback es true cuando la política declarada no pudo honrarse y el orden del arreglo de anotaciones volvió en su lugar, con una razón corta en ErrorMessage. SnapshotAvailable dice si el componente pudo leer los metadatos del documento en absoluto. Un /Tabs faltante se reporta como fallback al orden del arreglo de anotaciones en vez de hacerse pasar por una política declarada, así que un log de soporte puede distinguir "el autor pidió este orden" de "la página no pidió nada"

Valores de la política /Tabs de página R, C, S, A y W con los campos de estado que PDFium Component para Delphi reporta cuando el orden declarado se aplica o recurre al fallback
un /Tabs faltante es un fallback reportado al orden del arreglo de anotaciones, no una política declarada hecha pasar por una

¿Por qué el orden por filas y columnas no puede simplemente ordenar los valores /Rect?

El orden por filas y columnas tiene que calcularse en geometría de visualización, no en coordenadas crudas de anotaciones, porque el /Rotate de una página y la dirección de lectura del documento ambos cambian lo que "fila" significa. Ordene los arreglos /Rect de una página con /Rotate 90 por su coordenada y y obtiene lo que el visor muestra como columnas. El componente por tanto mapea cada rectángulo de widget por la transformación de visualización de la página, rotación intrínseca incluida, a resolución de 1/64 de punto, y agrupa desde allí

La dirección de lectura viene del diccionario ViewerPreferences del catálogo (ISO 32000-2 §12.2). /Direction /R2L invierte el orden dentro de una fila e invierte el orden de las columnas, que es lo que espera un formulario árabe o hebreo. Cuando /Direction está ausente, aplica L2R. Cuando contiene algo distinto de L2R o R2L, el orden por filas y columnas recurre al orden del arreglo de anotaciones con la razón en el estado, porque adivinar una dirección es peor que admitir que no hay ninguna

El agrupamiento es donde los procesadores legítimamente difieren, así que vale conocer la regla exacta. Para el orden por filas, el widget no visitado más arriba se vuelve el ancla, y cada widget cuyo centro vertical cae dentro del tope y fondo del ancla se une a su fila. El orden por columnas refleja esto horizontalmente. Los empates se resuelven determinísticamente por el ordinal del widget. En una cuadrícula limpia esto coincide con cualquier visor sensato. En un layout con campos fuertemente superpuestos o escalonados, otro procesador con una tolerancia distinta puede agrupar diferente, y ninguna parte de ISO 32000 hace que alguna de las dos respuestas esté mal

PDFium Component en Delphi: el orden de tabulación del arreglo de anotaciones salta al fondo de la columna derecha mientras el orden de filas /Tabs R declarado visita el formulario de dos columnas fila por fila
el orden por filas se calcula en la geometría de visualización tras /Rotate y la dirección de lectura, no en el orden crudo de /Annots

¿Cómo decide la secuencia el orden por estructura?

El orden por estructura sigue los diccionarios de referencia de objetos (OBJR) del árbol de estructura, en el orden en que un recorrido en profundidad de los arreglos /K realmente los alcanza. No sigue el orden de claves del /ParentTree, y no sigue el orden que produzca una enumeración del StructTreeRoot. Esos órdenes suelen coincidir en archivos de un solo productor y divergen tras un editor que rearma el árbol, que es precisamente cuando el orden de lectura de un formulario etiquetado importa más. Si usted mantiene ese árbol, la guía para mantener /ParentTree y /StructParents sincronizados cubre el mapeo inverso que tiene que permanecer consistente con él

Los widgets que el árbol de estructura nunca referencia los deja el estándar al procesador. PDFium Component los agrega después de los widgets estructurados en orden del arreglo de anotaciones y reporta cuántos agregó en UnstructuredWidgetCount. Una página que declara /S sobre un documento sin árbol de estructura alguno se trata como un recorrido vacío: cada widget aterriza en esa cola, el conteo lo dice, y el resultado no se marca como fallback completo. Un árbol de estructura con un ciclo, un OBJR referenciado dos veces, o un OBJR cuyo /Pg contradice la página que posee la anotación hace que el orden por estructura recurra al fallback. Esos defectos vuelven S inutilizable, pero no perturban R ni C en otras páginas

¿Por qué los valores de orden de tabulación no son índices de anotación?

Los valores del arreglo que devuelve FormFieldTabOrder cuentan solo widgets, mientras que un OBJR identifica un objeto de anotación que vive en el arreglo /Annots completo. Mezclar los dos espacios de índices es el bug único más probable en cualquier código que consuma el orden por estructura. Tome una página cuyo /Annots es [Link, Widget A, Widget B]. El OBJR del Widget B apunta al índice de anotación 2. El Widget B es el ordinal de campo 1. Alimente 2 a FocusFormField y o enfoca el campo equivocado o no enfoca nada, según cuántos widgets tenga la página

El componente hace esa conversión internamente, razón por la que su resultado puede entrar directo a FocusFormField y FormFieldInfo. Si su propio código lee objetivos OBJR u otros índices de anotación, conviértalos explícitamente antes de tratarlos como campos:

El OBJR de estructura apunta al índice de anotación 2 mientras las APIs de formularios de PDFium Component en Delphi cuentan widgets, así que el Widget B es el ordinal de campo 1
el índice de anotación 2 no es el ordinal de campo 1 — convierta explícitamente antes de llamar a FocusFormField
// Convierte un índice de anotación de página al ordinal de widget que usan
// FocusFormField, FormFieldInfo y FormFieldTabOrder; -1 si no es un widget
function AnnotationToFieldOrdinal(Pdf: TPdf; AnnotIndex: Integer): Integer;
var
  I: Integer;
begin
  Result := -1;
  if (AnnotIndex < 0) or (AnnotIndex >= Pdf.AnnotationCount) then
    Exit;
  if Pdf.Annotation[AnnotIndex].Subtype <> anWidget then
    Exit;                          // enlaces, notas y popups no son campos
  Result := 0;
  for I := 0 to AnnotIndex - 1 do
    if Pdf.Annotation[I].Subtype = anWidget then
      Inc(Result);
end;

Trate el resultado como válido solo mientras la página siga cargada y sin editar. Agregar o quitar cualquier anotación delante de un widget desplaza ambos espacios de índices, y un mapeo en caché de antes de la edición es exactamente el tipo de off-by-one que se reproduce solo en archivos de clientes

Navegación entre páginas con FocusNextFormField

FocusNextFormField(AcrossPages: Boolean; WrapDocument: Boolean = True; MaxPagesToScan: Integer = 256) recorre el orden de tabulación de la página actual, luego continúa hacia páginas posteriores, y desde 3.223.0 cada página que visita aplica su propia política declarada. La sobrecarga entre páginas llegó en la versión 3.220.0, así que el consejo del artículo de navegación de campos de formulario de detectar el wrap y cambiar PageNumber usted mismo ahora aplica solo a la sobrecarga sin argumentos, que aún cicla dentro de una página

procedure TFormViewer.HandleTabKey(Shift: TShiftState);
var
  Field: Integer;
begin
  if ssShift in Shift then
    Field := PdfView.FocusPreviousFormField(True)  // entre páginas, con wrap
  else
    Field := PdfView.FocusNextFormField(True);
  if Field < 0 then
    Beep   // nada elegible dentro del escaneo; página y foco sin cambios
  else
    UpdateFieldStatus(PdfView.PageNumber, Field);
end;

El valor de retorno es el ordinal de campo local a la página en la página que ahora tenga el foco, así que lea PageNumber junto a él. El recorrido entre páginas se salta los widgets en que un usuario no puede tabular con sentido: widgets ocultos, invisibles, NoView, de solo lectura, de firma, de tipo desconocido y de rectángulo vacío. Pasar WrapDocument = False se detiene en la frontera del documento con -1. MaxPagesToScan debe estar entre 1 y 4096, y cada página inspeccionada cuenta, incluida la actual. Cuando el escaneo no encuentra nada o el motor nativo se niega a enfocar el objetivo, la llamada devuelve -1 y deja la página y el foco anteriores exactamente como estaban

¿Dónde deja de aplicar el orden declarado?

La política declarada se honra solo cuando el componente puede leer los metadatos de la página de forma confiable, y cada caso en que no puede recurre al orden de widgets del arreglo de anotaciones con una razón explícita. Las fronteras son suficientemente específicas para listarlas:

  • Los metadatos vienen de una serialización privada acotada del documento nativo actual, no de los bytes del archivo original, porque los movimientos y ediciones de páginas en memoria vuelven obsoleto el archivo en disco. La instantánea se guarda en caché por revisión de contenido, así que las ediciones notificadas la invalidan, y las instantáneas fallidas también se guardan en caché
  • Los documentos cifrados, y las instantáneas pedidas durante una edición de page-object, no tienen instantánea y siempre recurren al fallback
  • La instantánea se acota en 64 MiB, 65536 objetos activos, 4096 páginas, 4096 anotaciones por página, 65536 anotaciones en total y una profundidad de estructura de 64; los documentos mayores recurren al fallback en vez de atascar la navegación
  • Solo los documentos AcroForm están cubiertos. Los formularios XFA quedan fuera de este perfil
  • El orden por filas y columnas necesita rectángulos de widget finitos y no vacíos y dimensiones de página sanas; la geometría degenerada dispara el fallback
  • Estas APIs manejan la navegación desde su código. El manejo propio de PDFium de una tecla Tab enviada al motor no se reemplaza, así que enrute la tecla por FocusNextFormField si quiere el orden declarado

Las consultas preservan la página actual, el foco nativo y cualquier texto de edición pendiente en el campo enfocado, y no corren acciones de ciclo de vida de documento o página. Llamar FormFieldTabOrder en cada pulsación de tecla es por tanto seguro, y tras la primera llamada sobre una revisión de contenido dada es barato

El orden de tabulación declarado, los diagnósticos de estado y la navegación de foco entre páginas son parte del PDFium Component para Delphi y C++Builder; la página del producto enlaza la referencia completa de la API, incluidas las entradas TPdfFormTabOrderMode y TPdfFormTabOrderStatus