Artigo Técnico

XFA dinâmico no PDFium Component: páginas são um delta

Quando um formulário XFA dinâmico num viewer Delphi adiciona ou remove páginas, o PDFium Component reporta o novo total pelo TPdf.PageCount e pelo TPdf.OnXfaPageCountChanged desde a v3.126.1, porque o evento de página nativo carrega um delta de adicionadas/removidas em vez de um total. As bibliotecas Windows V8 na v3.126.1 também movem as áreas de clique de input junto com fields realocados, e a v3.126.2 recarrega handles de página obsoletos depois de o callback de layout retornar. O relato de bug que iniciou isto foi um formulário de reembolso de despesas: clique em Add Row duas vezes, o formulário cresce para duas páginas, e o indicador de página orgulhosamente lê 1 de 1. Digite num field que se moveu para a página 2 e as teclas caem num lugar invisível. Nada disso apareceu com os formulários de amostra de comprimento fixo com que todo mundo testa primeiro, e os motivos valem a pena conhecer se você embute um viewer de formulários

O que acontece quando um formulário XFA dinâmico repagina?

Um formulário XFA dinâmico não tem lista de páginas fixa, então o total de páginas dele é uma saída do layout e pode mudar toda vez que o usuário edita dados. A XFA 3.3 descreve o formulário como uma árvore de subforms; um subform repetitivo é controlado por um instanceManager, e um script como _Row.addInstance() clona mais uma linha. O processador de layout então flui o conteúdo para as áreas de página de novo, o que pode adicionar uma página, descartar uma página ou empurrar fields existentes para outra página. A ISO 32000-1 §12.7.8 só define como os pacotes XFA viajam dentro do PDF; tudo que acontece depois disso pertence ao motor XFA, que no PDFium Component é o layout XFA do próprio PDFium rodando no processo host. Um viewer Delphi portanto lida com um documento cujo total de páginas, tamanhos de página e posições de widget são todos estado vivo. Três coisas dão errado quando o host assume o contrário:

  • O total de páginas que o host cacheia para navegação, intervalos de scroll e spinners de página fica obsoleto, ou pior, é atualizado com o número errado
  • Fields que se realocam mostram a borda na nova posição enquanto o editor e a área de clique do mouse ficam nas coordenadas antigas
  • O viewer mantém um handle de página que o layout substituiu, então cliques e pinturas vão para uma página que não existe mais naquele formulário

Persistir edições de linhas através de salvar e reabrir é um problema separado com regras próprias; este artigo fica com o que acontece em runtime dentro do viewer

Qual runtime do PDFium o XFA dinâmico precisa?

O XFA dinâmico no PDFium Component exige a build V8/XFA da biblioteca nativa, selecionada pela variável global EnableV8Engine na unit PDFium antes de o primeiro documento carregar. O processo se compromete com uma única DLL na primeira vez em que qualquer TPdf carrega a biblioteca, e uma build plain do PDFium não consegue rodar o motor XFA de forma alguma. Quando um documento abre, o TPdf espia o arquivo atrás de marcadores XFA e troca para a build V8 automaticamente, mas só se nenhuma biblioteca plain tiver sido carregada ainda naquele processo. Quando o comprometimento já seguiu o caminho errado, o TPdf.OnXfaRuntimeMissing dispara uma vez para o host avisar o usuário a reiniciar. Definir a flag explicitamente na inicialização remove o chute. A estrutura de callbacks FPDF_FORMFILLINFO que carrega os eventos XFA também precisa casar com a DLL; o contexto está em FPDF_FORMFILLINFO versão 2 e a ABI de callbacks XFA, e detectar formulários XFA e ler os pacotes deles cobre distinguir os tipos de formulário antes de você abrir um viewer

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // Decida antes de o primeiro TPdf carregar a biblioteca nativa:
  // o processo não pode trocar de pdfium.dll para pdfium.v8.dll depois
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

Por que o PageCount reportava 1 para um formulário de duas páginas?

Antes da v3.126.1, o PDFium Component armazenava o argumento page_count do evento de página nativo como o total do documento, e esse argumento na verdade é a diferença absoluta entre o novo e o antigo total de páginas. O PDFium levanta o FFI_PageEvent depois de uma passada de layout terminar com um tipo de evento de página adicionada ou removida; internamente ele atualiza o total de páginas armazenado primeiro e depois passa abs(new - old). No layout inicial o total antigo é zero, então o delta iguala o total, e uma amostra estática de três páginas reporta três páginas como esperado. É exatamente por isso que formulários de teste de comprimento fixo nunca expuseram o bug. A primeira vez que um formulário dinâmico cresce de uma página para duas, o delta é 1, e o wrapper setava tanto o TPdf.PageCount quanto o parâmetro NewCount do OnXfaPageCountChanged para 1. Remover uma linha de um formulário de três páginas produzia o mesmo tipo de absurdo na outra direção

Acumular o delta sobre o valor anterior também não é um conserto seguro. A ordem de callbacks de inicialização e layout significa que o wrapper nem sempre pode confiar no total anterior dele como baseline, então uma soma corrente pode derivar. Desde a v3.126.1, o callback ignora o argumento como total e chama o FPDF_GetPageCount no documento, que lê o total do layout que acabou de concluir. Ele então limpa as cenas de página em cache, armazena esse total como o override de total de páginas XFA por trás do TPdf.PageCount, e só depois disso levanta o OnXfaPageCountChanged. Na hora em que o seu handler roda, NewCount e FPdf.PageCount concordam

Diagrama de XFA dinâmico do PDFium Component em que adicionar uma linha repagina um formulário de uma página para duas e o FFI_PageEvent passa abs(novo menos antigo) como delta, então o wrapper antigo reportava TPdf.PageCount 1 enquanto a v3.126.1 lê o FPDF_GetPageCount e reporta o total correto
O evento de página nativo reporta um delta de adicionadas-ou-removidas, não um total, então a v3.126.1 ignora o argumento e lê o layout concluído antes de levantar o OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+: NewCount é o total do layout concluído, nunca um delta.
  // Isto roda dentro do callback de layout do PDFium: atualize só o estado
  // de UI do host, não feche o documento nem recarregue páginas daqui
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // Dispara depois de todo reload de página, incluindo o refresh XFA adiado
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

O evento só dispara para formulários Full XFA cujo layout muda em runtime. Documentos Static XFA e AcroForm nunca o levantam, então um viewer que trata dos dois pode deixar o mesmo handler atribuído. Deixá-lo sem atribuição também é seguro; o override por trás do TPdf.PageCount é aplicado de qualquer forma, e o evento existe para o host atualizar o que tiver cacheado

Por que a caixa de input fica na página antiga quando um field se move?

A borda se moveu e o editor não porque o notificador XFA nativo comparava um retângulo com ele mesmo. Quando o layout muda a geometria de um widget já carregado, o PDFium deveria notar o novo retângulo e chamar PerformLayout no widget, que reposiciona o editor de texto e a área de clique dele. A checagem comparava GetWidgetRect() com RecacheWidgetRect(). As duas funções devolvem uma referência const ao mesmo membro, e o recache sobrescreve esse membro no lugar, então a comparação sempre via dois valores idênticos e widgets carregados pulavam o relayout deles

O sintoma apareceu quando um teste mudou a altura de um subform de modo que fields existentes cruzassem para a página seguinte. Nas duas arquiteturas V8, a borda do field era desenhada na nova posição enquanto o texto digitado e a área de clique do mouse ficavam na coordenada Y anterior. Um relayout explícito não consertava, e recarregar a página também não, porque o widget ainda acreditava que a geometria dele estava atual. As bibliotecas Windows V8 entregues com a v3.126.1 copiam o retângulo antigo por valor antes do recache e comparam essa cópia, então widgets movidos fazem relayout e o valor editado aparece exatamente onde a borda está. Este é um conserto nativo: ele viaja com as DLLs, então atualizar as units Pascal mantendo um pdfium.v8.dll mais antigo deixa as áreas de clique fora do lugar. O teste de regressão que o motivou edita uma linha sobrevivente para um valor não padrão primeiro e depois exige esse valor na nova localização do field, porque uma linha reconstruída com valores padrão pareceria um passe

Diagrama de relayout de widget do PDFium Component contrastando a autocomparação antiga em que GetWidgetRect e RecacheWidgetRect devolviam um membro compartilhado, então widgets movidos pulavam o PerformLayout, com a checagem por cópia de valor do Windows V8 da v3.126.1 que reposiciona o editor e a área de clique do mouse sobre a borda redesenhada
Comparar um retângulo com ele mesmo nunca falha, então a borda se movia enquanto o texto digitado e os cliques ficavam para trás até a checagem salvar uma cópia por valor primeiro

Como o TPdfView recarrega páginas sem arrancar um handle de debaixo do PDFium?

Desde a v3.126.2, o TPdfView adia o reload de página que segue uma mudança de layout XFA até a pilha de chamadas nativa se desfazer. O evento de página normalmente dispara enquanto o PDFium ainda processa input: o usuário clicou num botão Add Row, o clique rodou um script, o script mudou a contagem de instâncias, e o layout terminou dentro daquela mesma chamada nativa. Fechar e reabrir o handle da página nesse momento liberaria um objeto que o chamador ainda está usando. Antes da v3.126.2, o viewer só se invalidava, então o handle da página exibida podia continuar apontando para estado pré-layout, e se o usuário estivesse na última página quando ela desapareceu, o número de página selecionado estava fora do intervalo

O refresh adiado funciona em alguns passos pequenos, e eles explicam o comportamento que você vê do host:

  1. O callback do evento de página marca a view como tendo um refresh de layout XFA pendente e posta uma mensagem de janela privada; eventos repetidos antes de a mensagem chegar se fundem num único refresh
  2. Uma view sem handle de janela ainda mantém a flag pendente e posta a mensagem a partir do CreateWnd, enquanto trocar de documento, desativar a view ou destruí-la limpa a flag
  3. Quando a mensagem chega, a view limpa a seleção de texto, o destaque de busca e o índice do field em foco, porque os três se referiam ao layout antigo
  4. A página selecionada é limitada ao novo PageCount; um número de página alterado passa pela troca de página normal, caso contrário a página atual é recarregada, e o modo de ajuste é aplicado de novo
  5. Se o layout não deixa página nenhuma, a view descarrega o handle de página antigo em vez de pintar uma página que não existe mais
Diagrama de refresh XFA adiado do TPdfView do PDFium Component em que um evento de página dentro da pilha de chamadas de layout nativa só marca um refresh pendente e posta uma mensagem de janela, que depois limpa o estado de seleção obsoleto, limita a página ao novo PageCount e recarrega ou descarrega o handle de página
O reload espera até a pilha de chamadas nativa se desfazer: uma mensagem postada funde eventos repetidos, depois a view limita a página, recarrega-a e levanta o OnPageChange

A mesma restrição vale para o seu próprio código. O OnXfaPageCountChanged roda dentro daquele callback de layout nativo, então trate-o como uma notificação: atualize rótulos, intervalos de spinner e estado da toolbar ali, e enfileire qualquer coisa mais pesada, como fechar o documento ou abrir outro, com uma mensagem postada para rodar depois de o callback retornar. O TPdfView.OnPageChange então diz quando a view de fato recarregou a página, e ler o PdfView1.PageNumber nesse ponto dá o valor limitado. Percorrimento com Tab e as checagens de FormType que um viewer de formulários roda na abertura estão cobertos em navegação de form fields PDF com o PDFium Component

Por que clicar num field Full XFA lança "Cannot open text page"?

Páginas Full XFA não têm página de texto PDF, e antes da v3.126.2 a seleção de texto padrão do viewer e a detecção de links tentavam carregar uma mesmo assim. Com o TPdfView.AllowUserTextSelection no padrão True, passar o mouse perguntava à camada de texto por um caractere sob o mouse, e um clique de mouse-up rodava uma probe automática de URL sobre o texto da página. Numa página Full XFA a página de texto não pode ser aberta, então um clique comum num field podia terminar numa exceção Cannot open text page. Desde a v3.126.2, os dois caminhos internos devolvem nenhum resultado quando o TPdf.FormType é ftXfaFull e o runtime XFA está disponível, então as configurações padrão funcionam e o input em fields continua disponível

Desligar o AllowUserTextSelection para documentos Full XFA continua sendo uma escolha de UI razoável, porque não há texto de página para selecionar e gestos de arrasto não devem iniciar um modo de seleção. Não é um substituto para atualizar, porém: em versões anteriores a probe de URL no clique não dependia dessa propriedade, então um viewer podia esbarrar na mesma exceção com a seleção desabilitada

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormType lê o documento aberto, então chame isto depois de FPdf.Active := True
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // Não há camada de texto PDF nas páginas Full XFA; fields continuam editáveis
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

Digitar precisou de um conserto próprio na v3.126.2. O editor de texto XFA nativo não substitui uma seleção quando recebe um caractere: o FORM_OnChar insere no caret, e o Backspace apaga um único caractere, então selecionar um valor e digitar por cima produzia texto antigo e novo lado a lado. O PDFium Component agora lembra que o clique caiu num field de texto XFA e roteia caracteres digitados, Backspace e Delete pelo FORM_ReplaceSelection sempre que existe uma seleção e o documento concede permissão de preencher formulários ou modificar. Se um field XFA read-only pode mudar continua sendo decidido pelo editor nativo, então um field marcado read-only no formulário mantém o valor dele mesmo num documento que de resto permite preencher. Definir TPdfView.AllowFormEvents como False também para esse roteamento de teclado, o que mantém um viewer read-only read-only

Referência rápida: XFA dinâmico num viewer Delphi

SintomaCausaCorrigido em
O total de páginas mostra 1 depois de o formulário crescer para duas páginasO evento de página nativo passa um delta de adicionadas/removidas, não um totalv3.126.1 (wrapper)
A borda do field se move, texto digitado e área de clique ficam para trásWidget carregado pulou o relayout após uma autocomparaçãov3.126.1 (bibliotecas Windows V8)
O viewer pinta ou roteia entrada para estado de página pré-layoutHandle de página não recarregado após repaginaçãov3.126.2 (refresh adiado)
Clicar num field lança Cannot open text pageSeleção de texto e probe de URL em páginas sem camada de textov3.126.2
Digitar sobre um valor selecionado anexa em vez de substituirO editor XFA nativo insere no caretv3.126.2
  • Defina EnableV8Engine como True antes de qualquer documento carregar, e trate o OnXfaRuntimeMissing para o caso de a biblioteca plain ter sido carregada primeiro
  • Leia o total pelo TPdf.PageCount ou pelo parâmetro NewCount do OnXfaPageCountChanged; nunca some ou subtraia totais de páginas por conta própria
  • Mantenha o handler do OnXfaPageCountChanged leve, porque ele roda dentro do callback de layout nativo
  • Sincronize o indicador de página atual no TPdfView.OnPageChange, que dispara depois de o reload adiado limitar o número da página
  • Implante as DLLs Windows V8 da v3.126.1 ou posterior junto com as units; a correção de relayout de widget vive em código nativo
  • Teste com um formulário que de fato muda o total de páginas e move um field editado através de uma quebra de página, porque amostras de comprimento fixo escondem todo bug desta lista

O XFA dinâmico transforma o total de páginas e a geometria de fields em valores vivos, e um viewer só permanece correto quando os toma do layout concluído e recarrega páginas num momento seguro. O PDFium Component trata dos dois dentro do TPdf e do TPdfView, então o host só precisa escutar. Detalhes e downloads estão na página de produto do PDFium Component para Delphi