Checkboxes e radio buttons são achatados 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, agora lê esse valor com FPDFAnnot_GetFormFieldValue, que resolve o dicionário do campo pai em vez da anotação widget
O relatório de bug que levou a isso é do tipo que você desconfia à primeira vista. Um cliente achata um formulário de consentimento assinado, abre o resultado, e todos os checkboxes estão vazios. Abra o arquivo de origem no Acrobat e as caixas estão visivelmente marcadas. Leia o arquivo de origem de volta pelo mesmo componente e os valores dos campos estão corretos. Apenas a saída achatada os perde, e apenas para checkboxes e radio buttons: os campos de texto na mesma página funcionam bem
Por que os checkboxes ficam desmarcados após o achatamento?
Porque o achatamento nunca olha para /V. O FPDFPage_Flatten grava o stream de aparência do widget no conteúdo da página, e a aparência escolhida é a nomeada por /AS. Se /AS ainda diz /Off enquanto o valor do campo diz que a caixa está marcada, o achatamento grava fielmente a aparência desmarcada. O valor nunca foi perdido; ele 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 um checkbox ou radio button, a entrada /N não é um stream, mas um subdicionário cujas chaves são nomes de estados de aparência, e a §12.5.2 torna /AS o seletor obrigatório quando /N é um subdicionário. Assim, um checkbox carrega duas aparências pré-construídas e um ponteiro. Erre o ponteiro e a renderização fica errada de um jeito que nenhuma quantidade de /V correto vai consertar. É também por isso que o modo de falha difere dos campos de texto, que não têm aparência pré-construída alguma para selecionar: o /N de um campo de texto é um único stream que precisa ser regenerado do zero após a mudança do valor, então o GenerateFormAppearances trata os dois casos por caminhos de código completamente separados, e apenas o caminho dos botões estava quebrado
Onde o valor do checkbox realmente fica armazenado?
No dicionário do campo, não no widget. A ISO 32000-1 §12.7.5.2 descreve checkboxes e radio buttons como campos de botão cujo /V é um objeto de nome que designa o estado de aparência atual, e a §12.7.3.1 coloca /V entre as entradas comuns a todos os dicionários de campo. A anotação widget definida na §12.5.6.19 contribui com /AS e /AP. Nada na especificação obriga um widget a carregar /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 }
O FPDFAnnot_GetStringValue não é defeituoso. Seu contrato é exatamente o que o nome diz: buscar uma entrada de string no dicionário de anotação que você passou a ele. Pedir a ele o /V no objeto 13 não retorna nada porque o objeto 13 realmente não tem /V. O defeito estava no chamador, que assumiu um modelo de objeto plano que a ISO 32000-1 nunca prometeu
Quando o campo e o widget compartilham um único dicionário?
Sempre que um campo tem exatamente um widget. A §12.5.6.19 permite que o dicionário do campo e sua única anotação widget sejam mesclados em um único objeto, e a maioria das ferramentas de autoria usa esse atalho. Em um objeto mesclado, /FT, /T, /V, /AS e /AP ficam todos lado a lado, então uma leitura de /V no nível do widget funciona e todo o bug permanece invisível
No momento em que um campo possui dois ou mais widgets, a mesclagem se torna impossível, e a §12.7.3.1 exige que os widgets se tornem /Kids de um dicionário de campo separado. Todo grupo de radio buttons tem essa forma por construção. O mesmo vale para checkboxes de consentimento repetidos em um cabeçalho e um rodapé, e qualquer campo que uma ferramenta de autoria tenha copiado para uma segunda página. Essa é toda a explicação de por que o defeito sobreviveu a uma suíte de regressão: o corpus de teste estava cheio de formulários com um único widget, e os arquivos dos clientes não. Se você mesmo percorrer os widgets em vez de confiar no componente, a mesma assimetria aparece na ordem de enumeração, e as notas em navegação de campos de formulário PDF com o PDFium Component abordam como uma varredura de anotações no nível da página se relaciona com a árvore de campos no nível do documento
Lendo o valor da forma como o PDFium pretende
O FPDFAnnot_GetFormFieldValue é a API correta, e ela já estava vinculada no componente havia algum tempo sem que o caminho dos checkboxes a utilizasse. Ela recebe tanto o handle do formulário quanto a anotação, e esse é o sinal que importa: com o ambiente de preenchimento de formulário disponível, o PDFium resolve a anotação para seu controle de formulário e lê o valor a partir do objeto de campo, retornando a resposta certa tanto para layouts mesclados quanto para layouts divididos
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 detalhes nesse trecho são fáceis de errar. O comprimento retornado é uma contagem de bytes para texto UTF-16 incluindo o terminador, então a contagem de caracteres é buflen div 2 - 1, e um valor de 2 significa uma string vazia. A guarda buflen >= 4, portanto, significa pelo menos um caractere real, o que é o que impede que um campo sem nenhum /V tenha seu /AS sobrescrito com um nome vazio
No que /AS e /AP /N realmente concordam
Eles concordam em um nome, e o nome é escolhido por quem produziu o arquivo. A §12.7.5.2 exige que o estado desligado se chame /Off, e deixa o estado ligado inteiramente a 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 radio buttons normalmente dá a cada kid um nome de estado ligado distinto, para que o grupo possa expressar qual botão está selecionado. É exatamente por isso que copiar /V literalmente para /AS é a operação correta, e não uma gambiarra: para um controle marcado, o PDFium relata o nome do estado ligado que o próprio arquivo define, e para um desmarcado relata Off, então o valor que você escreve em /AS tem a garantia de ser uma chave que existe naquele subdicionário /AP /N do widget. Fixar /Yes no código funcionaria com a saída do Acrobat e quebraria silenciosamente em todo o resto
Ordem das operações, e onde ainda é preciso cuidado
A sequência é fixa e implacável: ativar o preenchimento de formulário, atribuir valores, regenerar aparências, achatar, depois salvar. Pule a etapa de regeneração e o FPDFPage_Flatten encontra streams de aparência vazios ou desatualizados e os grava sem reclamar, o que é uma perda silenciosa de dados em vez de um retorno de erro
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');
Duas limitações honestas permanecem. Primeiro, a sincronização escreve o valor do campo no /AS de todos os widgets daquele campo, o que é correto para checkboxes mas apenas aproximado para grupos de radio buttons cujos kids definem cada um seu próprio nome de estado ligado; um kid cujo /AP /N não tem entrada correspondente ao /AS escrito não tem aparência para selecionar segundo a §12.5.5, então um botão não selecionado pode achatar para nada em vez de um círculo vazio. Auditar um grupo de radio buttons com FPDFAnnot_GetFormControlIndex antes de achatar vale as poucas linhas. Segundo, nada disso se aplica a XFA, onde o valor fica em um pacote de dados XML em vez dos dicionários AcroForm, uma separação abordada nas notas sobre edições de campo XFA que não são persistidas. A lição geral vale a pena manter além deste conserto específico: sempre que uma API recebe o handle do formulário além da anotação, ela está dizendo que vai resolver a hierarquia de campos para você, e sempre que recebe apenas a anotação, ela vai ler exatamente o objeto que você passou. Essa distinção também rege a troca de dados, já que exportar e importar dados de formulário XFDF funciona com nomes de campo totalmente qualificados, nunca com posições de widget
O achatamento de formulário é um daqueles recursos que parece uma única chamada de API e acaba sendo um contrato entre três dicionários. Se você preferir trabalhar com um componente que já encapsula esse contrato, o PDFium Component para Delphi e C++Builder traz a regeneração de aparência, o achatamento e o acesso a campos de formulário descritos aqui como propriedades e métodos comuns