Artigo Técnico

FPDF_FORMFILLINFO versão 2 no Delphi: siga a ABI da DLL

Uma fatura AcroForm com um punhado de campos de texto e nenhum conteúdo XFA abre normalmente numa pdfium.dll simples, mas o mesmo arquivo pode fazer a FPDFDOC_InitFormFillEnvironment devolver NULL quando o processo está rodando a pdfium.v8.dll com XFA. Os bytes do documento são idênticos nas duas execuções. O que difere é um inteiro: builds de PDFium com XFA exigem que FPDF_FORMFILLINFO.version seja 2, e um binding que grava 1 para documentos sem XFA é mandado embora antes de o PDFium olhar um único callback

Por que a FPDFDOC_InitFormFillEnvironment rejeita um PDF AcroForm comum?

A rejeição vem do binário, e o documento não tem voz nisso. O header de form fill, fpdf_formfill.h no PDFium e sua tradução Pascal em FPdfFormFill.pas, declara que um build compilado com o módulo XFA exige a versão 2, e diz isso duas vezes no mesmo bloco de comentário. Um build sem XFA aceita 1 ou 2. Sob um binding que manda versão 1 para arquivos não XFA, só uma das quatro combinações de binário e documento falha: um registro versão 1 entregue à pdfium.v8.dll junto com um arquivo AcroForm comum. A DLL simples aceita esse registro. A DLL V8 aceita um registro versão 2 para qualquer documento. Um formulário XFA real na DLL V8 nunca recebe versão 1 de um binding que deriva a versão do documento, então a célula que falha é precisamente aquela em que ninguém pensa em testar

Um binding feito à mão vê a FPDFDOC_InitFormFillEnvironment devolver NULL nesse ponto e não tem mais nada em que se apoiar. A TPdf do PDFium Component reportaria um ambiente recusado como um EPdfError durante o Active := True, mas ela sempre grava versão 2, então essa célula nunca aparece ali. Cair nessa célula é fácil. Um host põe a global EnableV8Engine em True porque quer JavaScript de AcroForm, e a partir daí todo PDF do processo passa pelo binário V8. Ou o host nunca toca na flag, mas o primeiro arquivo que ele abre tem uma entrada /XFA no dicionário AcroForm dele ou /NeedsRendering true no catálogo: quando a biblioteca PDFium ainda não foi carregada, a TPdf escaneia o arquivo atrás desses dois marcadores e liga a EnableV8Engine antes de carregar a DLL. De um jeito ou de outro a escolha vale pelo resto do processo, e todo arquivo AcroForm comum aberto depois esbarra no build com XFA. Qual binário distribuir em primeiro lugar está coberto em o guia de deploy da DLL do PDFium e diagnóstico de falhas de carga; tudo abaixo assume que o build V8 já está carregado

Diagrama do PDFium Component das quatro combinações da pdfium.dll simples e da pdfium.v8.dll com XFA contra documentos AcroForm e XFA: um registro versão 1 falha só no build V8 com um formulário comum, em FPDFDOC_InitFormFillEnvironment, enquanto um registro versão 2 abre os quatro
Ligar a versão do ABI ao documento significa que a escolha do binário V8 em todo o processo transforma todo PDF simples posterior numa inicialização de ambiente que falha

O que o campo version no FPDF_FORMFILLINFO promete

O FPDF_FORMFILLINFO.version diz ao PDFium quanta parte do registro ele pode ler. A versão 1 cobre a parte estável: Release, os callbacks de FFI_Invalidate a FFI_DoGoToAction, e o ponteiro m_pJsPlatform. A versão 2 acrescenta uma cauda experimental. Um build sem XFA que receba a versão 2 também vai chamar os callbacks experimentais que encontrar lá; um build com XFA não aceita menos que isso. O header nunca menciona o documento. Ao gravar 2 você está prometendo que a memória depois de m_pJsPlatform existe e que todo slot nela guarda ou uma função cdecl válida ou NULL

Essa cauda começa com xfa_disabled, um FPDF_BOOL ignorado abaixo da versão 2, seguido de dezessete ponteiros de função de FFI_DisplayCaret a FFI_DoURIActionWithKeyboardModifier. Quinze deles, de FFI_DisplayCaret a FFI_PutRequestURL, são documentados como "Required for XFA, otherwise set to NULL". Os dois últimos, FFI_OnFocusChange e FFI_DoURIActionWithKeyboardModifier, são opcionais para todo host, e o header acrescenta que um embedder versão 2 que deixe o FFI_DoURIActionWithKeyboardModifier sem definir tem os cliques de URI redirecionados ao FFI_DoURIAction. Posto tudo junto, NULL é o valor especificado para um host que não conduz XFA. Um registro zerado com FillChar e carimbado com versão 2 cumpre o contrato num build simples exatamente como um registro versão 1 cumpre, e é também o único registro que um build com XFA aceita

Diagrama do PDFium Component do registro FPDF_FORMFILLINFO no Delphi: a versão 1 cobre os callbacks de FFI_Invalidate até FFI_DoGoToAction mais m_pJsPlatform, a versão 2 acrescenta xfa_disabled e dezessete ponteiros da era FFI_DisplayCaret, FillChar limpa todo byte, e slots NULL são o estado documentado para um host que não está conduzindo XFA
O registro Pascal é sempre o layout completo da versão 2, então um build com XFA o aceita e um build sem XFA simplesmente nunca chama os slots experimentais que ficam NULL

O lado de JavaScript segue o mesmo padrão. O m_pJsPlatform não é usado por builds sem V8; num build V8 um ponteiro NULL impede o JavaScript de rodar enquanto o documento renderiza. O registro IPDF_JsPlatform para o qual ele aponta tem o próprio campo version, e o header diz que esse atualmente precisa ser 2

A armadilha: deixar o documento escolher a versão do ABI

O erro é um condicional que soa sensato por si só. Um binding feito à mão já precisa decidir se vale a pena ligar os callbacks XFA, e é tentador deixar o mesmo teste escolher o número da versão

// Uma forma tentadora para um binding feito à mão: a versão segue o documento
FillChar(FormInfo, SizeOf(FormInfo), 0);
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
if IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable then
  FormInfo.version := 2
else
  FormInfo.version := 1;   // todo documento não XFA recebe 1

FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// na pdfium.v8.dll com um arquivo AcroForm comum, FormHandle é nil aqui

A intenção por trás desse branch é defensiva: manter a versão em 1 para que um build com XFA nunca leia slots que o host não preencheu. Mas o FillChar na linha de cima já os preencheu com NULL, que o header define como o valor correto para um host que não está rodando XFA. O branch protege contra um perigo que a declaração já eliminou e anda direto para dentro do que o header alerta. Ele também não pode ser pego testando arquivos XFA, já que esses tomam o caminho versão 2 toda vez

O PDFium Component constrói o registro ao contrário. Sempre que TPdf.FormFill é True, abrir um documento limpa o registro completo, grava versão 2 e xfa_disabled := 1, e só então pergunta o que o documento precisa. Dito como regra para qualquer binding: defina a versão a partir do registro que você declarou, e deixe nada mais tocá-la. Aqui está essa forma para o seu próprio código, com um wrapper THostFormFillInfo declarado mais abaixo

// FormInfo é um campo THostFormFillInfo do seu objeto viewer, então ele sobrevive
// ao form handle; HostInvalidate, HostGetPage e o resto são seus próprios
// callbacks cdecl
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;

FormInfo.Info.version := 2;        // casa com o layout declarado, sempre
FormInfo.Info.xfa_disabled := 1;   // XFA fica desligada a menos que se prove utilizável

FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... os outros callbacks da versão 1

IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
RuntimeReady := IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable;
if RuntimeReady then
begin
  FormInfo.Info.xfa_disabled := 0;
  FormInfo.Info.FFI_DisplayCaret := HostDisplayCaret;
  FormInfo.Info.FFI_PageEvent := HostPageEvent;
  // ... os slots restantes da versão 2
end;

FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo.Info);
if FormHandle = nil then
  raise Exception.Create('Cannot initialize form fill environment');
XfaRunning := RuntimeReady and (FPDF_LoadXFA(Doc) <> 0);

Onde RuntimeReady e V8FeaturesAvailable pertencem

Checagens de capacidade decidem quais slots são preenchidos, e no PDFium Component elas nunca tocam no layout. A TPdf calcula a prontidão a partir de três fatos: o documento reporta um form type XFA através de TPdf.XFA, a global XfaFeaturesAvailable acha os helpers de string XFA exportados pela DLL carregada, e a V8FeaturesAvailable acha as exportações V8. Os callbacks da versão 1 são ligados para todo documento, porque AcroForm e XFA dependem deles. Os dezessete slots da versão 2 são atribuídos, e o xfa_disabled limpo, só quando os três fatos valem. Depois que o ambiente existe, a FPDF_LoadXFA roda só para esse caso, e só um retorno não zero faz a TPdf.XfaRuntimeAvailable reportar True. A FPDF_LoadXFA é exportada até por builds sem o engine XFA e lá simplesmente devolve false, e é por isso que a checagem da exportação sozinha não basta

Quando o documento é XFA mas o runtime falta, o registro fica na versão 2 com xfa_disabled em 1 e a cauda NULL. O ambiente abre, o conteúdo XFA fica estático, e a TPdf dispara OnXfaRuntimeMissing para que o host possa sugerir reiniciar na pdfium.v8.dll. O m_pJsPlatform segue uma terceira regra: é anexado sempre que a V8FeaturesAvailable é verdadeira, para arquivos AcroForm comuns também, e é isso que permite scripts de nível de documento, cálculos de campo e app.alert rodarem sob o build V8. O PageCount lê a árvore de páginas estática até a XFA repaginar o documento através do FFI_PageEvent; nesse ponto o novo total vem da FPDF_GetPageCount e é reportado via OnXfaPageCountChanged

No lado do host, duas propriedades respondem a duas perguntas diferentes. A XFA diz o que o arquivo contém, a partir da FPDF_GetFormType, independentemente do binário. A XfaRuntimeAvailable diz se o engine realmente partiu para este documento. Leia a segunda depois que o Active ficar True, como faz o artigo sobre detectar formulários XFA e extrair pacotes XFA, e deixe o campo de versão completamente fora do raciocínio

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Disparado enquanto o documento abre quando ele é XFA mas a DLL carregada não
  // consegue rodar o engine. O ambiente de formulário continua existindo, com XFA desabilitada
  StatusBar.SimpleText :=
    'XFA form detected; restart with pdfium.v8.dll for dynamic rendering';
end;

procedure TMainForm.OpenDocument(const FileName: string);
begin
  Pdf.Active := False;
  Pdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  Pdf.FormFill := True;
  Pdf.FileName := FileName;
  Pdf.Active := True;   // arquivos AcroForm comuns abrem sob a pdfium.v8.dll também
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaNotice;   // sua própria rotina de UI
end;

Erros de layout que quebram o registro antes de a versão ser lida

Um número de versão correto só ajuda se os bytes por trás dele estiverem onde o compilador C os pôs. Vários erros de binding produzem um registro que o PDFium lê errado não importa qual versão ele carregue, e um deles fica invisível no Win32. É declarar a FPDF_FORMFILLINFO como packed record. No Win32 todo campo tem quatro bytes, então layouts packed e alinhados por acaso coincidem. No Win64 o struct C preenche quatro bytes depois de version para que o Release caia numa fronteira de 8 bytes, e preenche de novo depois do xfa_disabled; uma declaração Pascal packed descarta os dois vãos e desloca todo ponteiro de callback em quatro bytes. A declaração em FPdfFormFill.pas é um record simples por essa razão

O segundo erro é um registro truncado: uma tradução que para no m_pJsPlatform mas grava versão 2. O PDFium então lê a cauda de qualquer memória que siga o registro. O terceiro é tempo de vida. O header diz que o registro passado à FPDFDOC_InitFormFillEnvironment precisa continuar válido até o form handle devolvido ser fechado, então um registro guardado numa variável local do procedimento que abre o documento é um ponteiro pendente no momento em que esse procedimento retorna. A TPdf mantém o registro dela como campo do componente, embrulhado num registro externo que põe o struct do PDFium no offset zero e um back-pointer para a TPdf dona logo depois, e é assim que todo callback estático reencontra o objeto dele

type
  // Errado no Win64: o packing remove o padding que o struct C tem depois
  // de version, então Release fica no offset 4 aqui e no offset 8 no PDFium
  TBadFormFillInfo = packed record
    version: Integer;
    Release: Pointer;
    // ... campos restantes
  end;

  // Seu próprio wrapper: o registro do PDFium primeiro, seu back-pointer depois
  THostFormFillInfo = record
    Info: FPDF_FORMFILLINFO;   // offset 0, então pThis faz o cast direto de volta
    Owner: TObject;
  end;
  PHostFormFillInfo = ^THostFormFillInfo;

procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
  left, top, right, bottom: Double); cdecl;
begin
  try
    // TMyViewer e InvalidatePdfRect representam a sua própria classe viewer
    TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
      page, left, top, right, bottom);
  except
    // uma exceção Pascal não deve atravessar os frames C++ do PDFium
  end;
end;

Duas outras entradas merecem cuidado. A FFI_GetLocalTime devolve um FPDF_SYSTEMTIME de 16 bytes por valor, e Delphi e MSVC discordam no Win32 sobre onde vai o ponteiro de resultado oculto, então uma implementação Pascal corromperia a FPDF_FORMFILLINFO de quem chamou. O PDFium atual marca o membro como não usado, e a declaração Pascal carrega um comentário para deixá-lo nil; o PDFium Component faz isso. Todo callback que o componente instala captura exceções Pascal e devolve o valor de falha documentado em vez disso, porque o ABI C não tem como carregar uma exceção de volta pela fronteira. O artigo sobre endurecer o ABI do PDFium no Delphi percorre essa disciplina usando a FFI_OpenFile como exemplo

Versão de protocolo e disponibilidade de recurso são dois eixos

Um campo de versão numa estrutura de callbacks responde quão grande o registro é e o que pode ser lido dele. A detecção de recurso responde quais desses slots vão fazer alguma coisa. O primeiro é fixado pelo binário nativo e pela declaração contra a qual você compilou; o segundo varia por documento, por tabela de exportações da DLL e por configuração do host. O caso XFA precisa das duas respostas ao mesmo tempo, o que torna tentador dobrá-las num único booleano, e essa dobra quebra no instante em que um build exige uma versão mínima. Formulários XFA, que a ISO 32000-1 §12.7.8 descreve como um payload XML carregado junto ao dicionário AcroForm, são o recurso desta história. O layout do registro é o protocolo, e o PDFium tem o direito de checar o layout antes de ler o arquivo

Diagrama do PDFium Component separando os dois eixos por trás do FPDF_FORMFILLINFO: a versão de protocolo fixada pelo layout do registro e pelo binário nativo, e a disponibilidade de recurso em que RuntimeReady controla xfa_disabled, dezessete slots da versão 2, FPDF_LoadXFA enquanto V8FeaturesAvailable decide m_pJsPlatform, por documento e por host
Um campo de versão descreve a memória que o outro lado pode ler, as checagens de capacidade decidem quais slots fazem algo útil, e colapsar os dois num único booleano quebra o build que exige uma versão mínima

A mesma estrutura aparece onde quer que uma biblioteca C versione os structs dela: FPDF_LIBRARY_CONFIG, IPDF_JsPlatform, qualquer tabela de callbacks com um header de tamanho ou versão. Declare o layout mais novo que você entende, limpe-o por completo, grave a versão correspondente incondicionalmente e preencha os slots de acordo com o que o documento e a DLL conseguem fazer de fato. Se um futuro fpdf_formfill.h introduzir a versão 3, o trabalho é uma declaração nova e uma atribuição mudada. Um branch chaveado pelo conteúdo do documento teria de ser revisitado para cada combinação de build e arquivo, e a que ele erra costuma ser a que ninguém abriu

O tratamento de registro descrito aqui faz parte do PDFium Component para Delphi, Lazarus e C++Builder, com a mesma declaração servindo builds Win32 e Win64. Uma aplicação que já distribui a pdfium.v8.dll para AcroForms dirigidos por JavaScript pode abrir o resto do acervo, XFA ou não, por esse único binário sem tratamento especial para o ambiente de formulário