Artigo Técnico

Índice de Widget vs Índice de Anotação em PDFium Delphi

No PDFium Component, o componente VCL/LCL baseado em PDFium para Delphi, C++Builder e Lazarus, um índice de campo de formulário não é um índice de anotação. Uma página carrega anotações Link, Text e Ink ao lado de seus widgets, então a enumeração de campos precisa filtrar por FPDFAnnot_GetSubtype e expor um índice lógico baseado em zero, mapeado de volta para uma posição real de anotação apenas na chamada nativa

O bug que expõe isso é inconfundível depois que você já o viu. Um testador aperta Tab em um formulário de fatura preenchido e o cursor some, porque o foco foi para um hyperlink no rodapé. Ou pior, nada acontece: seu código registra o campo 3 como focado, o painel de UI atualiza, e FORM_SetFocusedAnnot silenciosamente retornou false o tempo todo. Ambos os sintomas vêm do mesmo erro de design, e um deles tem uma segunda causa raiz escondida embaixo

Os dois espaços de índice que o PDFium te entrega

O PDFium expõe dois esquemas de numeração sobre a mesma página, e eles só coincidem em documentos que por acaso não contêm nada além de widgets de formulário. O primeiro é o índice de anotação: uma posição no array /Annots da página, que é o que FPDFPage_GetAnnotCount conta e o que FPDFPage_GetAnnot recebe (ISO 32000-1 §12.5.2). O segundo é o índice lógico de campo que uma API no nível da aplicação deveria oferecer, partindo de zero sobre os campos interativos que um usuário realmente pode alcançar. A ISO 32000-1 §12.5.6.19 define anotações widget como a representação visual de campos de formulário interativos, e a §12.7 define o formulário em si. Tudo o mais na página é um subtipo diferente com semântica diferente: uma anotação Link tem um destino, uma anotação Ink tem uma lista de traços, uma anotação Text é uma nota adesiva. Nenhuma delas pertence a uma contagem de campos, e nenhuma pode aceitar foco de formulário. Ainda assim, no array /Annots elas ficam intercaladas com os widgets na ordem em que a aplicação produtora as escreveu, o que frequentemente não é a ordem que qualquer outra coisa no documento sugere

Por que o Tab cai em um hyperlink em vez do próximo campo?

Porque a contagem de campos era, na verdade, uma contagem de anotações. A implementação original retornava FPDFPage_GetAnnotCount diretamente de FormFieldCount, enquanto o acessor de informação de campo, o helper de ordem de tabulação e o helper de foco tratavam todos esse mesmo inteiro como uma posição de widget. Em uma página AcroForm limpa com seis widgets e nada mais, seis é igual a seis e todo teste passa. Adicione um hyperlink no rodapé e um comentário de revisor na margem, e a contagem relata oito campos, os índices 6 e 7 resolvem para objetos que não são de formulário, e o Tab cai direto neles

A correção do lado da enumeração é contar subtipos em vez de anotações. Abra cada anotação, pergunte seu subtipo, mantenha os widgets, e feche o handle em um bloco finally, porque FPDFPage_GetAnnot retorna um handle possuído que precisa voltar atravé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;

Observe o que isso deliberadamente não faz. Não pergunta nada ao ambiente de preenchimento de formulário, e não precisa de um handle de formulário, porque o subtipo mora no dicionário de anotação e é legível a partir da página sozinha. Isso importa para a ordenação: a contagem está disponível antes mesmo de você ter decidido se o documento merece um ambiente de preenchimento de formulário, o que o artigo sobre JavaScript AcroForm e eventos de host aborda como uma decisão de segurança, não de conveniência

Mapeando o índice lógico de volta na fronteira nativa

A regra que impede os dois espaços de vazarem um para o outro é simples: o índice lógico é o único número que atravessa sua API pública, e ele é convertido em um índice de anotação na última função antes da chamada nativa. Um único helper de mapeamento, usado igualmente por informação de campo, foco, setters de flag e ordem de tabulação, é o que torna essa regra aplicável

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;

Duas propriedades desse helper valem a pena declarar com clareza. É uma varredura linear, então um loop ingênuo sobre cada campo custa um número quadrático de aberturas de anotação em uma página com centenas de widgets; se você está enumerando a página inteira, percorra as anotações uma vez e colete os handles de widget conforme avança, em vez de chamar o mapeador por campo. E ele retorna -1 em vez de lançar exceção, o que deixa o chamador decidir se um índice obsoleto é um erro de programação que mereça uma exceção ou uma corrida que valha a pena ignorar, por exemplo depois que uma edição removeu uma anotação que uma lista de UI em cache ainda referencia

Por que o FORM_SetFocusedAnnot falha em uma página headless?

Porque o PDFium se recusa a focar um widget cuja page view nunca foi marcada como válida. FORM_SetFocusedAnnot resolve a anotação para uma page view dentro do ambiente de preenchimento de formulário, e se essa page view não existe, ele retorna false sem nenhum diagnóstico. Corrigir apenas o mapeamento de índice, portanto, resolve o Tab caindo em um hyperlink, mas deixa o segundo sintoma intocado: seu registro lógico de foco diz campo 3, o widget focado nativo continua sendo nada, e todo acessor construído sobre o foco nativo, texto focado, valor focado, estado de seleção de escolha, continua retornando vazio. A page view é criada por FORM_OnAfterLoadPage e destruída por FORM_OnBeforeClosePage. Em um visualizador construído em torno de um controle visual, essas chamadas acontecem como parte da exibição de uma página, o que é por isso que a falha tantas vezes parece um bug exclusivo de modo headless: o mesmo código que funciona na demo com GUI falha na ferramenta em lote. O ciclo de vida pertence ao objeto documento, não ao visualizador, então o PDFium Component agora emite ambas as chamadas sempre que uma página é carregada ou descarregada com um handle de formulário presente. A assinatura em C recebe a página primeiro e o handle de formulário depois, o que é fácil de inverter ao escrever o binding manualmente

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;

A verificação que comprova a correção é aquela que compara os dois lados. Chame FocusFormField com um índice lógico, depois leia um valor através de um acessor que passa pelo widget focado nativo em vez de pelo seu próprio registro, como FocusedFormFieldValue ou FocusedFormOptionSelected. Se o índice lógico ida-e-volta funciona mas o acessor nativo volta vazio, é a page view que está faltando, não o mapeamento

O que o índice lógico de campo não promete

Um índice de campo baseado em zero é uma conveniência, não uma identidade semântica, e quatro limitações decorrem disso. Ele é por página, não por documento, então o índice 0 na página 2 é um widget diferente do índice 0 na página 1, e compará-los não tem sentido. Ele é posicional, então inserir ou excluir uma anotação invalida todo índice em cache acima da mudança; trate um índice armazenado como válido apenas enquanto a página permanecer carregada e não editada

A terceira limitação é a que surpreende quem revisa uma lista de campos. O índice enumera widgets, não campos. Um grupo de radio buttons é um único campo com vários widgets filhos, então um grupo de três botões contribui com três índices consecutivos que relatam todos o mesmo Name. O registro TPdfFormFieldInfo carrega GroupCount e GroupIndex exatamente para esse caso, e uma UI de lista que os ignora mostra o mesmo campo três vezes. A quarta limitação diz respeito à ordem de percurso: a ordem de tabulação exposta aqui é a ordem de enumeração de widgets, que segue o array /Annots, não a entrada /Tabs da página (ISO 32000-1 §7.7.3.3) nem a árvore de campos do AcroForm. Para a maioria dos produtores essas coincidem; para um formulário organizado em duas colunas por um gerador que emitiu primeiro a coluna da direita, não coincidem, e o caminho de teclado descrito no artigo de navegação de campos de formulário vai parecer errado mesmo que cada índice esteja correto. Quando um arquivo de cliente se comporta de forma estranha, despeje os dois espaços de índice lado a lado antes de teorizar: a visão de anotação e a visão de campo da mesma página, impressas juntas, geralmente tornam a causa óbvia em uma olhada

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;

Uma contagem de anotações bem acima da contagem de campos significa que a página mistura subtipos, o que é normal em documentos revisados e é exatamente a situação para a qual o mapeamento existe; o artigo sobre fluxo de revisão de anotações olha para a mesma página pelo lado das marcações. Contagens iguais em todo arquivo de teste, por outro lado, significam que seus fixtures não conseguem detectar essa classe de bug de jeito nenhum, e a resposta honesta é adicionar um fixture de formulário que carregue um link e uma nota adesiva

As APIs de enumeração de campos, foco e anotação descritas aqui vêm com o PDFium Component para Delphi, C++Builder e Lazarus, cuja página de produto traz a referência completa de campos de formulário, incluindo o registro de informação de campo e os acessores de foco