Artículo técnico

Índice de widget vs índice de anotación en PDFium Delphi

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 con base cero, mapeado de vuelta a una posición de anotación real solo en la llamada nativa

El error que expone esto es inconfundible una vez que lo has visto. Un probador presiona Tab en un formulario de factura ya 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 interfaz 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 oculta debajo

Los dos espacios de índice que PDFium te entrega

PDFium expone dos esquemas de numeración sobre la misma página, y solo coinciden en documentos que dan la casualidad de no contener nada más que widgets de formulario. El primero es el índice de anotación: una posición en el arreglo /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 lógico de campo que una API a nivel de aplicación debería ofrecer, corriendo desde cero sobre los campos interactivos que un usuario realmente puede alcanzar. ISO 32000-1 §12.5.6.19 define los widgets de anotación como la representación visual de campos de formulario interactivos, y §12.7 define al 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 conteo de campos, y ninguna puede aceptar el foco de formulario. Sin embargo, en el arreglo /Annots se encuentran intercaladas con los widgets en cualquier orden en que la aplicación productora las haya escrito, que frecuentemente no es el orden que sugiere cualquier otra cosa sobre el documento

Por qué Tab cae en un hipervínculo en lugar del siguiente campo

Porque el conteo de campos en realidad era un conteo de anotaciones. La implementación original devolvía FPDFPage_GetAnnotCount directamente desde FormFieldCount, mientras que el accesor de información de campo, el asistente de orden de tabulación, y el asistente de foco todos trataban a 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 prueba pasa. Agrega un hipervínculo en el pie de página y un comentario de revisor en el margen, y el conteo reporta ocho campos, los índices 6 y 7 resuelven hacia objetos que no son de formulario, y Tab camina directo hacia ellos

La corrección del lado 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 propio que debe volver 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;

Nota 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 sola. Eso importa para el orden: el conteo está disponible antes de que hayas decidido si el documento siquiera merece un entorno de relleno de formulario, que el artículo sobre JavaScript de AcroForm y eventos del host cubre como una decisión de seguridad en lugar de una 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 en un índice de anotación en la última función antes de la llamada nativa. Un solo asistente de mapeo, usado tanto por la información de campo, el foco, los establecedores de indicadores, y el orden de tabulación, es lo que hace exigible esa regla

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;

Dos propiedades de este asistente vale la pena declararlas con claridad. Es un recorrido lineal, así que un bucle ingenuo sobre cada campo cuesta una cantidad cuadrática de aperturas de anotación en una página con cientos de widgets; si estás enumerando toda la página, recorre las anotaciones una vez y recolecta los handles de widget sobre la marcha en lugar de llamar al mapeador por cada campo. Y devuelve -1 en lugar de lanzar una excepción, lo que le permite a quien invoca decidir si un índice obsoleto es un error de programación que merece una excepción o una carrera que vale la pena ignorar, por ejemplo después de que una edición eliminó una anotación a la que una lista de interfaz 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 lo tanto, arregla que Tab caiga en un hipervínculo pero deja el segundo síntoma intacto: tu registro de foco lógico dice campo 3, el widget nativo enfocado sigue siendo nada, 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 alrededor de un control visual esas llamadas suceden como parte de mostrar una página, que es por qué el fallo tan a menudo se ve como un error exclusivo de modo headless: el mismo código que funciona en la demo con GUI 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 se carga o descarga una página 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 enlace 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 los dos lados. Llama a FocusFormField con un índice lógico, luego 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 o FocusedFormOptionSelected. Si el índice lógico va y vuelve correctamente pero el accesor nativo devuelve vacío, lo que falta es la vista de página, no el mapeo

Lo que no promete el índice lógico de campo

Un índice de campo con base cero es una conveniencia, no una identidad semántica, y de ahí 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 no tiene 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 radio es un 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 exactamente para este caso, y una interfaz 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 expuesto aquí es el orden de enumeración de widgets, que sigue el arreglo /Annots, no la entrada /Tabs de la página (ISO 32000-1 §7.7.3.3) y no el árbol de campos de AcroForm. Para la mayoría de los productores esos coinciden; para un formulario dispuesto en dos columnas por un generador que emitió primero la columna derecha, no coinciden, y la ruta de teclado descrita en el artículo de navegación de campos de formulario se sentirá equivocada aunque cada índice sea correcto. Cuando un archivo de cliente se comporta de forma extraña, vuelca ambos espacios de índice lado a lado antes de teorizar: la vista de anotación y la vista de campo 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 conteo de anotaciones muy por encima del conteo 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 revisión de anotaciones mira la misma página desde el lado del marcado. Conteos iguales en cada archivo de prueba, por otro lado, significan que tus fixtures no pueden detectar en absoluto esta clase de error, y la respuesta honesta es agregar un fixture de formulario que lleve un enlace y una nota adhesiva

La enumeración de campos, el foco, y las API de anotación descritas aquí se incluyen con PDFium Component para Delphi, C++Builder, y Lazarus, cuya página de producto incluye la referencia completa de campos de formulario, incluido el registro de información de campo y los accesores de foco