Desde la versión 3.223.0, PDFium Component para Delphi ordena la navegación de campos de formulario según 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 informa de si se aplicó la política declarada o se usó un fallback
La queja que motiva todo esto es siempre la misma. Un usuario rellena un formulario de seguro de dos columnas, pulsa Tab tras el campo del nombre, y el cursor salta al fondo de la columna derecha. Nada está roto en los índices. El productor sencillamente 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 sitio equivocado. El artículo sobre índice de widget frente a índice de anotación cubre cómo se relacionan los ordinales de widget con el array /Annots. En PDFium Component la política declarada gana siempre que pueda establecerse, y cuando no puede, el componente te dice por qué
Qué declara realmente 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 dice a un procesador en qué orden visitar las anotaciones de esa página, y su semántica se detalla bajo anotaciones en §12.5.1. PDF 1.5 introdujo tres valores, y PDF 2.0 añadió 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 array de anotaciones: el orden del propio/Annots(PDF 2.0)/W, orden de widgets: primero los widgets en orden de array, luego las demás anotaciones (PDF 2.0)
El detalle que hace tropezar 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 en un nodo /Pages no significa nada, y un lector que suba por la cadena de padres a buscar una está inventando una política que el autor nunca puso. PDFium Component lee el /Tabs de la página misma y de ningún otro sitio
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 plana 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 puedes loguear. RequestedMode es lo que declaró la página, EffectiveMode es lo que se aplicó, y UsedFallback es true cuando la política declarada no pudo honrarse y volvió el orden del array de anotaciones, con una razón corta en ErrorMessage. SnapshotAvailable dice si el componente pudo leer los metadatos del documento siquiera. Un /Tabs ausente se reporta como fallback al orden del array de anotaciones en vez de hacerse pasar por política declarada, así que un log de soporte puede distinguir «el autor pidió este orden» de «la página no pidió nada»
¿Por qué el orden por filas y columnas no puede ordenar simplemente los valores /Rect?
El orden por filas y columnas tiene que computarse 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 cambian ambos lo que significa «fila». Ordena los arrays /Rect de una página con /Rotate 90 por su coordenada y y obtienes lo que el visor muestra como columnas. El componente por eso mapea cada rectángulo de widget a través de 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 ahí
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 cualquier cosa distinta de L2R o R2L, el orden por filas y columnas recurre al orden del array de anotaciones con la razón en el estado, porque adivinar una dirección es peor que admitir que no la hay
El agrupamiento es donde los procesadores difieren legítimamente, así que conviene conocer la regla exacta. Para el orden por filas, el widget sin visitar más arriba se vuelve el ancla, y cada widget cuyo centro vertical cae dentro del techo y suelo del ancla se une a su fila. El orden por columnas refleja esto horizontalmente. Los empates se resuelven de forma determinista por el ordinal del widget. En una rejilla limpia esto coincide con cualquier visor sensato. En un layout con campos muy solapados o escalonados, otro procesador con una tolerancia distinta puede agrupar diferente, y ninguna parte de ISO 32000 hace que cualquiera de las dos respuestas esté mal
¿Cómo decide la secuencia el orden por estructura?
El orden por estructura sigue los diccionarios de referencia de objeto (OBJR) del árbol de estructura, en el orden en que un recorrido en profundidad de los arrays /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 único productor y divergen tras reordenar un editor el árbol, que es justo cuando más importa el orden de lectura de un formulario etiquetado. Si mantienes ese árbol tú mismo, la guía para mantener /ParentTree y /StructParents sincronizados cubre el mapeo inverso que tiene que quedarse consistente con él
Los widgets que el árbol de estructura jamás referencia los deja la especificación en manos del procesador. PDFium Component los añade tras los widgets estructurados en orden del array de anotaciones y reporta cuántos añadió 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 recuento 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 a la página que posee la anotación hace que el orden por estructura recurre al fallback. Esos defectos dejan S inservible, 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 array que devuelve FormFieldTabOrder cuentan solo widgets, mientras que un OBJR identifica un objeto de anotación que vive en el array /Annots completo. Mezclar los dos espacios de índices es el bug más probable de todos en cualquier código que consuma el orden por estructura. Toma una página cuyo /Annots es [Link, Widget A, Widget B]. El OBJR de Widget B apunta al índice de anotación 2. Widget B es el ordinal de campo 1. Dale un 2 a FocusFormField y o enfocas el campo equivocado o no enfocas nada, según cuántos widgets tenga la página
El componente hace esa conversión internamente, que es por lo que su resultado puede ir directo a FocusFormField y FormFieldInfo. Si tu propio código lee objetivos OBJR u otros índices de anotación, conviértelos explícitamente antes de tratarlos como campos:
// 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; // los 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;
Trata el resultado como válido solo mientras la página siga cargada y sin editar. Añadir o quitar cualquier anotación delante de un widget desplaza ambos espacios de índices, y un mapeo cacheado 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 la vuelta y cambiar PageNumber tú mismo ahora aplica solo a la sobrecarga sin argumentos, que sigue ciclando 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 vuelta
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 de la página en la que sea que esté el foco ahora, así que lee PageNumber junto a él. El recorrido entre páginas se salta los widgets en los que un usuario no puede tabular con sentido: widgets ocultos, invisibles, NoView, read-only, de firma, de tipo desconocido y de rectángulo vacío. Pasar WrapDocument = False se para 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 aplicarse el orden declarado?
La política declarada solo se honra cuando el componente puede leer los metadatos de la página con fiabilidad, y cada caso en que no puede recurre al orden de widgets del array de anotaciones con una razón explícita. Las fronteras son lo bastante 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 dejan rancio el archivo en disco. La instantánea se cachea por revisión de contenido, así que las ediciones notificadas la invalidan, y las instantáneas fallidas también se cachean
- Los documentos cifrados, y las instantáneas pedidas durante una edición de page-object, no tienen instantánea y recurren siempre al fallback
- La instantánea tiene topes de 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 se cubren los documentos AcroForm. Los formularios XFA se 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 sensatas; la geometría degenerada dispara el fallback
- Estas APIs conducen la navegación desde tu código. El manejo propio de PDFium de una tecla Tab reenviada al motor no se sustituye, así que encamina la tecla vía
FocusNextFormFieldsi quieres 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 ejecutan acciones de ciclo de vida de documento o página. Llamar a 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 forman 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