Artículo técnico

Índice de widget frente a índice de anotación en PDFium

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