Artigo Técnico

XFA no PDFium Component: newlines, emoji e restoreState

O PDFium Component salva valores de formulário XFA editados com exatidão, através de save e reopen, quando roda o runtime Windows V8 pdfium.v8.dll entregue na v3.125.2 ou posterior. Runtimes mais antigos adicionavam line feeds a valores de fields, cortavam emoji para um caractere BMP sem relação, pulavam silenciosamente saves XFA de stream único e podiam engolir uma última escrita que falhou. Um sintoma de reopen não é defeito da biblioteca de forma alguma: um formulário dinâmico cujo subform raiz não tem restoreState="auto" reconstrói o layout dele a partir do template

Os relatos de bug para isto todos pareciam iguais. Um cliente preenche um formulário XFA de sinistro num viewer Delphi, salva, reabre, e algo está levemente errado. Uma caixa de comentários vazia agora guarda uma linha em branco, e depois de um segundo save guarda duas. Um nome digitado com um emoji volta com um glifo de private use. Ninguém recebe um erro, e é isso que torna esses bugs caros: a deriva aparece semanas depois no export de outra pessoa

O que dá errado quando um formulário XFA é salvo e reaberto?

Quatro defeitos separados no caminho nativo de save XFA causavam deriva de valores, e cada um se escondia atrás de um save de aparência bem-sucedida. Dois vieram da serialização, um do layout de armazenamento de stream único, e um do próprio writer PDF. A tabela mapeia cada sintoma à causa dele e ao release em que o PDFium Component o corrigiu

Sintoma após reabrirCausaCorrigido em
Campo vazio guarda um line feed; valores crescem um newline por saveOs dois writers 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 packet de formulárioTruncamento de wchar_t de 16 bits na decodificação; filtragem de surrogates no serializer de formuláriov3.125.2, pdfium.v8.dll
Edições num documento XFA single-stream simplesmente somemO save nativo rejeitava o layout de stream, mas o valor de retorno era ignoradov3.125.2; comments e processing instructions preservados desde a v3.126.0
Arquivo truncado embora o save reportasse sucessoA última escrita em buffer falhava depois de o writer já ter devolvido sucessov3.125.2 runtime V8; v3.125.3 pdfium.dll comum
Formulário dinâmico de três páginas reabre com duasO 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 fields XFA não podiam ser persistidas com o PDFium de forma alguma, o que era preciso para os runtimes da época. O runtime V8 mais novo salva valores XFA nativamente, então uma edição feita no formulário vivo chega ao packet de datasets salvo sem cirurgia de packet do seu lado

Qual runtime do PDFium salva valores XFA?

A fidelidade de save XFA depende da DLL nativa, não do wrapper Delphi, então a primeira checagem é qual runtime o seu processo de fato carregou. O PDFium Component entrega duas builds Windows por arquitetura: a pdfium.dll comum, construída sem V8 e XFA, e a pdfium.v8.dll, que carrega o motor JavaScript e o runtime de formulários XFA. Só a pdfium.v8.dll consegue rodar um formulário XFA, então toda correção XFA descrita aqui vive nela, começando pelas bibliotecas V8 Win32 e Win64 reconstruídas na v3.125.2

O conserto da última escrita é código genérico de writer PDF, então importa para documentos comuns também. A v3.125.3 reconstruiu as bibliotecas pdfium.dll comuns para carregar aquele mesmo reparo. Código-fonte compartilhado não é prova de comportamento compartilhado: até o binário ser reconstruído, a DLL antiga mantém o bug antigo

Uma segunda armadilha ficava no loader. Antes da v3.125.2, definir EnableV8Engine como True fazia o binding escolher o nome padrão pdfium.v8.dll e ignorar um caminho completo no LibraryName. Uma aplicação que apontava para um runtime recém-implantado podia continuar carregando uma cópia mais antiga de outra pasta. Desde a v3.125.2, um LibraryName que contém um diretório seleciona exatamente aquele arquivo em qualquer modo de engine, e um caminho ausente falha em vez de cair para outra biblioteca embutida

uses
  System.SysUtils, PDFium;

procedure SelectXfaRuntime;
begin
  // Um diretório em LibraryName fixa este arquivo exato (v3.125.2 e posteriores);
  // se o arquivo falta, o carregamento lança em vez de cair para outro
{$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 na inicialização, não no primeiro save
end;

Depois de abrir um documento, o TPdf.XFA diz que o arquivo contém XFA e o TPdf.XfaRuntimeAvailable diz que a DLL carregada consegue de fato executá-lo. Se você também precisa distinguir formulários estáticos de dinâmicos, o TPdf.FormType devolve ftXfaFull ou ftXfaForeground; o artigo sobre detectar formulários XFA e extrair pacotes XFA em Delphi cobre essa sondagem em detalhe

Por que fields XFA salvos ganham line feeds extras?

Fields XFA salvos ganhavam line feeds porque os dois writers XFA nativos, o writer genérico de elementos XML e o serializer do packet de formulário, embelezavam a saída deles com um newline depois de start tags. Na maior parte do XML esse espaço em branco é cosmético. Em dados XFA não é: quando o packet de datasets é parseado de novo, o texto entre <Comments> e </Comments> é o valor do field, newline incluído. Um campo vazio portanto reabria guardando um único LF, e cada ciclo seguinte de save e reopen podia adicionar outro

Diagrama do ciclo de save XFA do PDFium Component em que o writer adiciona um newline depois de start tags, o parser da reabertura lê o LF entre as tags Comments como o valor do field, e cada save seguinte anexa mais um line feed até a v3.125.2 remover só o espaço em branco sintetizado pelo serializer
Um ciclo de save e reopen planta o primeiro line feed e cada rodada seguinte adiciona outro, e é por isso que a deriva mostrou a forma completa dela só na segunda geração

O conserto óbvio, aparar valores no load, estaria errado. Usuários digitam espaços no início, espaços no fim e texto multi-linha deliberado em fields XFA, e um bloco de endereço ou um código de largura fixa precisa sobreviver byte a byte. O conserto da v3.125.2 portanto remove só o espaço em branco que o próprio serializer sintetizou em volta de tags. Valores de usuário, nós de texto existentes e seções CDATA passam intocados, então " indented" continua indentado e um campo intencionalmente vazio continua vazio

Por que um emoji volta como um caractere diferente?

Um emoji voltava errado porque o wchar_t do Windows tem 16 bits de largura, e dois caminhos de decodificação armazenavam um valor escalar Unicode completo num único wchar_t. O decoder de stream UTF-8 e o parser de character references numéricas como &#x1F642; faziam isso. U+1F642, a carinha sorrindo de leve, não cabe em 16 bits, então os bits altos caíam fora e U+F642 aparecia em vez dele: um code point na Private Use Area que a maior parte das fontes renderiza como um quadrado ou nada

O serializer de formulário tinha o problema oposto. Ele filtrava caracteres um wchar_t por vez, via duas surrogate code units inválidas isoladamente, e descartava as duas, então o emoji sumia do packet de formulário por completo. Na v3.125.2 o decoder consome cada valor escalar por completo e emite um par de surrogates apropriado. Quando só resta um slot de saída, ele mantém o surrogate baixo pendente e não reporta fim de stream enquanto aquela unit está em buffer. Uma sequência UTF-8 dividida entre blocos de leitura é carregada para a próxima leitura em vez de ser descartada. O exporter de formulários agora mantém pares de surrogates válidos juntos, e character references numéricas produzem pares corretos também

Diagrama de tratamento de surrogates do PDFium Component em que U+1F642 chega como o par UTF-16 D83D DE42 e dois caminhos de defeito o corrompem: decoders wchar_t de 16 bits truncam o escalar para U+F642 na private use area, enquanto o serializer de formulário filtra surrogates solitários e descarta o emoji por completo
O wchar_t do Windows tem 16 bits de largura, então um escalar que precisa de um par de surrogates ou perdia a metade alta ou sumia do packet até os dois caminhos aprenderem a manter pares juntos

Dados de teste Latin-1 nunca mostram nada disso, então todo teste de round-trip XFA precisa de pelo menos um caractere do plano suplementar

XFA single-stream e falhas de save que ninguém via

Um documento XFA de stream único perdia as edições dele porque o helper nativo de save rejeitava aquele layout de armazenamento e o chamador dele ignorava a falha. A ISO 32000-1 §12.7.8 permite que a entrada /XFA do dicionário de formulário interativo seja tanto um array de nomes de packets e streams quanto um único stream guardando o documento XDP inteiro. Arrays de packets são o caso comum, mas streams únicos são perfeitamente legais, e o save PDF terminava 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 single-stream suportado. Ele primeiro exporta os dois packets vivos, datasets e form, para uma área de estágio e os valida, e só então substitui os packets correspondentes no XDP original. Outros packets e as declarações de namespace da raiz são mantidos. Se o estágio falha, o stream XFA persistente nunca é tocado e o documento mantém a marca de modificação

Comments XML e processing instructions precisaram de cuidado extra porque o DOM XML interno os descarta. Na v3.125.2 a presença deles fazia o save falhar de vez em vez de perder conteúdo em silêncio. A v3.126.0 os preserva: antes do parse, cada comment ou processing instruction é trocado por um marcador construído a partir de um prefixo que não ocorre em lugar nenhum do texto original. Depois de os packets vivos serem substituídos, todo marcador precisa aparecer exatamente uma vez antes de o token original ser restaurado e o stream gravado. Tokens fora dos packets substituídos portanto mantêm o texto e a ordem, incluindo tokens no prólogo, no template e em outros packets

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

  • Comments ou processing instructions dentro dos packets vivos datasets ou form, já que as posições originais deles não podem ser mapeadas para conteúdo recém-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, character references inválidas, entidades desconhecidas e processing instructions malformadas, que são rejeitadas em vez de consertadas em silêncio
Pipeline de save XFA single-stream do PDFium Component em que os packets vivos datasets e form são exportados para estágio, validados, e então substituídos dentro do XDP original com comments preservados por marcadores, enquanto falhas de estágio e inputs como DTDs ou XMLDSig recusam o save explicitamente
O export em estágio é validado antes de qualquer coisa ser substituída, então um save que falha deixa o stream XFA persistente intocado e o documento mantém a marca de modificação

A saída single-stream é UTF-8 e preserva o modelo de conteúdo XML, não o layout de bytes original nem a declaração de encoding

O último defeito ficava abaixo do XFA. O writer nativo de arquivos fazia buffer da saída em blocos de 32 KB e descarregava o último bloco parcial só no destrutor, depois de o writer do documento já ter reportado sucesso. Um disco cheio ou erro de I/O naquele ú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 do save, e a marca de modificação XFA só é limpa depois de um sucesso de verdade. Do lado Delphi, o TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean grava num arquivo temporário ao lado do alvo e o move para o lugar só quando o save devolve True, então um save que falha deixa o arquivo anterior intacto

Por 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 subform raiz dele não declara restoreState="auto", e isso é uma decisão de autoria do formulário, não um defeito do PDFium Component. Na XFA 3.3, o restoreState no subform raiz tem manual como padrão. Sob manual, o processador XFA restaura só estado limitado do packet de formulário salvo e deixa o resto para os scripts do autor. Valores de fields salvos e contagens de instâncias de subforms repetitivos 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 packet de formulário salvo guardava a nova altura, os valores e as contagens de instâncias. Na reabertura, porém, o layout era reconstruído a partir das alturas do template e o formulário reflua para duas páginas. O runtime estava certo: o template nunca pediu restauração automática. Declarar no subform raiz conserta o reopen:

<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">
      <!-- fields; scripts podem mudar h ou adicionar instâncias em run time -->
    </subform>
  </subform>
</template>

Se você não é dono do template, não remendeie em volta dele no viewer: um formulário que depende do modo manual espera que os próprios scripts dele reconstruam o estado. Repaginação viva enquanto o usuário digita é um tópico separado, coberto em como o PDFium Component rastreia totais de páginas XFA dinâmicas e fields movidos

Como verificar um save XFA em Delphi?

A única checagem confiável de save XFA é reabrir o arquivo salvo numa instância nova de TPdf e ler os dados armazenados de volta. O TPdf.GetXfaDatasets devolve o packet de datasets como ele está armazenado no documento, não o modelo de dados XFA vivo, então chamá-lo antes de salvar mostra os valores antigos. Depois de reabrir, mostra exatamente o que foi gravado. Um documento de stream único não tem packets nomeados separadamente: o PDFium reporta o XDP inteiro como um packet com nome vazio, então GetXfaPacketByName('datasets') e GetXfaDatasets não devolvem nada, e o fallback lê o stream completo pelo 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 packets
    if Length(Bytes) = 0 then
    begin
      Packets := Pdf.GetXfaFormPackets;   // stream único: um packet 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);  // a saída XDP salva é UTF-8
  finally
    Pdf.Free;
  end;
end;

A rotina de save então commita a edição pendente, confere 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 commita o buffer de edição do field em foco. O TPdf.SetFocusedFormFieldText(const Value: WString): Boolean preenche o field em foco programaticamente, mas depende de um foco que o wrapper rastreia pelo FocusFormField, que percorre widget annotations. Uma página XFA dinâmica normalmente não tem nenhuma, então ali o texto normalmente chega pelo input de teclado no TPdfView, e a função devolve False quando nenhum field 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 field rastreado tem foco
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // commita 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 escaping além de & e < é uma escolha do serializer. Para checagens de produção, carregue o XML reaberto com um parser XML de verdade e compare o nó de texto do elemento de dados vinculado. Rode a checagem duas vezes seguidas também, porque o defeito de newline só mostrou a forma completa dele na segunda geração

Referência rápida: checklist de fidelidade de save XFA

  • Implante 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 o conserto da última escrita esteja em ambas
  • Aponte o LibraryName para um caminho completo e defina EnableV8Engine como True; um caminho ausente 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 field em foco seja commitado
  • Nunca ignore o resultado Boolean do SaveAs; um resultado False deixa o arquivo anterior no lugar
  • Verifique reabrindo num TPdf novo e lendo o GetXfaDatasets, com fallback para o GetXfaFormPackets em XFA single-stream
  • Teste com valores vazios, espaços no início, texto multi-linha, & e um caractere do plano suplementar, através de duas gerações de save
  • Espere falhas de save explícitas para DTDs, XMLDSig e comments dentro dos packets vivos de XFA single-stream
  • Se um formulário dinâmico perde geometria de run time na reabertura, confira o subform raiz por 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 a ABI XFA em Delphi. O runtime V8, o wrapper Delphi e C++Builder e o controle de viewer fazem todos parte do PDFium Component para Delphi e C++Builder, que inclui os dois runtimes Windows para Win32 e Win64