Artigo Técnico

Converter XFA estático em campos AcroForm no Delphi

O PDFium Component converte formulários XFA estáticos em campos AcroForm ordinários a partir do Delphi através do FlattenPdfXfaToAcroForm na unidade FPdfDocumentXfaFlatten. A função lê os pacotes template e datasets do XFA, mapeia cada campo absolutamente posicionado para um campo terminal AcroForm com o seu próprio fluxo de aparência, e anexa o resultado como uma revisão incremental, pelo que o PDF convertido abre como um formulário normal em visualizadores que nunca suportaram XFA

A motivação é normalmente um bilhete de suporte em vez de um item de roteiro. Um formulário governamental ou de seguros foi autorado num designer que o gravou como XFA, funciona num leitor desktop, e em todo o resto abre como uma página em branco que diz que o documento requer um visualizador mais recente. O XFA foi descontinuado no PDF 2.0 (ISO 32000-2), browsers e visualizadores móveis nunca o implementaram, e o próprio PDFium só renderiza XFA com a build habilitada para V8. Formulários XFA estáticos, aqueles cujo layout é fixado em tempo de design, transportam geometria suficiente no template para serem reconstruídos como campos AcroForm, e é exatamente essa fatia que este conversor trata

O que produz afinal o FlattenPdfXfaToAcroForm?

O FlattenPdfXfaToAcroForm produz uma revisão AcroForm ao nível de bytes anexada a uma cópia inalterada da origem, nunca uma reescrita do ficheiro original. O PDFium não expõe API pública para criar campos de formulário, pelo que o componente nem sequer passa pelo PDFium para este trabalho: lê a revisão ativa com o seu próprio leitor de objetos, constrói dicionários de campos, anotações widget, XObjects de aparência e um recurso de fonte /Helv como objetos PDF brutos, e serializa-os através de uma atualização incremental esparsa no sentido da ISO 32000-1 §7.5.6

O novo dicionário /AcroForm transporta /Fields, /DA e /DR e nenhuma entrada /XFA. Quando a origem mantém o seu AcroForm como objeto indireto, esse objeto é reescrito no lugar; um dicionário inline é substituído dentro do catálogo. De qualquer forma a revisão ativa já não aponta para os pacotes XFA, pelo que um visualizador que compreende ambos os modelos abre os campos AcroForm em vez de tentar dispor o formulário XFA. Os bytes XFA originais continuam na revisão anterior, que é o que uma atualização incremental significa, mas nada na revisão atual os referencia

Pipeline de conversão de XFA estático para AcroForm no PDFium Component para Delphi: o FlattenPdfXfaToAcroForm lê os pacotes template e datasets, mapeia campos absolutamente posicionados para dicionários de campo e widgets, e anexa o AcroForm como uma revisão incremental esparsa sem entrada /XFA
O FlattenPdfXfaToAcroForm nunca toca nos bytes originais: os campos convertidos, widgets e fluxos de aparência são anexados como uma revisão cujo /AcroForm simplesmente deixa de apontar para os pacotes XFA
uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentXfaFlatten;

procedure ConvertXfaForm(const SourceFile, TargetFile: string);
var
  Source: TFileStream;
  Options: TPdfXfaFlattenOptions;
  OutputPdf: TBytes;
  Report: TPdfXfaFlattenReport;
  Fields: TPdfXfaFlattenFields;
begin
  Options := TPdfXfaFlattenOptions.Default;
  Options.MaxFields := 1024;   // a predefinição é 512, o intervalo permitido é 1..4096

  Source := TFileStream.Create(SourceFile, fmOpenRead or fmShareDenyWrite);
  try
    if not FlattenPdfXfaToAcroForm(Source, Options, OutputPdf, Report, Fields) then
      raise Exception.Create('XFA conversion failed: ' + Report.ErrorText);
  finally
    Source.Free;
  end;

  TFile.WriteAllBytes(TargetFile, OutputPdf);
  Writeln(Format('%d template fields, %d bound to datasets, %d written on %d pages',
    [Report.TemplateFields, Report.DatasetBound, Report.FieldsWritten, Report.PagesWritten]));
end;

A função devolve False e enche o Report.ErrorText para qualquer entrada rejeitada ou malformada, e restaura a posição do fluxo de origem à saída. O cancelamento é o único caminho de exceção que se propaga: se o Options.CancellationToken disparar, o EPdfOperationCancelled é relançado depois de o relatório ser preenchido. O TPdfXfaFlattenReport também transporta SourceSha256 e OutputSha256, que é cómodo quando um trabalho em lote tem de provar que entrada produziu que ficheiro convertido. O TPdfXfaFlattenOptions.Default define um orçamento de pacote de 8 MiB, 512 campos e um teto de saída de 512 MiB; a origem não pode estar encriptada

Ler os pacotes template e datasets

O PDFium Component aceita ambos os esquemas que a ISO 32000-1 §12.7.8 permite para a entrada /XFA: a forma de matriz que alterna nomes de pacotes e referências de fluxos, e a forma XDP de fluxo único em que um fluxo guarda todo o documento <xdp:xdp>. Os fluxos de pacotes podem ser não comprimidos ou /FlateDecode sem preditor, e a descompressão é delimitada pelo MaxPacketBytes

O caso de fluxo único é onde o tratamento XML ingénuo parte. Os designers costumam escrever <xfa:datasets> com um prefixo de namespace enquanto o template está sem prefixo, pelo que fatiar por nome literal de etiqueta encontra um pacote e perde o outro. O conversor casa nomes de elementos e atributos pelo nome local, depois de uma verificação separada de que as etiquetas de abertura e fecho se emparelham pelo seu nome completo com prefixo. Se só precisa de olhar para os pacotes em vez de os converter, o guia de detetar formulários XFA e extrair os seus pacotes com o PDFium VCL cobre esse caminho só de leitura

Os valores dos campos vêm de dois lugares. Um elemento <value> no template fornece a predefinição, e o pacote datasets fornece os dados preenchidos, vinculados pelo nome do campo. Nomes de campos repetem-se em formulários reais, pelo que o vinculador compara a cadeia de ancestrais <subform> nomeados com o caminho de ancestrais nos dados e toma a correspondência mais longa sem conflito. Quando dois campos folha partilham um nome sob subforms diferentes, o /T AcroForm torna-se SubformPath.Name para que os dois campos permaneçam distintos após a conversão

Por que é que o choiceList precisa de uma flag Combo explícita?

O XFA e o PDF têm predefinições opostas para campos de escolha, pelo que um conversor que não copie flags transforma todos os menus suspensos em caixas de lista. Um <choiceList> XFA com o open="userControl" predefinido ou com open="onEntry" é um menu suspenso; um campo /FT /Ch PDF sem flags é uma caixa de lista com deslocação, e só a flag Combo (posição de bit 18 na Tabela 230 da ISO 32000-1, valor 131072, o mesmo bit que o PDFium chama FPDF_FORMFLAG_CHOICE_COMBO) a torna uma combo box. O conversor por isso define a flag Combo para os casos de menu suspenso e deixa as flags vazias só quando o template diz open="always" ou open="multiSelect", que mantêm a lista permanentemente aberta. Builds anteriores à 3.181.1 trocavam os valores Combo e Pushbutton e procuravam um open="on" não padrão, pelo que formulários achatados com elas valem a pena ser reprocessados

Decisão de flags de campo de escolha na conversão de XFA estático para AcroForm do PDFium Component: um atributo open de userControl ou onEntry define a flag Combo, bit 18 valor 131072, para produzir uma combo box de menu suspenso, enquanto open always ou multiSelect deixa as flags vazias para uma caixa de lista permanentemente aberta
Sem a flag Combo um menu suspenso XFA achata-se numa caixa de lista com deslocação, pelo que o conversor lê o atributo open e define o bit 18 só para os casos de menu suspenso

O resto do mapeamento de interface segue as tabelas de flags de campo na ISO 32000-1 §12.7.4:

  • textEdit, numericEdit e dateTimeEdit tornam-se /FT /Tx; um campo de texto multilinha recebe a flag Multiline (/Ff 4096, Tabela 228) e um fluxo de aparência dividido por linhas
  • passwordEdit torna-se /FT /Tx com a flag Password (/Ff 8192) e uma aparência que desenha um asterisco por escalar Unicode, não por unidade de código UTF-16
  • checkButton torna-se uma caixa de verificação /FT /Btn cujo valor é /Yes quando os dados vinculados são 1 e /Off caso contrário
  • <exclGroup> torna-se um pai de rádio /FT /Btn /Ff 32768 (Tabela 226) com /V, /DV, /Opt e /Kids, mais um widget filho por membro com o seu próprio /AS e /AP de dois estados
  • imageEdit torna-se um pushbutton cuja aparência pinta a imagem; um JPEG de linha de base é incorporado à letra sob /DCTDecode, enquanto PNG é descodificado e recodificado como píxeis /FlateDecode com um /SMask extra quando o PNG transporta transparência alfa ou tRNS

Valores que não sejam ASCII imprimível simples, incluindo quebras de linha, são escritos como strings <FEFF...> UTF-16BE, pelo que um nome japonês ou um endereço de duas linhas sobrevive à viagem. A saída Fields espelha o que foi escrito, o que a torna um rasto de auditoria cómodo

procedure DumpConvertedFields(const Fields: TPdfXfaFlattenFields);
const
  KindNames: array[TPdfXfaFlattenFieldKind] of string = (
    'text', 'check', 'choice', 'image', 'numeric', 'dateTime', 'password', 'radio');
var
  I: Integer;
  F: TPdfXfaFlattenField;
begin
  for I := 0 to High(Fields) do
  begin
    F := Fields[I];
    Writeln(Format('%s.%s [%s] page %d at %.1f,%.1f size %.1f x %.1f',
      [F.SubformPath, F.Name, KindNames[F.Kind], F.PageIndex + 1, F.X, F.Y, F.W, F.H]));
    case F.Kind of
      fxfkChoice:
        Writeln(Format('  %d items, list box: %s', [Length(F.Choices), BoolToStr(F.ListBox, True)]));
      fxfkRadio:
        Writeln(Format('  %d radio widgets, value "%s"', [Length(F.RadioWidgets), F.Value]));
      fxfkImage:
        Writeln(Format('  %d image bytes', [Length(F.ImageData)]));
    else
      Writeln('  value "' + F.Value + '"');
    end;
  end;
end;

Geometria, unidades de medida e encaminhamento de páginas

A geometria XFA é convertida para o espaço de utilizador PDF virando cada campo contra a altura MediaBox da sua própria página, porque o XFA mede para baixo a partir do topo da página e o PDF mede para cima a partir do fundo. Deslocamentos de elementos <subform> aninhados acumulam-se a caminhar para baixo no template, e todos os campos têm de acabar com largura e altura positivas ou a conversão é rejeitada

Conversão de geometria XFA para PDF no PDFium Component para Delphi: um retângulo de campo medido para baixo a partir da aresta superior no template XFA é virado contra a altura MediaBox da sua própria página para o espaço de utilizador PDF, onde a coordenada y cresce para cima a partir do fundo
O template XFA descendente e o espaço de utilizador PDF ascendente encontram-se numa única subtração contra a altura MediaBox, que é o que coloca cada widget convertido na sua posição desenhada

As medidas aceitam as unidades que designers reais emitem: números simples e pt, mais in (72 pontos), mm (72 / 25.4), cm (72 / 2.54), pc (12), px (0.75) e mp (0.001, milipontos). A análise é independente de locale, pelo que uma instalação Windows alemã não transforma 8.5in em disparates. Um sufixo de unidade desconhecido é rejeitado em vez de ser lido como pontos, o que deslocaria silenciosamente o campo

O encaminhamento de páginas segue o <pageSet>: elementos <pageArea> irmãos são numerados a partir de um pela ordem de declaração e reclamam os campos aninhados sob eles, enquanto campos sem ancestral <pageArea> ficam na página um. Cada página recebe a sua própria atualização de /Annots. Um campo encaminhado para além da última página do PDF é um erro em vez de um widget largado na página um

Que formulários XFA o conversor recusa?

O conversor recusa tudo o que tenha um layout que não possa ser conhecido sem correr o motor de layout XFA. Isso significa um atributo layout diferente de position num <subform> ou <exclGroup> (fluxo tb, lr-tb e amigos, que é com que os formulários dinâmicos são construídos), qualquer elemento <script>, e qualquer tipo de interface fora dos sete listados acima. Formulários dinâmicos que crescem páginas em tempo de execução pertencem a uma cadeia de ferramentas diferente; o artigo sobre repaginação XFA dinâmica e contagens de páginas no PDFium Component mostra o que esse layout em vivo envolve

A recusa é deliberada. Um subform de fluxo não tem coordenadas fixas, pelo que qualquer "conversão" teria de inventar posições, e um formulário com scripts frequentemente calcula totais ou alterna visibilidade que um AcroForm estático não consegue reproduzir. Produzir um PDF que parece completo mas perdeu silenciosamente um total calculado é pior do que um erro claro no Report.ErrorText. A especificação XFA 3.3 define muito mais do que este subconjunto, e o conversor não finge o contrário

Armadilhas ao escrever e verificar a revisão AcroForm

Dois problemas apareceram ao construir o escritor, e ambos se aplicam a quem produza atualizações incrementais à mão. O primeiro: o catálogo tem de fazer sempre parte do conjunto de atualização, mesmo quando os seus bytes não mudam porque o AcroForm é um objeto indireto reescrito noutro lugar. O escritor de atualização esparsa deriva o /Root do trailer dos objetos que transporta, e uma atualização sem o objeto raiz produz uma revisão que os visualizadores não conseguem ancorar

O segundo diz respeito à verificação. Carregar o ficheiro convertido através do invólucro PDFium despoleta a inicialização do ambiente de formulários assim que o documento tem um /AcroForm, e o pdfium.dll anfitrião na configuração de teste não tinha a exportação FORM_GetTextDirection, pelo que a inicialização falhava antes de um único campo poder ser conferido. As verificações de releitura usam portanto o leitor de objetos do componente, que analisa a revisão ativa sem tocar na camada de formulários

uses
  System.SysUtils, System.Classes, FPdfCompress;

function HasActiveAcroForm(const Pdf: TBytes): Boolean;
var
  Stream: TBytesStream;
  Objects: TPdfActiveObjects;
  Revision: TPdfSparseRevisionInfo;
  ErrorText: string;
begin
  Stream := TBytesStream.Create(Pdf);
  try
    if not ReadPdfActiveObjects(Stream, TPdfActiveObjectReadOptions.Default,
      Objects, Revision, ErrorText) then
      raise Exception.Create(ErrorText);
    // o catálogo ativo tem de referenciar um AcroForm; um AcroForm inline
    // escrito pelo conversor não transporta entrada /XFA
    Result := Pos('/AcroForm', string(Revision.CatalogDictionary)) > 0;
  finally
    Stream.Free;
  end;
end;

Os fluxos de aparência são deliberadamente simples: Helvetica a 10 pontos para texto e valores de escolha, um quadrado traçado para caixas de verificação, um quadrado interior preenchido para o estado selecionado do rádio. Tornam o formulário convertido legível em todo o lado, mas não são uma cópia ao píxel da renderização XFA original, pelo que fontes, cores e bordas definidas no template não são transportadas. Se um formulário tem de parecer idêntico à saída do designer, renderize-o uma vez com a build XFA e compare antes de mudar um pipeline de produção

FAQ

O FlattenPdfXfaToAcroForm precisa da build V8 ou XFA do PDFium?

Não. O FlattenPdfXfaToAcroForm analisa os pacotes e escreve a revisão com código do PDFium Component, pelo que funciona com o pdfium.dll padrão e não executa qualquer script XFA

O conversor consegue tratar documentos XFA encriptados?

Não diretamente. Uma origem encriptada é rejeitada com um erro, por isso desencripte o documento primeiro e converta a cópia desencriptada

Os campos convertidos continuam editáveis?

Sim. A saída contém campos AcroForm reais de texto, caixa de verificação, rádio, escolha e pushbutton com valores e fluxos de aparência, pelo que os utilizadores podem continuar a preencher o formulário em qualquer visualizador com suporte a AcroForm

Conversão de XFA estático, extração de pacotes XFA e o runtime XFA habilitado para V8 fazem todos parte do PDFium Component for Delphi and C++Builder, que embrulha o PDFium para projetos Delphi, C++Builder e Lazarus