En PDFium Component, el componente VCL/LCL basado en PDFium para Delphi, C++Builder y Lazarus, un índice de campo de formulario no es un índice de anotación. Una página lleva anotaciones Link, Text e Ink junto a sus widgets, así que la enumeración de campos debe filtrar por FPDFAnnot_GetSubtype y exponer un índice lógico indexado desde cero, mapeado de vuelta a una posición de anotación real solo en la llamada nativa
El bug que expone esto es inconfundible una vez que lo has visto. Un tester pulsa Tab en un formulario de factura rellenado y el cursor desaparece, porque el foco fue a un hipervínculo en el pie de página. O peor, no pasa nada en absoluto: tu código registra el campo 3 como enfocado, el panel de la UI se actualiza, y FORM_SetFocusedAnnot devolvió silenciosamente false todo el tiempo. Ambos síntomas provienen del mismo error de diseño, y uno de ellos tiene una segunda causa raíz escondida debajo
Los dos espacios de índices que PDFium te entrega
PDFium expone dos esquemas de numeración sobre la misma página, y solo coinciden en documentos que resulta que no contienen nada más que widgets de formulario. El primero es el índice de anotación: una posición en el array /Annots de la página, que es lo que cuenta FPDFPage_GetAnnotCount y lo que recibe FPDFPage_GetAnnot (ISO 32000-1 §12.5.2). El segundo es el índice de campo lógico que una API a nivel de aplicación debería ofrecer, que corre desde cero sobre los campos interactivos a los que un usuario realmente puede acceder. ISO 32000-1 §12.5.6.19 define las anotaciones widget como la representación visual de los campos de formulario interactivos, y §12.7 define el propio formulario. Todo lo demás en la página es un subtipo distinto con semántica distinta: una anotación Link tiene un destino, una anotación Ink tiene una lista de trazos, una anotación Text es una nota adhesiva. Ninguna de ellas pertenece a un recuento de campos, y ninguna puede aceptar foco de formulario. Sin embargo, en el array /Annots están intercaladas con los widgets en el orden que sea que la aplicación productora las escribiera, que con frecuencia no es el orden que sugiere ninguna otra cosa del documento
¿Por qué Tab aterriza en un hipervínculo en lugar del siguiente campo?
Porque el recuento de campos en realidad era un recuento de anotaciones. La implementación original devolvía FPDFPage_GetAnnotCount directamente desde FormFieldCount, mientras que el accesor de información de campo, el helper de orden de tabulación, y el helper de foco trataban todos ese mismo entero como una posición de widget. En una página AcroForm limpia con seis widgets y nada más, seis es igual a seis y cada test pasa. Añade un hipervínculo en el pie de página y un comentario de revisor en el margen, y el recuento reporta ocho campos, los índices 6 y 7 resuelven a objetos que no son de formulario, y Tab camina directamente hacia ellos
La corrección en el extremo de la enumeración es contar subtipos en lugar de anotaciones. Abre cada anotación, pregunta por su subtipo, conserva los widgets, y cierra el handle en un bloque finally, porque FPDFPage_GetAnnot devuelve un handle poseído que debe devolverse a través de FPDFPage_CloseAnnot
function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
Count, I: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := 0;
if Page = nil then
Exit;
Count := FPDFPage_GetAnnotCount(Page); // every annotation, not just fields
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
Inc(Result);
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
Fíjate en lo que esto deliberadamente no hace. No le pregunta nada al entorno de relleno de formulario, y no necesita un handle de formulario, porque el subtipo vive en el diccionario de anotación y es legible desde la página por sí sola. Eso importa para el orden: el recuento está disponible antes de que hayas decidido siquiera si el documento merece un entorno de relleno de formulario, lo cual el artículo sobre JavaScript de AcroForm y eventos del host trata como una decisión de seguridad más que de conveniencia
Mapear el índice lógico de vuelta en el límite nativo
La regla que evita que los dos espacios se filtren entre sí es simple: el índice lógico es el único número que cruza tu API pública, y se convierte a un índice de anotación en la última función antes de la llamada nativa. Un único helper de mapeo, usado tanto por la información de campo como por el foco, los setters de flags, y el orden de tabulación, es lo que hace esa regla exigible
function AnnotationIndexForField(Page: FPDF_PAGE;
FieldIndex: Integer): Integer;
var
Count, I, Current: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := -1;
if (Page = nil) or (FieldIndex < 0) then
Exit;
Count := FPDFPage_GetAnnotCount(Page);
Current := 0;
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
begin
if Current = FieldIndex then
Exit(I); // real /Annots position: native calls only
Inc(Current);
end;
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
Merece la pena exponer con claridad dos propiedades de este helper. Es un recorrido lineal, así que un bucle ingenuo sobre cada campo cuesta un número cuadrático de aperturas de anotación en una página con cientos de widgets; si estás enumerando la página completa, recorre las anotaciones una sola vez y recolecta los handles de widget sobre la marcha en lugar de llamar al mapeador por campo. Y devuelve -1 en lugar de lanzar una excepción, lo que deja que el llamante decida si un índice obsoleto es un error de programación que merece una excepción o una carrera que merece ignorarse, por ejemplo tras una edición que eliminó una anotación a la que una lista de UI en caché todavía hace referencia
¿Por qué falla FORM_SetFocusedAnnot en una página headless?
Porque PDFium se niega a enfocar un widget cuya vista de página nunca se marcó como válida. FORM_SetFocusedAnnot resuelve la anotación hacia una vista de página dentro del entorno de relleno de formulario, y si esa vista de página no existe devuelve false sin ningún diagnóstico. Corregir solo el mapeo de índices por tanto arregla que Tab aterrice en un hipervínculo pero deja intacto el segundo síntoma: tu registro de foco lógico dice campo 3, el widget nativo enfocado sigue siendo ninguno, y cada accesor construido sobre el foco nativo, texto enfocado, valor enfocado, estado de selección de opción, sigue devolviendo vacío. La vista de página la crea FORM_OnAfterLoadPage y la destruye FORM_OnBeforeClosePage. En un visor construido en torno a un control visual esas llamadas ocurren como parte de mostrar una página, razón por la cual el fallo tan a menudo parece un bug exclusivo de headless: el mismo código que funciona en la demo gráfica falla en la herramienta por lotes. El ciclo de vida pertenece al objeto documento, no al visor, así que PDFium Component ahora emite ambas llamadas siempre que una página se carga o descarga con un handle de formulario presente. La firma en C recibe primero la página y segundo el handle de formulario, que es fácil de invertir al escribir el binding a mano
procedure ReportFirstField(const FileName: string);
var
Pdf: TPdf;
Idx: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FormFill := True; // form-fill environment, before Active
Pdf.FileName := FileName;
Pdf.Active := True;
Pdf.PageNumber := 1; // page load also runs FORM_OnAfterLoadPage
Idx := Pdf.FocusNextFormField; // logical index, 0-based over widgets
if Idx < 0 then
Exit; // page holds no widget annotations
Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
string(Pdf.FocusedFormFieldValue)); // reads the native focused widget
finally
Pdf.Free; // page unload runs FORM_OnBeforeClosePage
end;
end;
La comprobación que demuestra la corrección es la que compara ambos lados. Llama a FocusFormField con un índice lógico, y después lee un valor a través de un accesor que pasa por el widget nativo enfocado en lugar de por tu propio registro, como FocusedFormFieldValue u FocusedFormOptionSelected. Si el índice lógico hace el viaje de ida y vuelta pero el accesor nativo vuelve vacío, lo que falta es la vista de página, no el mapeo
Lo que el índice de campo lógico no promete
Un índice de campo indexado desde cero es una conveniencia, no una identidad semántica, y de eso se derivan cuatro límites. Es por página, no por documento, así que el índice 0 en la página 2 es un widget distinto del índice 0 en la página 1 y compararlos carece de sentido. Es posicional, así que insertar o eliminar una anotación invalida cada índice en caché por encima del cambio; trata un índice almacenado como válido solo mientras la página permanezca cargada y sin editar
El tercer límite es el que sorprende a quienes revisan una lista de campos. El índice enumera widgets, no campos. Un grupo de botones de radio es un único campo con varios widgets hijos, así que un grupo de tres botones aporta tres índices consecutivos que todos reportan el mismo Name. El registro TPdfFormFieldInfo lleva GroupCount y GroupIndex precisamente para este caso, y una UI de lista que los ignora muestra el mismo campo tres veces. El cuarto límite concierne al orden de recorrido: el orden de tabulación que se expone aquí es el orden de enumeración de widgets, que sigue el array /Annots, no la entrada /Tabs de la página (ISO 32000-1 §7.7.3.3) ni el árbol de campos de AcroForm. Para la mayoría de los productores esto coincide; para un formulario maquetado en dos columnas por un generador que emitió primero la columna derecha, no coincide, y la ruta de teclado descrita en el artículo de navegación de campos de formulario se sentirá incorrecta aunque cada índice sea correcto. Cuando un fichero de un cliente se comporta de forma extraña, vuelca ambos espacios de índices lado a lado antes de teorizar: la vista de anotaciones y la vista de campos de la misma página, impresas juntas, normalmente hacen obvia la causa de un vistazo
procedure DumpIndexSpaces(Pdf: TPdf);
var
I: Integer;
Info: TPdfFormFieldInfo;
begin
for I := 0 to Pdf.AnnotationCount - 1 do
Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));
for I := 0 to Pdf.FormFieldCount - 1 do
begin
Info := Pdf.FormFieldInfo[I];
Writeln('field ', I, ': ', string(Info.Name),
' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
end;
end;
Un recuento de anotaciones muy por encima del recuento de campos significa que la página mezcla subtipos, lo cual es normal en documentos revisados y es exactamente la situación para la que existe el mapeo; el artículo sobre el flujo de trabajo de revisión de anotaciones mira la misma página desde el lado de las marcas. Recuentos iguales en cada fichero de prueba, por otro lado, significan que tus fixtures no pueden detectar en absoluto esta clase de bug, y la respuesta honesta es añadir un fixture de formulario que lleve un enlace y una nota adhesiva
Las APIs de enumeración de campos, foco y anotaciones descritas aquí se incluyen con PDFium Component para Delphi, C++Builder y Lazarus, cuya página de producto lleva la referencia completa de campos de formulario incluyendo el registro de información de campo y los accesores de foco