Artigo Técnico

FPDF_FORMFILLINFO versão 2 em Delphi: seguir a ABI da DLL

O PDFium Component passa agora a pôr FPDF_FORMFILLINFO.version a 2 em todos os ambientes de preenchimento de formulário que inicializa, porque a versão que uma compilação nativa do PDFium aceita é uma propriedade dessa compilação e não do documento que está a ser aberto. Um pdfium.v8.dll com XFA recusa a versão 1 sem mais, pelo que um PDF AcroForm simples aberto através dele falhava no FPDFDOC_InitFormFillEnvironment sem que houvesse XFA em lado nenhum. A correção da v3.116.0 é pequena, mas o erro por trás dela é geral e vale a pena nomeá-lo: um campo de versão de protocolo descreve a disposição de memória que o outro lado espera, e nunca deve ser derivado do facto de precisar ou não das funcionalidades que essa disposição transporta

Porque é que o FPDFDOC_InitFormFillEnvironment falha num PDF simples com o pdfium.v8.dll?

O ambiente falha porque uma compilação do PDFium com XFA valida o campo version antes de fazer seja o que for, e a lógica antiga do wrapper dava-lhe um 1 sempre que o documento atual não fosse um formulário XFA. O sintoma num host Delphi é um EPdfError lançado pelo TPdf.InitializeFormFill com a mensagem Cannot initialize form fill environment, disparado ao abrir uma fatura ou um formulário fiscal comum que não tem mais nada além de campos de texto AcroForm. O mesmo ficheiro abre bem contra o pdfium.dll simples. A mesma DLL abre bem um documento XFA a sério. Só a combinação entre a compilação V8 e um documento não XFA se quebra, que é exatamente a combinação em que um host cai depois de ligar o EnableV8Engine para ter JavaScript de AcroForm, ou depois de a seleção automática no LoadDocument já ter comprometido o processo com o pdfium.v8.dll por causa de um ficheiro XFA anterior. Esse compromisso é a nível do processo: o EnableV8Engine é lido antes do primeiro LoadLibrary, e uma vez carregada a compilação XFA, todos os PDFs simples seguintes passam pela mesma configuração de ambiente contra o mesmo binário. O host não fez nada de errado; o wrapper fez a pergunta errada ao preencher o registo. Se ainda está a decidir que binário distribuir, a nossa nota sobre distribuir a DLL do PDFium e diagnosticar falhas de carregamento cobre a escolha entre simples e V8, e este artigo assume que a compilação V8 já está no processo

Diagrama do PDFium Component das quatro combinações entre o pdfium.dll simples e o pdfium.v8.dll com XFA contra documentos AcroForm e XFA: um registo de versão 1 quebrava apenas a compilação V8 com um formulário simples, EPdfError no FPDFDOC_InitFormFillEnvironment, enquanto o registo de versão 2 corrigido abre as quatro
Um único condicional ligava a versão da ABI ao documento, pelo que a escolha do binário V8 a nível do processo transformava todos os PDFs simples seguintes numa inicialização de ambiente falhada

O que é que o campo version do FPDF_FORMFILLINFO promete ao certo?

O FPDF_FORMFILLINFO.version diz ao PDFium que campos do registo lhe é permitido ler, e o header público fpdf_formfill.h liga os valores aceitáveis à forma como a biblioteca foi compilada e não ao documento. Em paráfrase, o contrato tem três partes. A versão 1 cobre os callbacks estáveis de FFI_Invalidate a FFI_DoGoToAction mais o ponteiro m_pJsPlatform. Uma compilação sem o módulo XFA aceita 1 ou 2 e, com 2, chama também os callbacks experimentais adicionais. Uma compilação com o módulo XFA exige 2, ponto final, e o header repete essa exigência duas vezes como se contasse que as pessoas a fossem perder. Em lado nenhum o contrato menciona o documento. A versão é uma afirmação sobre o registo que alocou: com um 2 está a prometer que a memória a seguir ao m_pJsPlatform existe e contém ou ponteiros de função válidos ou NULL

É na região da versão 2 que vive toda a maquinaria XFA. Começa com xfa_disabled, um FPDF_BOOL que o header descreve como ignorado abaixo da versão 2 e significativo apenas quando o módulo XFA está compilado, e continua com dezassete ponteiros de função, de FFI_DisplayCaret a FFI_DoURIActionWithKeyboardModifier. Cada um deles está documentado como obrigatório para XFA e caso contrário para ser posto a NULL. Essa formulação é a chave de toda a correção. O NULL não é um estado de erro para essas ranhuras; é o estado documentado para um host que não está a conduzir XFA. Um registo que tenha sido limpo com FillChar e depois marcado como versão 2 satisfaz o contrato numa compilação sem XFA tão bem como um registo de versão 1, e é o único registo que uma compilação XFA aceita

Diagrama do PDFium Component do registo FPDF_FORMFILLINFO em Delphi: a versão 1 cobre os callbacks de FFI_Invalidate a FFI_DoGoToAction mais o m_pJsPlatform, a versão 2 acrescenta o xfa_disabled e dezassete ponteiros da era FFI_DisplayCaret, o FillChar limpa todos os bytes, e as ranhuras a NULL são o estado documentado para um host que não conduz XFA
O registo Pascal é sempre a disposição completa da versão 2, pelo que uma compilação com XFA o aceita e uma compilação simples simplesmente nunca chama as ranhuras experimentais que ficam a NULL

A seleção antiga ligava a ABI ao documento

O defeito era um único condicional que parecia razoável isoladamente. O TPdf.InitializeFormFill calcula um flag RuntimeReady a partir de três factos: o documento reporta um tipo de formulário XFA através de TPdf.XFA, os helpers de string XFA resolveram-se através de XfaFeaturesAvailable, e as exportações V8 resolveram-se através de V8FeaturesAvailable. Antes da v3.116.0, esse mesmo flag escolhia também a versão

// v3.115.0 e anteriores: a versão da ABI seguia o documento
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;

if RuntimeReady then
  FFormFillInfo.Info.version := 2
else
  FFormFillInfo.Info.version := 1;

// ... e o ramo de runtime em falta voltava a fixá-la
else if XFA then
begin
  FFormFillInfo.Info.version := 1;
  FFormFillInfo.Info.xfa_disabled := 1;
  if Assigned(FOnXfaRuntimeMissing) then
    FOnXfaRuntimeMissing(Self);
end;

Leia-o com o header na mão e a falha é óbvia. O RuntimeReady é false em todos os documentos AcroForm simples, pelo que todos os documentos simples anunciavam a versão 1. No pdfium.dll isso é pacífico. No pdfium.v8.dll, que é a compilação com XFA, o PDFium verifica o campo, encontra-o abaixo do 2 exigido e devolve um FPDF_FORMHANDLE nulo, que o CheckPdf transforma na exceção acima. A intenção do código antigo era defensiva: manter a versão 1 para que uma compilação XFA nunca leia as ranhuras não atribuídas da versão 2. Defendia contra um problema que o header já descarta e criou um que o header avisa explicitamente. O código corrigido decide a versão uma vez, à cabeça, a partir daquilo que o registo é fisicamente

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // sentinela: usar a árvore de páginas estática
  if not FormFill then
    Exit;

  FillChar(FFormFillInfo, SizeOf(FFormFillInfo), 0);
  FFormFillInfo.Pdf := Self;

  // O registo completo da versão 2 é alocado e limpo acima. O PDFium
  // aceita a versão 2 sem XFA e exige-a em todas as compilações
  // com XFA, incluindo quando este documento não contém formulário XFA.
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // O RuntimeReady limita os callbacks XFA e o xfa_disabled, nunca a versão.
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

Onde é que o RuntimeReady ainda pertence: os callbacks e o xfa_disabled

O RuntimeReady mantém o seu papel de gate do comportamento XFA; simplesmente deixa de tocar na disposição do registo. Os callbacks da versão 1, FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction e o resto desse bloco, são ligados incondicionalmente porque o AcroForm e o XFA dependem ambos deles. Os dezassete ponteiros da versão 2 só são atribuídos dentro do ramo RuntimeReady, a par de xfa_disabled := 0. Quando o documento é XFA mas o runtime não está lá, o registo fica na versão 2 com o xfa_disabled a 1 e as ranhuras da versão 2 deixadas a NULL, e o wrapper dispara OnXfaRuntimeMissing para que o host possa sugerir reiniciar com o pdfium.v8.dll. Depois de o ambiente existir, o FPDF_LoadXFA só é chamado quando o RuntimeReady era true, e só um retorno true põe FXfaRuntimeUsable, que é o que o TPdf.XfaRuntimeAvailable reporta

  if RuntimeReady then
  begin
    FFormFillInfo.Info.xfa_disabled := 0;   // 0 = XFA ativado
    FFormFillInfo.Info.FFI_DisplayCaret := FormFillDisplayCaret;
    FFormFillInfo.Info.FFI_GetCurrentPageIndex := FormFillGetCurrentPageIndex;
    FFormFillInfo.Info.FFI_SetCurrentPage := FormFillSetCurrentPage;
    FFormFillInfo.Info.FFI_GotoURL := FormFillGotoURL;
    FFormFillInfo.Info.FFI_GetPageViewRect := FormFillGetPageViewRect;
    FFormFillInfo.Info.FFI_PageEvent := FormFillPageEvent;
    FFormFillInfo.Info.FFI_PopupMenu := FormFillPopupMenu;
    FFormFillInfo.Info.FFI_OpenFile := FormFillOpenFile;
    FFormFillInfo.Info.FFI_EmailTo := FormFillEmailTo;
    // ... de FFI_UploadTo a FFI_DoURIActionWithKeyboardModifier
  end
  else if XFA then
  begin
    // Runtime indisponível: manter a versão 2, deixar o XFA desativado, avisar o host.
    if Assigned(FOnXfaRuntimeMissing) then
      FOnXfaRuntimeMissing(Self);
  end;

  FFormHandle := FPDFDOC_InitFormFillEnvironment(FDocument, FFormFillInfo.Info);
  CheckPdf(FFormHandle <> nil, 'Cannot initialize form fill environment');
  if RuntimeReady then
    FXfaRuntimeUsable := FPDF_LoadXFA(FDocument) <> 0;

Dois pormenores nesse bloco são fáceis de errar quando se escreve a sua própria ligação. O FXfaPageCountOverride é reposto a -1 como sentinela antes de acontecer mais alguma coisa, para que o PageCount recue para a árvore de páginas estática até o FFI_PageEvent reportar uma repaginação; um zero ali reivindicaria em silêncio um documento vazio. E cada um dos callbacks da versão 2 é uma rotina cdecl estática que recupera o TPdf dono a partir do registo e engole qualquer exceção Pascal antes de voltar ao PDFium, que é a disciplina que a nossa nota sobre endurecer a ABI do PDFium em Delphi detalha para o FFI_OpenFile. Nada na mudança de versão relaxa qualquer das duas regras

A versão 2 é segura quando a DLL não tem módulo XFA?

Sim, e a razão está no registo e não numa promessa da biblioteca. Numa compilação sem XFA, o header diz que a versão 2 faz com que os callbacks experimentais sejam também chamados, pelo que a questão é o que o PDFium encontra quando olha. O TPdfFormFillInfo é um registo packed cujo membro Info é o FPDF_FORMFILLINFO completo, incluindo todos os campos da versão 2, e o InitializeFormFill limpa tudo com FillChar antes de tocar num byte. Assim, num pdfium.dll simples com um documento simples, a biblioteca vê a versão 2, o xfa_disabled definido e NULL em todas as ranhuras experimentais, que é precisamente o estado que o header prescreve para um host que não implementa XFA. Não há registo truncado para a biblioteca ler para além dele, porque o registo nunca foi mais curto do que a versão 2. A lógica antiga defendia-se de um desencontro de disposição que a declaração Pascal já tinha eliminado

O limite que vale a pena enunciar com honestidade é aquele que o registo não consegue cobrir. A versão 2 num documento simples não liga o JavaScript, o scripting XFA nem nenhum dos eventos do host por trás desses callbacks. O m_pJsPlatform só é anexado quando V8FeaturesAvailable é true, o XFA fica desativado a menos que o RuntimeReady fosse true, e o TPdf.XFA continua a reportar o tipo de formulário a partir do FPDF_GetFormType, independentemente do que o ambiente negociou. Um host que queira saber se o XFA dinâmico vai realmente renderizar deve continuar a ler o XfaRuntimeAvailable depois de o Active passar a true, como recomenda a nossa nota sobre detetar formulários XFA e extrair pacotes XFA, em vez de inferir seja o que for do campo da versão

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Dispara do InitializeFormFill quando o documento é XFA mas o
  // pdfium.dll carregado não consegue correr o motor. O ambiente de formulário abre na mesma,
  // porque a versão 2 foi passada de qualquer forma; só o runtime XFA é que está desligado.
  StatusBar.SimpleText :=
    'XFA form detected; restart with pdfium.v8.dll to enable 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;   // já não lança exceção num PDF simples sob o pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaWarning;
end;

Versão de protocolo e disponibilidade de funcionalidades são dois eixos diferentes

A regra geral que sai desta correção é que um campo de versão numa estrutura de callbacks responde à pergunta «qual é o tamanho deste registo e o que é que lhe é permitido ler», enquanto a deteção de funcionalidades responde a «quais dessas ranhuras farão algo de útil». A primeira é fixada pelo binário nativo e pela declaração Pascal com que compilou. A segunda varia por documento, por tabela de exportações da DLL e por configuração do host. Colapsar as duas num único booleano é tentador porque o caso XFA calha a precisar de ambas, mas no momento em que uma compilação impõe uma versão mínima o colapso quebra-se para todos os documentos que não precisam da funcionalidade. Os formulários XFA, descritos na ISO 32000-1 §12.7.8 como um payload XML que vive a par do dicionário AcroForm, são aqui a funcionalidade; a disposição do registo é o protocolo, e o PDFium tem o direito de insistir na disposição antes de alguma vez olhar para o ficheiro. A mesma forma aparece em qualquer sítio onde uma biblioteca C versiona as suas estruturas: um bloco de informação de visualizador, um registo de opções de renderização, uma tabela de callbacks de plataforma. O padrão seguro é o que o InitializeFormFill corrigido segue. Declare a disposição mais recente que compreende, limpe-a por completo, ponha a versão a coincidir com essa disposição incondicionalmente, e depois deixe as verificações de capacidade decidir que ranhuras preencher. Se um futuro header do PDFium acrescentar uma versão 3, a mudança é na declaração e nessa única atribuição, não num ramo dependente do documento que estará errado para a combinação que ninguém testou

Diagrama do PDFium Component que separa os dois eixos por trás do FPDF_FORMFILLINFO: a versão do protocolo fixada pela disposição do registo e pelo binário nativo, e a disponibilidade de funcionalidades onde o RuntimeReady limita o xfa_disabled, dezassete ranhuras da versão 2, o FPDF_LoadXFA e o m_pJsPlatform por documento e por host
Um campo de versão descreve a memória que o outro lado pode ler, as verificações de capacidade decidem que ranhuras fazem algo de útil, e colapsar as duas num só booleano quebra a compilação que impõe um mínimo

A inicialização de preenchimento de formulário corrigida vem no PDFium Component para Delphi, Lazarus e C++Builder, e aplica-se tanto em Win32 como em Win64, já que as duas compilações partilham a mesma declaração de registo. Se a sua aplicação já seleciona o pdfium.v8.dll para AcroForms movidos a JavaScript, esta é a mudança que lhe permite abrir o resto do seu arquivo de PDFs através do mesmo binário, sem casos especiais no ambiente de formulário