Artigo Técnico

XFA no PDFium Component: newlines, emoji e restoreState

O PDFium Component grava valores de formulários XFA editados exatamente, através de gravar e reabrir, quando corre o runtime Windows V8 pdfium.v8.dll distribuído na v3.125.2 ou posterior. Runtimes mais antigos acrescentavam line feeds a valores de campos, cortavam emoji para um carácter BMP sem relação, saltavam silenciosamente gravações XFA de stream único e podiam engolir uma escrita final falhada. Um sintoma de reabertura não é um defeito da biblioteca de todo: um formulário dinâmico cujo subform raiz não tem restoreState="auto" reconstrói o seu layout a partir do template

Os relatórios de bug disto pareciam todos iguais. Um cliente preenche um formulário de requisição XFA num viewer Delphi, grava, reabre, e algo está ligeiramente errado. Uma caixa de comentários vazia agora contém uma linha em branco, e depois de uma segunda gravação contém duas. Um nome digitado com um emoji volta com um glifo de private use. Ninguém recebe um erro, que é o que torna estes bugs caros: a deriva aparece semanas depois no export de outra pessoa

O que corre mal quando um formulário XFA é gravado e reaberto?

Quatro defeitos separados no caminho de gravação XFA nativo causavam deriva de valores, e cada um escondia-se atrás de uma gravação de aspeto bem-sucedido. Dois vinham da serialização, um do layout de armazenamento de stream único, e um do próprio escritor PDF. A tabela mapeia cada sintoma à sua causa e à versão em que o PDFium Component o corrigiu

Sintoma após reabrirCausaCorrigido em
Campo vazio contém um line feed; os valores crescem um newline por gravaçãoAmbos os escritores XFA inseriam newlines de layout depois de start tagsv3.125.2, pdfium.v8.dll
U+1F642 volta como U+F642, ou o emoji desaparece do pacote de formulárioTruncamento de wchar_t de 16 bits na descodificação; filtragem de surrogates no serializador de formuláriosv3.125.2, pdfium.v8.dll
Edições num documento XFA de stream único simplesmente desaparecemA gravação nativa recusava o layout de stream, mas o valor de retorno era ignoradov3.125.2; comentários e instruções de processamento mantidos desde a v3.126.0
Ficheiro truncado embora a gravação reportasse sucessoA última escrita em buffer falhou depois de o escritor já ter devolvido sucessoruntime V8 v3.125.2; pdfium.dll comum v3.125.3
Formulário dinâmico de três páginas reabre como duas páginasO subform raiz não pede restoreState="auto"Autoria do formulário, não um defeito da biblioteca

Textos anteriores concluíam que edições de campos XFA não podiam de todo ser persistidas com o PDFium, o que era exato para os runtimes da época. O runtime V8 mais novo grava valores XFA nativamente, por isso uma edição feita no formulário vivo chega ao pacote datasets gravado sem cirurgia de pacotes do seu lado

Que runtime PDFium grava valores XFA?

A fidelidade de gravação XFA depende da DLL nativa, não do wrapper Delphi, por isso a primeira verificação é que runtime o seu processo realmente carregou. O PDFium Component distribui duas builds Windows por arquitetura: a pdfium.dll comum, compilada sem V8 e XFA, e a pdfium.v8.dll, que transporta o motor JavaScript e o runtime de formulários XFA. Só a pdfium.v8.dll consegue correr um formulário XFA, por isso todas as correções XFA aqui descritas vivem aí, começando pelas bibliotecas V8 Win32 e Win64 reconstruídas na v3.125.2

A correção da última escrita é código genérico do escritor PDF, por isso interessa também para documentos comuns. A v3.125.3 reconstruiu as bibliotecas pdfium.dll comuns para transportar essa mesma reparação. Fonte partilhado não é prova de comportamento partilhado: até o binário ser reconstruído, a DLL antiga guarda o bug antigo

Uma segunda armadilha sentava-se no loader. Antes da v3.125.2, pôr o EnableV8Engine a True fazia o binding escolher o nome por omissão pdfium.v8.dll e ignorar um caminho completo em LibraryName. Uma aplicação que apontasse a um runtime recém-implantado podia continuar a carregar uma cópia mais antiga de outra pasta. Desde a v3.125.2, um LibraryName que contenha um diretório seleciona exatamente esse ficheiro em qualquer modo de motor, e um caminho em falta falha em vez de fazer fallback para outra biblioteca incluída

uses
  System.SysUtils, PDFium;

procedure SelectXfaRuntime;
begin
  // Um diretório em LibraryName fixa este ficheiro exato (v3.125.2 e posterior);
  // se o ficheiro falta, o carregamento lança em vez de fazer fallback
{$IFDEF WIN64}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
  PDFium.EnableV8Engine := True;
  PDFium.LoadLibrary;  // falhe no arranque, não na primeira gravação
end;

Depois de abrir um documento, o TPdf.XFA diz-lhe que o ficheiro contém XFA e o TPdf.XfaRuntimeAvailable diz-lhe que a DLL carregada consegue realmente executá-lo. Se também precisa distinguir formulários estáticos de dinâmicos, o TPdf.FormType devolve ftXfaFull ou ftXfaForeground; o artigo sobre deteção de formulários XFA e extração de pacotes XFA em Delphi cobre essa sondagem em detalhe

Porque é que os campos XFA gravados ganham line feeds extra?

Os campos XFA gravados ganhavam line feeds porque ambos os escritores XFA nativos, o escritor genérico de elementos XML e o serializador do pacote de formulário, faziam pretty-print da sua saída com um newline depois de start tags. Na maioria do XML esse espaço em branco é cosmético. Em dados XFA não é: quando o pacote datasets é analisado outra vez, o texto entre <Comments> e </Comments> é o valor do campo, newline incluído. Um campo vazio por isso reabria com um único LF, e cada ciclo seguinte de gravar-e-reabrir podia acrescentar outro

Diagrama do ciclo de gravação XFA do PDFium Component em que o escritor acrescenta um newline depois de start tags, o parser reaberto lê o LF entre as tags Comments como o valor do campo, e cada gravação seguinte acrescenta outro line feed até a v3.125.2 remover só o espaço em branco sintetizado pelo serializador
um ciclo de gravar-e-reabrir planta o primeiro line feed e cada ronda seguinte acrescenta outro, razão pela qual a deriva só mostrou a sua forma completa na segunda geração

A reparação óbvia, cortar espaços aos valores ao carregar, estaria errada. Os utilizadores digitam espaços iniciais, espaços finais e texto multilinha deliberado em campos XFA, e um bloco de endereço ou um código de largura fixa têm de sobreviver byte a byte. A correção da v3.125.2 por isso remove só o espaço em branco que o próprio serializador sintetizou à volta das tags. Valores do utilizador, nós de texto existentes e secções CDATA passam intactos, por isso " indented" fica indented e um campo intencionalmente vazio fica vazio

Porque é que um emoji volta como um carácter diferente?

Um emoji voltava errado porque o wchar_t do Windows tem 16 bits de largura, e dois caminhos de descodificação guardavam um valor escalar Unicode completo num único wchar_t. O descodificador de streams UTF-8 e o parser de referências de caracteres numéricas como &#x1F642; faziam ambos isto. O U+1F642, a carinha levemente sorridente, não cabe em 16 bits, por isso os bits altos caíam e o U+F642 aparecia em vez dele: um code point na Private Use Area que a maioria das fontes renderiza como uma caixa ou nada

O serializador de formulários tinha o problema oposto. Filtrava caracteres um wchar_t de cada vez, via duas unidades de código surrogate inválidas isoladas, e largava ambas, por isso o emoji desaparecia do pacote de formulário por inteiro. Na v3.125.2 o descodificador consome cada valor escalar por completo e emite um par surrogate próprio. Quando só resta uma ranhura de saída, mantém o surrogate baixo pendente e não reporta fim de stream enquanto essa unidade estiver em buffer. Uma sequência UTF-8 dividida entre blocos de leitura é transportada para a leitura seguinte em vez de ser descartada. O exportador de formulários agora mantém pares surrogate válidos juntos, e as referências de caracteres numéricas produzem pares corretos também

Diagrama de tratamento de surrogates do PDFium Component em que o U+1F642 chega como o par UTF-16 D83D DE42 e dois caminhos com defeito corrompem-no: descodificadores wchar_t de 16 bits truncam o escalar para U+F642 na private use area, enquanto o serializador de formulários filtra surrogates solitários e larga o emoji por inteiro
o wchar_t do Windows tem 16 bits de largura, por isso um escalar que precisa de um par surrogate ou perdia a sua metade alta ou desaparecia do pacote até ambos os caminhos aprenderem a manter pares juntos

Dados de teste Latin-1 nunca mostram nada disto, por isso todos os testes de round-trip XFA precisam de pelo menos um carácter de plano suplementar

XFA de stream único e falhas de gravação que ninguém via

Um documento XFA de stream único perdia as suas edições porque o ajudante de gravação nativo recusava esse layout de armazenamento e o chamador ignorava a falha. O ISO 32000-1 §12.7.8 permite que a entrada /XFA do dicionário de formulário interativo seja ou um array de nomes de pacotes e streams ou um único stream que contenha o documento XDP inteiro. Arrays de pacotes são o caso comum, mas streams únicos são perfeitamente legais, e a gravação PDF completava como se nada tivesse acontecido enquanto os dados do formulário ficavam nos valores antigos

Desde a v3.125.2, o runtime V8 trata do subconjunto de stream único suportado. Primeiro exporta ambos os pacotes vivos, datasets e form, para uma área de encenação e valida-os, e só então substitui os pacotes correspondentes no XDP original. Outros pacotes e as declarações de namespaces da raiz são mantidos. Se a encenação falhar, o stream XFA persistente nunca é tocado e o documento mantém a sua marca de modificação

Comentários XML e instruções de processamento precisavam de cuidado extra porque o DOM XML interno os larga. Na v3.125.2 a sua presença fazia a gravação falhar de todo em vez de perder conteúdo em silêncio. A v3.126.0 preserva-os: antes de analisar, cada comentário ou instrução de processamento é trocado por um marcador construído a partir de um prefixo que não ocorre em lado nenhum do texto original. Depois de os pacotes vivos serem substituídos, cada marcador tem de aparecer exatamente uma vez antes do token original ser restaurado e o stream ser escrito. Os tokens fora dos pacotes substituídos mantêm por isso o seu texto e ordem, incluindo tokens no prólogo, no template e noutros pacotes

Alguns inputs ainda são recusados de propósito, e cada recusa é uma falha de gravação explícita:

  • Comentários ou instruções de processamento dentro dos pacotes vivos datasets ou form, já que as suas posições originais não podem ser mapeadas para conteúdo recentemente exportado
  • Declarações DTD e assinaturas XMLDSig, já que reescrever o XDP não consegue manter uma assinatura XML válida
  • Codificação UTF-8 ou UTF-16 inválida, tags incompletas, referências de caracteres inválidas, entidades desconhecidas e instruções de processamento malformadas, que são recusadas em vez de reparadas em silêncio
Pipeline de gravação XFA de stream único do PDFium Component em que os pacotes vivos datasets e form são exportados para encenação, validados, depois substituídos dentro do XDP original com comentários preservados através de marcadores, enquanto falhas de encenação e inputs como DTDs ou XMLDSig recusam a gravação explicitamente
o export encenado é validado antes de qualquer coisa ser substituída, por isso uma gravação falhada deixa o stream XFA persistente intacto e o documento mantém a sua marca de modificação

O output de stream único é UTF-8 e preserva o modelo de conteúdo XML, não o layout de bytes original nem a declaração de codificação

O último defeito sentava-se abaixo do XFA. O escritor de ficheiros nativo faz buffer do output em blocos de 32 KB e descarregava o último bloco parcial só no seu destrutor, depois de o escritor do documento já ter reportado sucesso. Um disco cheio ou um erro de E/S nesse último bloco era invisível para o chamador. Desde a v3.125.2 no runtime V8 e a v3.125.3 no runtime comum, esse flush final faz parte do resultado da gravação, e a marca de modificação XFA só é limpa depois de um sucesso real. Do lado Delphi, o TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean escreve para um ficheiro temporário ao lado do alvo e move-o para o sítio só quando a gravação devolve True, por isso uma gravação falhada deixa o ficheiro anterior intacto

Porque é que um formulário XFA dinâmico reabre com menos páginas?

Um formulário XFA dinâmico reabre com menos páginas quando o seu subform raiz não declara restoreState="auto", e isso é uma decisão de autoria do formulário e não um defeito do PDFium Component. No XFA 3.3, o restoreState no subform raiz por omissão é manual. Sob manual, o processador XFA restaura só um estado limitado do pacote de formulário gravado e deixa o resto aos scripts do autor. Valores de campos gravados e contagens de instâncias de subforms repetidos ainda voltam, mas propriedades geométricas definidas em run time não

O caso que expôs isto era um formulário de três páginas cujo script crescia um subform para h="450pt". O pacote de formulário gravado guardava a nova altura, os valores e as contagens de instâncias. Na reabertura, no entanto, o layout era reconstruído a partir das alturas do template e o formulário refluía para duas páginas. O runtime estava certo: o template nunca tinha pedido restauração automática. Declará-lo no subform raiz corrige a reabertura:

<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
  <subform name="form1" layout="tb" restoreState="auto">
    <pageSet>
      <pageArea name="Page1">
        <contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
        <medium stock="letter"/>
      </pageArea>
    </pageSet>
    <subform name="Details" layout="tb" w="7.5in">
      <!-- campos; scripts podem mudar h ou acrescentar instâncias em run time -->
    </subform>
  </subform>
</template>

Se o template não é seu, não o remendeie à volta no viewer: um formulário que depende do modo manual espera que os seus próprios scripts reconstruam o estado. A repaginação viva enquanto o utilizador digita é um tema separado, coberto em como o PDFium Component segue contagens de páginas XFA dinâmicas e campos movidos

Como verifica uma gravação XFA em Delphi?

A única verificação fiável de uma gravação XFA é reabrir o ficheiro gravado numa instância TPdf fresca e ler os dados guardados de volta. O TPdf.GetXfaDatasets devolve o pacote datasets como está guardado no documento, não o modelo de dados XFA vivo, por isso chamá-lo antes de gravar mostra os valores antigos. Depois de reabrir, mostra exatamente o que foi escrito. Um documento de stream único não tem pacotes nomeados separadamente: o PDFium reporta o XDP inteiro como um pacote com nome vazio, por isso GetXfaPacketByName('datasets') e GetXfaDatasets não devolvem nada, e o fallback lê o stream completo através de GetXfaFormPackets

uses
  System.SysUtils, PDFium, FPdfXfa;

function ReadSavedXfaData(const FileName: string): string;
var
  Pdf: TPdf;
  Packets: TXfaPacketList;
  Bytes: TBytes;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Bytes := Pdf.GetXfaDatasets;          // layout de array de pacotes
    if Length(Bytes) = 0 then
    begin
      Packets := Pdf.GetXfaFormPackets;   // stream único: um pacote sem nome
      if Length(Packets) = 1 then
      begin
        SetLength(Bytes, Length(Packets[0].Content));
        if Length(Bytes) > 0 then
          Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
      end;
    end;
    Result := TEncoding.UTF8.GetString(Bytes);  // o output XDP gravado é UTF-8
  finally
    Pdf.Free;
  end;
end;

A rotina de gravação depois consolida a edição pendente, verifica o resultado do SaveAs e compara o valor reaberto. O TPdf.ClearFormFieldFocus mata o foco do formulário, que é o momento em que o PDFium consolida o buffer de edição do campo com foco. O TPdf.SetFocusedFormFieldText(const Value: WString): Boolean preenche o campo com foco programaticamente, mas depende de um foco que o wrapper rastreia através do FocusFormField, que percorre anotações de widgets. Uma página XFA dinâmica normalmente não tem nenhum, por isso aí o texto normalmente chega por input de teclado no TPdfView, e a função devolve False quando nenhum campo rastreado tem foco

function XmlText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
end;

procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
  Expected: string);
var
  Saved: string;
begin
  // Preenchimento scripted opcional; False significa que nenhum campo rastreado tem foco
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // consolida o buffer de edição
  if not Pdf.SaveAs(FileName) then      // inclui o flush final (v3.125.2+)
    raise EPdfError.CreateFmt('Saving %s failed', [FileName]);

  Saved := ReadSavedXfaData(FileName);
  if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
    Saved) = 0 then
    raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;

Trate o teste de substring como um smoke test. Um elemento vazio pode ser serializado como <Tag/>, atributos podem aparecer em elementos de dados, e escapar para além de & e < é uma escolha do serializador. Para verificações de produção, carregue o XML reaberto com um parser XML real e compare o nó de texto do elemento de dados ligado. Corra a verificação duas vezes seguidas também, porque o defeito do newline só mostrou a sua forma completa na segunda geração

Referência rápida: checklist de fidelidade de gravação XFA

  • Distribua a pdfium.v8.dll da v3.125.2 ou posterior para formulários XFA, e a v3.125.3 ou posterior para a pdfium.dll comum, para que a correção da última escrita esteja em ambos
  • Aponte o LibraryName a um caminho completo e ponha o EnableV8Engine a True; um caminho em falta falha em vez de carregar outra cópia
  • Confirme o TPdf.XFA e o TPdf.XfaRuntimeAvailable depois de abrir o documento
  • Chame o ClearFormFieldFocus antes do SaveAs para que o campo com foco seja consolidado
  • Nunca ignore o resultado Boolean do SaveAs; um resultado False deixa o ficheiro anterior no sítio
  • Verifique reabrindo num TPdf novo e lendo o GetXfaDatasets, com fallback para o GetXfaFormPackets em XFA de stream único
  • Teste com valores vazios, espaços iniciais, texto multilinha, & e um carácter de plano suplementar, ao longo de duas gerações de gravação
  • Espere falhas de gravação explícitas para DTDs, XMLDSig e comentários dentro dos pacotes vivos de XFA de stream único
  • Se um formulário dinâmico perde geometria de run time na reabertura, verifique o subform raiz à procura de restoreState="auto" antes de suspeitar da biblioteca

Para a estrutura de callbacks que o runtime XFA espera de uma aplicação host, veja FPDF_FORMFILLINFO versão 2 e o ABI XFA em Delphi. O runtime V8, o wrapper Delphi e C++Builder e o controlo de viewer fazem todos parte do PDFium Component para Delphi e C++Builder, que inclui ambos os runtimes Windows para Win32 e Win64