Artigo Técnico

Bug de Achatamento de Checkbox em PDF: Valor vs Widget

As checkboxes e os botões de opção (radio buttons) achatam como desmarcados porque o estado de aparência /AS nunca foi sincronizado com o valor do campo /V. O PDFium Component, o componente VCL e LCL baseado em PDFium para Delphi, C++Builder e Lazarus, lê agora esse valor com FPDFAnnot_GetFormFieldValue, que resolve o dicionário do campo pai em vez do widget de anotação

O relatório de bug que trouxe até aqui é do tipo que se desconfia à primeira vista. Um cliente achata um formulário de consentimento assinado, abre o resultado, e todas as checkboxes aparecem vazias. Abrir o ficheiro de origem no Acrobat e as caixas estão visivelmente marcadas. Ler o ficheiro de origem de volta através do mesmo componente e os valores dos campos estão corretos. Só a saída achatada perde os valores, e só para checkboxes e botões de opção: os campos de texto na mesma página saem corretamente

Porque é que as checkboxes ficam desmarcadas depois de achatar?

Porque achatar nunca olha para /V. FPDFPage_Flatten grava o fluxo de aparência do widget no conteúdo da página, e a aparência escolhida é a designada por /AS. Se /AS continuar a indicar /Off enquanto o valor do campo diz que a caixa está ligada, o achatamento grava fielmente a aparência de desligado. O valor nunca se perdeu; simplesmente nunca foi consultado

A ISO 32000-1 §12.5.5 define o dicionário de aparência /AP com três entradas possíveis, /N, /R e /D. Para uma checkbox ou botão de opção, a entrada /N não é um fluxo mas sim um subdicionário cujas chaves são nomes de estados de aparência, e o §12.5.2 torna /AS o seletor obrigatório quando /N é um subdicionário. Assim, uma checkbox transporta duas aparências pré-construídas e um ponteiro. Errando o ponteiro, a renderização fica errada de uma forma que nenhum /V correto consegue reparar. É também por isso que o modo de falha difere dos campos de texto, que não têm qualquer aparência pré-construída para selecionar: um /N de campo de texto é um único fluxo que tem de ser regenerado de raiz depois de o valor mudar, pelo que GenerateFormAppearances trata os dois casos através de caminhos de código completamente distintos, e só o caminho dos botões estava avariado

Onde vive realmente o valor da checkbox?

No dicionário do campo, não no widget. A ISO 32000-1 §12.7.5.2 descreve as checkboxes e os botões de opção como campos de botão cujo /V é um objeto de nome que designa o estado de aparência atual, e o §12.7.3.1 coloca /V entre as entradas comuns a todos os dicionários de campo. O widget de anotação definido no §12.5.6.19 contribui com /AS e /AP. Nada na especificação obriga um widget a transportar /V

// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off

{ What the two objects look like when the field has several widgets:

  12 0 obj                          % field dictionary (the parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (a kid)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

FPDFAnnot_GetStringValue não é defeituosa. O seu contrato é exatamente o que o nome diz: obter uma entrada de string do dicionário de anotação que lhe é passado. Pedir-lhe /V no objeto 13 devolve nada porque o objeto 13 genuinamente não tem /V. O defeito estava na chamada, que assumia um modelo de objeto plano que a ISO 32000-1 nunca prometeu

Quando é que campo e widget partilham um único dicionário?

Sempre que um campo tem exatamente um widget. O §12.5.6.19 permite que o dicionário de campo e a sua única anotação de widget sejam fundidos num único objeto, e a maioria das ferramentas de autoria aproveita esse atalho. Num objeto fundido, /FT, /T, /V, /AS e /AP encontram-se todos lado a lado, pelo que uma leitura de /V ao nível do widget é bem-sucedida, e o bug inteiro fica invisível

A partir do momento em que um campo passa a ter dois ou mais widgets, a fusão torna-se impossível, e o §12.7.3.1 exige que os widgets se tornem /Kids de um dicionário de campo separado. Todo o grupo de opções (radio group) tem esta forma por construção. O mesmo se passa com checkboxes de consentimento repetidas num cabeçalho e num rodapé, e com qualquer campo que uma ferramenta de autoria tenha copiado para uma segunda página. Esta é toda a explicação de por que motivo o defeito sobreviveu a um conjunto de testes de regressão: o corpus de testes estava cheio de formulários de widget único, e os ficheiros dos clientes não. Se percorrer os widgets manualmente em vez de confiar no componente, a mesma assimetria surge na ordem de enumeração, e as notas sobre navegação de campos de formulário PDF com PDFium Component explicam como uma varredura de anotações ao nível da página se relaciona com a árvore de campos ao nível do documento

Ler o valor da forma que o PDFium pretende

FPDFAnnot_GetFormFieldValue é a API correta, e já estava vinculada no componente há algum tempo sem que o caminho das checkboxes a usasse. Recebe o handle do formulário além da anotação, e é esse o sinal que importa: com o ambiente de preenchimento de formulário disponível, o PDFium resolve a anotação até ao seu controlo de formulário e lê o valor a partir do objeto de campo, pelo que devolve a resposta certa tanto para disposições fundidas como separadas

FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP is prebuilt per state; only /AS has to be synchronised with /V.
    // FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
    // which is where ISO 32000-1 12.7.5.2 keeps the value.
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

Dois pormenores neste excerto são fáceis de errar. O comprimento devolvido é uma contagem de bytes para texto UTF-16 incluindo o terminador, pelo que a contagem de caracteres é buflen div 2 - 1, e um valor de 2 significa uma string vazia. A guarda buflen >= 4 significa, por isso, pelo menos um carácter real, o que impede que um campo sem qualquer /V veja o seu /AS sobrescrito com um nome vazio

O que /AS e /AP /N realmente acordam entre si

Acordam um nome, e o nome é escolhido por quem produziu o ficheiro. O §12.7.5.2 exige que o estado de desligado se chame /Off, e deixa o estado de ligado inteiramente ao critério do produtor. /Yes é uma convenção, não uma regra. O Acrobat escreve /Yes, mas muitos geradores escrevem /On, /1, /Choice1, ou uma palavra localizada, e um grupo de opções normalmente atribui a cada elemento filho um nome de estado ligado distinto, para que o grupo possa exprimir qual botão está selecionado. É precisamente por isto que copiar /V literalmente para /AS é a operação correta e não um truque: para um controlo marcado, o PDFium comunica o nome de estado ligado que o próprio ficheiro define, e para um controlo desmarcado comunica Off, pelo que o valor escrito em /AS tem garantidamente uma chave existente no subdicionário /AP /N desse widget. Fixar /Yes diretamente no código funcionaria com resultados do Acrobat mas quebraria silenciosamente em qualquer outro lugar

Ordem das operações, e onde ainda é preciso cuidado

A sequência é fixa e não perdoa desvios: ativar o preenchimento de formulário, atribuir valores, regenerar aparências, achatar, depois gravar. Saltar o passo de regeneração faz com que FPDFPage_Flatten encontre fluxos de aparência vazios ou desatualizados e os grave sem qualquer aviso, o que é uma perda silenciosa de dados e não um erro devolvido

Pdf.FileName := FormPath;
Pdf.FormFill := True;          // required: FormHandle must exist
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // writes /V only

Pdf.GenerateFormAppearances;   // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

Restam dois limites honestos. Primeiro, a sincronização escreve o valor do campo no /AS de cada widget desse campo, o que é correto para checkboxes mas aproximado para grupos de opções cujos elementos filho definem cada um o seu próprio nome de estado ligado; um elemento filho cujo /AP /N não tenha uma entrada correspondente ao /AS escrito fica sem aparência para selecionar segundo o §12.5.5, pelo que um botão não selecionado pode achatar para nada em vez de um círculo vazio. Auditar um grupo de opções com FPDFAnnot_GetFormControlIndex antes de achatar vale as poucas linhas que custa. Segundo, nada disto se aplica a XFA, onde o valor vive num pacote de dados XML em vez dos dicionários AcroForm, uma separação coberta nas notas sobre edições de campo XFA que não são persistidas. A lição geral vale a pena reter para além desta correção específica: sempre que uma API recebe o handle do formulário além da anotação, está a indicar que vai resolver a hierarquia de campos por si; e sempre que recebe apenas a anotação, vai ler exatamente o objeto que lhe foi passado. Essa distinção rege também a troca de dados, já que exportar e importar dados de formulário XFDF funciona sobre nomes de campo totalmente qualificados, nunca sobre posições de widget

Achatamento de formulários é uma daquelas funcionalidades que parece uma única chamada de API e acaba por ser um contrato entre três dicionários. Se preferir trabalhar com um componente que já codifica esse contrato, o PDFium Component para Delphi e C++Builder disponibiliza a regeneração de aparências, o achatamento e o acesso a campos de formulário aqui descritos como propriedades e métodos comuns