Artigo Técnico

PDFium Component e XFA dinâmico: a contagem é um delta

Quando um formulário XFA dinâmico num viewer Delphi acrescenta ou remove páginas, o PDFium Component reporta o novo total através de TPdf.PageCount e TPdf.OnXfaPageCountChanged desde a v3.126.1, porque o evento de páginas nativo transporta um delta de adicionadas/removidas em vez de um total. As bibliotecas Windows V8 na v3.126.1 também movem as áreas de input com os campos deslocados, e a v3.126.2 recarrega page handles obsoletos depois da callback de layout regressar. O relatório de bug que começou isto era um formulário de requisição de despesas: clique duas vezes em Add Row, o formulário cresce para duas páginas, e o indicador de páginas orgulhosamente lê 1 de 1. Digite num campo que se mudou para a página 2 e as teclas caem num sítio invisível. Nada disto aparecia nos formulários de amostra de comprimento fixo com que toda a gente testa primeiro, e as razões valem a pena conhecer se 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, por isso a sua contagem de páginas é um output do layout e pode mudar sempre que o utilizador edita dados. O XFA 3.3 descreve o formulário como uma árvore de subforms; um subform repetido é controlado por um instanceManager, e um script como _Row.addInstance() clona mais uma linha. O processador de layout depois verte o conteúdo de novo nas áreas de página, o que pode acrescentar uma página, tirar uma página ou empurrar campos existentes para outra página. O ISO 32000-1 §12.7.8 só define como os pacotes XFA viajam dentro do PDF; tudo o que acontece depois pertence ao motor XFA, que no PDFium Component é o layout XFA do próprio PDFium a correr no processo host. Um viewer Delphi por isso lida com um documento cuja contagem de páginas, tamanhos de página e posições de widgets são todos estado vivo. Três coisas correm mal quando o host assume o contrário:

  • A contagem de páginas que o host guarda em cache para navegação, intervalos de scroll e spinners de páginas fica obsoleta, ou pior, é atualizada com o número errado
  • Campos que se relocalizam mostram a borda na nova posição enquanto o editor e a área de input do rato ficam nas coordenadas antigas
  • O viewer mantém um page handle que o layout substituiu, por isso cliques e pinturas vão para uma página que já não existe nesse formulário

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

Que runtime 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 unidade PDFium antes do primeiro documento carregar. O processo compromete-se com uma DLL da primeira vez que qualquer TPdf carrega a biblioteca, e uma build PDFium simples não consegue correr o motor XFA de todo. Quando um documento abre, o TPdf até espreita o ficheiro à procura de marcadores XFA e muda para a build V8 automaticamente, mas só se nenhuma biblioteca simples tiver sido ainda carregada nesse processo. Quando o compromisso já foi tomado no sentido errado, o TPdf.OnXfaRuntimeMissing dispara uma vez para o host poder dizer ao utilizador para reiniciar. Definir a flag explicitamente no arranque elimina as adivinhações. A estrutura de callbacks FPDF_FORMFILLINFO que transporta os eventos XFA também tem de coincidir com a DLL; o contexto está em FPDF_FORMFILLINFO versão 2 e o ABI de callbacks XFA, e deteção de formulários XFA e leitura dos seus pacotes cobre distinguir os tipos de formulários antes de abrir um viewer

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // Decida antes do primeiro TPdf carregar a biblioteca nativa:
  // o processo não pode mudar de pdfium.dll para pdfium.v8.dll mais tarde
  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;

Porque é que o PageCount reportava 1 num formulário de duas páginas?

Antes da v3.126.1, o PDFium Component guardava o argumento page_count do evento de páginas nativo como o total do documento, e esse argumento é na verdade a diferença absoluta entre as contagens de páginas nova e antiga. O PDFium lança o FFI_PageEvent depois de uma passagem de layout terminar com um tipo de evento de página adicionada ou removida; internamente atualiza primeiro a contagem de páginas que guarda e depois passa abs(new - old). No layout inicial a contagem antiga é zero, por isso 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. Da primeira vez que um formulário dinâmico cresce de uma página para duas, o delta é 1, e o wrapper punha tanto o TPdf.PageCount como o parâmetro NewCount do OnXfaPageCountChanged a 1. Remover uma linha de um formulário de três páginas produzia o mesmo tipo de disparate no outro sentido

Acumular o delta sobre o valor anterior também não é uma reparação segura. A ordem das callbacks de inicialização e de layout significa que o wrapper nem sempre pode confiar na sua contagem anterior como base, por isso uma soma corrente pode desviar-se. Desde a v3.126.1, a callback ignora o argumento como contagem e chama FPDF_GetPageCount no documento, que lê o total do layout que acabou de terminar. Depois limpa as cenas de páginas em cache, guarda esse total como a sobreposição de contagem de páginas XFA por trás do TPdf.PageCount, e só depois disso lança o OnXfaPageCountChanged. Quando o seu handler corre, o NewCount e o FPdf.PageCount coincidem

Diagrama XFA dinâmico do PDFium Component em que acrescentar uma linha repagina um formulário de uma página para duas e o FFI_PageEvent passa abs(new menos old) como delta, por isso 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áginas nativo reporta um delta de adicionadas-ou-removidas, não um total, por isso a v3.126.1 ignora o argumento e lê o layout terminado antes de lançar o OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+: NewCount é o total do layout terminado, nunca um delta.
  // Isto corre dentro da 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 cada recarga de página, incluindo o refresh XFA diferido
  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 lançam, por isso um viewer que trate de ambos pode deixar o mesmo handler atribuído. Deixá-lo por atribuir também é seguro; a sobreposição por trás do TPdf.PageCount é aplicada na mesma, e o evento existe para o host refrescar o que tiver em cache

Porque é que a caixa de input fica na página antiga quando um campo se move?

A borda moveu-se e o editor não porque o notificador XFA nativo comparou um retângulo consigo próprio. Quando o layout muda a geometria de um widget já carregado, supõe-se que o PDFium repara no retângulo novo e chama PerformLayout no widget, que reposiciona o editor de texto e a sua área de input. A verificação comparava GetWidgetRect() com RecacheWidgetRect(). Ambas as funções devolvem uma referência const ao mesmo membro, e o recache sobrescreve esse membro no sítio, por isso a comparação via sempre dois valores idênticos e os widgets carregados saltavam o seu relayout

O sintoma apareceu quando um teste mudou a altura de um subform de modo a que campos existentes atravessassem para a página seguinte. Nas duas arquiteturas V8, a borda do campo era desenhada na sua nova posição enquanto o texto digitado e a área de input do rato ficavam na coordenada Y anterior. Um relayout explícito não a corrigia, e recarregar a página tão pouco, porque o widget ainda acreditava que a sua geometria estava atual. As bibliotecas Windows V8 enviadas com a v3.126.1 copiam o retângulo antigo por valor antes do recache e comparam essa cópia, por isso os widgets movidos fazem relayout e o valor editado aparece exatamente onde a borda está. Esta é uma correção nativa: viaja com as DLLs, por isso atualizar as unidades Pascal mantendo um pdfium.v8.dll mais antigo deixa as áreas de input deslocadas no sítio. A verificação de regressão que a impulsionou edita primeiro uma linha sobrevivente para um valor não predefinido e depois exige esse valor na nova localização do campo, porque uma linha reconstruída com valores predefinidos pareceria senão um passe

Diagrama de relayout de widgets do PDFium Component contrastando a autocomparação antiga em que GetWidgetRect e RecacheWidgetRect devolviam um membro partilhado e os widgets movidos saltavam o PerformLayout, com a verificação por cópia por valor do Windows V8 da v3.126.1 que reposiciona o editor e a área de input do rato sobre a borda redesenhada
comparar um retângulo consigo próprio nunca falha, por isso a borda moveu-se enquanto o texto digitado e os cliques ficavam para trás até a verificação guardar uma cópia por valor primeiro

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

Desde a v3.126.2, o TPdfView adia a recarga de página que se segue a uma mudança de layout XFA até a pilha de chamadas nativa se ter desenrolado. O evento de páginas normalmente dispara enquanto o PDFium ainda está a processar input: o utilizador clicou num botão Add Row, o clique correu um script, o script mudou a contagem de instâncias, e o layout terminou dentro dessa mesma chamada nativa. Fechar e reabrir o page handle nesse momento libertaria um objeto que o chamador ainda está a usar. Antes da v3.126.2, o viewer só se invalidava a si próprio, por isso o page handle exibido podia continuar a apontar para estado anterior ao layout, e se o utilizador estivesse na última página quando esta desapareceu, o número de página selecionado estava fora do intervalo

O refresh diferido funciona em uns quantos passos pequenos, e explicam o comportamento que se vê do lado do host:

  1. A callback do evento de páginas marca a vista como tendo um refresh de layout XFA pendente e publica uma mensagem de janela privada; eventos repetidos antes da mensagem chegar fundem-se num único refresh
  2. Uma vista sem handle de janela ainda mantém a flag pendente e publica a mensagem a partir do CreateWnd, enquanto mudar de documento, desativar a vista ou destruí-la limpa a flag
  3. Quando a mensagem chega, a vista limpa a seleção de texto, o destaque de pesquisa e o índice do campo com 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 mudado passa pela troca de página normal, caso contrário a página atual é recarregada, e o modo de ajuste é aplicado outra vez
  5. Se o layout não deixar páginas nenhuma, a vista descarrega o seu page handle antigo em vez de pintar uma página que já não existe
Diagrama do refresh XFA diferido do TPdfView do PDFium Component em que um evento de páginas dentro da pilha de chamadas do layout nativo só marca um refresh pendente e publica uma mensagem de janela, que mais tarde limpa o estado de seleção obsoleto, limita a página ao novo PageCount e recarrega ou descarrega o page handle
a recarga espera até a pilha de chamadas nativa se desenrolar: uma mensagem publicada funde eventos repetidos, depois a vista limita a página, recarrega-a e lança o OnPageChange

A mesma restrição aplica-se ao seu próprio código. O OnXfaPageCountChanged corre dentro dessa callback de layout nativa, por isso trate-o como uma notificação: atualize rótulos, intervalos de spinners e estado da toolbar aí, e coloque na fila tudo o que for mais pesado, como fechar o documento ou abrir outro, com uma mensagem publicada para correr depois de a callback regressar. O TPdfView.OnPageChange depois diz-lhe quando a vista realmente recarregou a página, e ler o PdfView1.PageNumber nesse momento dá-lhe o valor limitado. A travessia pela tecla Tab e as verificações FormType que um viewer de formulários corre ao abrir estão cobertas em navegação de campos de formulários PDF com o PDFium Component

Porque é que clicar num campo Full XFA lança "Cannot open text page"?

As páginas Full XFA não têm página de texto PDF, e antes da v3.126.2 a seleção de texto e a deteção de ligações por omissão do viewer tentavam carregar uma na mesma. Com o TPdfView.AllowUserTextSelection na sua predefinição True, o passar do rato pedia à camada de texto um carácter debaixo do cursor, e um clique de largar o rato corria uma sonda 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, por isso um clique comum num campo podia acabar numa exceção Cannot open text page. Desde a v3.126.2, ambos os caminhos internos devolvem nenhum resultado quando o TPdf.FormType é ftXfaFull e o runtime XFA está disponível, por isso as definições por omissão funcionam e o input em campos fica disponível

Desligar o AllowUserTextSelection para documentos Full XFA continua a ser 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, no entanto: em versões anteriores a sonda de URL no clique não dependia dessa propriedade, por isso um viewer podia apanhar a mesma exceção com a seleção desligada

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

A escrita precisava da sua própria reparação na v3.126.2. O editor de texto XFA nativo não substitui uma seleção quando recebe um carácter: o FORM_OnChar insere no cursor, e o Backspace apaga um único carácter, por isso selecionar um valor e escrever por cima produzia texto antigo e novo lado a lado. O PDFium Component agora lembra-se de que o clique caiu num campo de texto XFA e encaminha caracteres digitados, Backspace e Delete através do FORM_ReplaceSelection sempre que existe uma seleção e o documento concede permissão de preencher formulários ou de modificar. Se um campo XFA só de leitura pode mudar continua a ser decidido pelo editor nativo, por isso um campo marcado como só de leitura no formulário mantém o seu valor mesmo num documento que de outra forma permite preencher. Pôr o TPdfView.AllowFormEvents a False também para este encaminhamento de teclado, o que mantém um viewer só de leitura só de leitura

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

SintomaCausaCorrigido em
A contagem de páginas mostra 1 depois de o formulário crescer para duas páginasO evento de páginas nativo passa um delta de adicionadas/removidas, não um totalv3.126.1 (wrapper)
A borda do campo move-se, o texto digitado e a área de input ficam para trásWidget carregado saltava o relayout depois de uma autocomparaçãov3.126.1 (bibliotecas Windows V8)
O viewer pinta ou encaminha input para estado de página anterior ao layoutPage handle não recarregado depois da repaginaçãov3.126.2 (refresh diferido)
Clicar num campo lança Cannot open text pageSeleção de texto e sonda de URL em páginas sem camada de textov3.126.2
Escrever sobre um valor selecionado acrescenta em vez de substituirO editor XFA nativo insere no cursorv3.126.2
  • Ponha o EnableV8Engine a True antes de qualquer documento carregar, e trate do OnXfaRuntimeMissing para o caso de a biblioteca simples ter sido carregada primeiro
  • Leia o total do TPdf.PageCount ou do parâmetro NewCount do OnXfaPageCountChanged; nunca some ou subtraia contagens de páginas você próprio
  • Mantenha o handler do OnXfaPageCountChanged leve, porque corre dentro da callback de layout nativa
  • Sincronize o indicador de página atual no TPdfView.OnPageChange, que dispara depois da recarga diferida limitar o número de página
  • Distribua as DLLs Windows V8 da v3.126.1 ou posterior juntamente com as unidades; a correção de relayout de widgets vive em código nativo
  • Teste com um formulário que realmente mude a sua contagem de páginas e mova um campo editado através de uma quebra de página, porque amostras de comprimento fixo escondem todos os bugs desta lista

O XFA dinâmico torna a contagem de páginas e a geometria de campos em valores vivos, e um viewer só permanece correto quando os toma do layout terminado e recarrega páginas num momento seguro. O PDFium Component trata de ambos dentro do TPdf e do TPdfView, por isso ao host só resta ouvir. Detalhes e transferências estão na página do produto PDFium Component para Delphi