Articolo tecnico

Convertire moduli XFA statici in campi AcroForm in Delphi

PDFium Component converte i moduli XFA statici in normali campi AcroForm da Delphi attraverso FlattenPdfXfaToAcroForm nell'unità FPdfDocumentXfaFlatten. La funzione legge i pacchetti template e datasets dell'XFA, mappa ogni campo posizionato assolutamente in un campo terminale AcroForm con il proprio appearance stream, e accoda il risultato come revisione incrementale, così il PDF convertito si apre come un normale modulo in viewer che non hanno mai supportato XFA

La motivazione di solito è un ticket di supporto piuttosto che una voce di roadmap. Un modulo di un ente pubblico o di un'assicurazione è stato scritto in un designer che l'ha salvato come XFA, funziona in un reader desktop, e ovunque altro si apre come pagina bianca che dice che il documento richiede un viewer più recente. XFA è stata dichiarata deprecata in PDF 2.0 (ISO 32000-2), i browser e i viewer mobile non l'hanno mai implementata, e lo stesso PDFium renderizza XFA solo con la build abilitata V8. I moduli XFA statici, quelli il cui layout è fisso in fase di progettazione, portano abbastanza geometria nel template per essere ricostruiti come campi AcroForm, e quella è esattamente la fetta che questo convertitore gestisce

Cosa produce davvero FlattenPdfXfaToAcroForm?

FlattenPdfXfaToAcroForm produce una revisione AcroForm a livello di byte accodata a una copia invariata della sorgente, mai una riscrittura del file originale. PDFium non espone alcuna API pubblica per creare campi modulo, quindi il componente non passa affatto per PDFium per questo lavoro: legge la revisione attiva con il proprio reader di oggetti, costruisce dizionari di campo, annotazioni widget, appearance XObject e una risorsa font /Helv come oggetti PDF grezzi, e li serializza attraverso un aggiornamento incrementale sparso nel senso di ISO 32000-1 §7.5.6

Il nuovo dizionario /AcroForm porta /Fields, /DA e /DR e nessuna voce /XFA. Quando la sorgente conserva il proprio AcroForm come oggetto indiretto, quell'oggetto viene riscritto sul posto; un dizionario inline viene sostituito dentro il catalogo. In entrambi i casi la revisione attiva non punta più ai pacchetti XFA, quindi un viewer che capisce entrambi i modelli apre i campi AcroForm invece di provare a impaginare il modulo XFA. I byte XFA originali sono ancora nella revisione precedente, che è ciò che significa un aggiornamento incrementale, ma nulla nella revisione corrente li referenzia

Pipeline di conversione da XFA statico ad AcroForm in PDFium Component per Delphi: FlattenPdfXfaToAcroForm legge i pacchetti template e datasets, mappa i campi posizionati assolutamente in dizionari di campo e widget, e accoda l'AcroForm come revisione incrementale sparsa senza una voce /XFA
FlattenPdfXfaToAcroForm non tocca mai i byte originali: i campi convertiti, i widget e gli appearance stream vengono accodati come una revisione il cui /AcroForm semplicemente smette di puntare ai pacchetti 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;   // il predefinito è 512, l'intervallo consentito è 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;

La funzione restituisce False e riempie Report.ErrorText per qualsiasi input rifiutato o malformato, e ripristina la posizione dello stream sorgente all'uscita. La cancellazione è l'unico percorso di eccezione che si propaga: se Options.CancellationToken scatta, EPdfOperationCancelled viene rilanciata dopo che il report è stato riempito. TPdfXfaFlattenReport porta anche SourceSha256 e OutputSha256, comodo quando un job batch deve dimostrare quale input ha prodotto quale file convertito. TPdfXfaFlattenOptions.Default imposta un budget pacchetti di 8 MiB, 512 campi e un tetto di output di 512 MiB; la sorgente non deve essere cifrata

Leggere i pacchetti template e datasets

PDFium Component accetta entrambi i layout che ISO 32000-1 §12.7.8 consente per la voce /XFA: la forma array che alterna nomi di pacchetto e riferimenti a stream, e la forma XDP a stream singolo dove uno stream contiene l'intero documento <xdp:xdp>. Gli stream dei pacchetti possono essere non compressi o /FlateDecode senza predittore, e la decompressione è limitata da MaxPacketBytes

Il caso a stream singolo è dove la gestione XML ingenua si rompe. I designer scrivono comunemente <xfa:datasets> con un prefisso di namespace mentre il template sta senza prefisso, quindi affettare per nome di tag letterale trova un pacchetto e perde l'altro. Il convertitore abbina nomi di elementi e attributi per local name, dopo un controllo separato che i tag di apertura e chiusura si appaiino per il loro nome completo con prefisso. Se vi serve solo guardare i pacchetti invece che convertirli, la guida al rilevare moduli XFA ed estrarre i loro pacchetti con PDFium VCL copre quel percorso in sola lettura

I valori dei campi vengono da due posti. Un elemento <value> nel template fornisce il predefinito, e il pacchetto datasets fornisce i dati compilati, legati per nome di campo. I nomi di campo si ripetono nei moduli reali, quindi il binder confronta la catena dei progenitori <subform> nominati con il percorso dei progenitori nei dati e prende la corrispondenza non in conflitto più lunga. Quando due campi foglia condividono un nome sotto subform diverse, il /T AcroForm diventa SubformPath.Name così i due campi restano distinti dopo la conversione

Perché choiceList ha bisogno di un flag Combo esplicito?

XFA e PDF hanno valori predefiniti opposti per i campi di scelta, quindi un convertitore che non copia flag trasforma ogni menu a tendina in una casella di riepilogo. Un <choiceList> XFA con il predefinito open="userControl" o con open="onEntry" è un menu a tendina; un campo PDF /FT /Ch senza flag è una casella di riepilogo scorrevole, e solo il flag Combo (posizione di bit 18 in ISO 32000-1 Tabella 230, valore 131072, lo stesso bit che PDFium nomina FPDF_FORMFLAG_CHOICE_COMBO) lo rende una casella combinata. Il convertitore quindi imposta il flag Combo per i casi a tendina e lascia i flag vuoti solo quando il template dice open="always" o open="multiSelect", che tengono l'elenco permanentemente aperto. Le build prima della 3.181.1 scambiavano i valori Combo e Pushbutton e cercavano un non standard open="on", quindi i moduli appiattiti con loro meritano una nuova esecuzione

Decisione dei flag del campo di scelta nella conversione da XFA statico ad AcroForm di PDFium Component: un attributo open pari a userControl o onEntry imposta il flag Combo, bit 18 valore 131072, per produrre una casella combinata a tendina, mentre open always o multiSelect lascia i flag vuoti per una casella di riepilogo permanentemente aperta
Senza il flag Combo un menu a tendina XFA si appiattisce in una casella di riepilogo scorrevole, quindi il convertitore legge l'attributo open e imposta il bit 18 solo per i casi a tendina

Il resto della mappatura della UI segue le tabelle dei flag di campo in ISO 32000-1 §12.7.4:

  • textEdit, numericEdit e dateTimeEdit diventano /FT /Tx; un campo di testo multilinea riceve il flag Multiline (/Ff 4096, Tabella 228) e un appearance stream con righe divise
  • passwordEdit diventa /FT /Tx con il flag Password (/Ff 8192) e un aspetto che disegna un asterisco per scalare Unicode, non per unità di codice UTF-16
  • checkButton diventa una casella di controllo /FT /Btn il cui valore è /Yes quando i dati legati sono 1 e /Off altrimenti
  • <exclGroup> diventa un genitore radio /FT /Btn /Ff 32768 (Tabella 226) con /V, /DV, /Opt e /Kids, più un widget figlio per membro con il proprio /AS e /AP a due stati
  • imageEdit diventa un pushbutton il cui aspetto dipinge l'immagine; un JPEG baseline viene incorporato alla lettera sotto /DCTDecode, mentre il PNG viene decodificato e ricodificato come pixel /FlateDecode con un /SMask extra quando il PNG porta trasparenza alpha o tRNS

I valori che non sono ASCII stampabile semplice, interruzioni di riga comprese, vengono scritti come stringhe <FEFF...> UTF-16BE, così un nome giapponese o un indirizzo su due righe sopravvive al viaggio. L'output Fields rispecchia ciò che è stato scritto, il che ne fa una traccia di audit comoda

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, unità di misura e instradamento delle pagine

La geometria XFA viene convertita nello spazio utente PDF capovolgendo ogni campo contro l'altezza del MediaBox della propria pagina, perché XFA misura verso il basso dalla cima della pagina e il PDF misura verso l'alto dal fondo. Gli offset dagli elementi <subform> annidati si accumulano scendendo lungo il template, e ogni campo deve finire con larghezza e altezza positive o la conversione viene rifiutata

Conversione di geometria da XFA a PDF in PDFium Component per Delphi: un rettangolo di campo misurato verso il basso dal bordo superiore nel template XFA viene capovolto contro l'altezza del MediaBox della propria pagina nello spazio utente PDF, dove la coordinata y cresce verso l'alto dal fondo
Il template XFA verso il basso e lo spazio utente PDF verso l'alto si incontrano in una sottrazione contro l'altezza del MediaBox, che è ciò che colloca ogni widget convertito alla posizione progettata

Le misure accettano le unità che i designer veri emettono: numeri nudi e pt, più in (72 punti), mm (72 / 25.4), cm (72 / 2.54), pc (12), px (0.75) e mp (0.001, millipunti). Il parsing è indipendente dal locale, così un'installazione Windows tedesca non trasforma 8.5in in insensatezza. Un suffisso di unità sconosciuto viene rifiutato invece di essere letto come punti, il che sposterebbe silenziosamente il campo

L'instradamento delle pagine segue il <pageSet>: gli elementi <pageArea> fratelli si numerano da uno in ordine di dichiarazione e rivendicano i campi annidati sotto di loro, mentre i campi senza progenitore <pageArea> restano a pagina uno. Ogni pagina riceve il proprio aggiornamento /Annots. Un campo instradato oltre l'ultima pagina del PDF è un errore invece che un widget abbandonato a pagina uno

Quali moduli XFA rifiuta il convertitore?

Il convertitore rifiuta qualsiasi cosa il cui layout non possa essere noto senza eseguire il motore di layout XFA. Ciò significa un attributo layout diverso da position su un <subform> o <exclGroup> (flowed tb, lr-tb e compagnia, che è ciò da cui sono costruiti i moduli dinamici), qualunque elemento <script>, e qualunque tipo di UI fuori dai sette elencati sopra. I moduli dinamici che crescono di pagine a runtime appartengono a una toolchain diversa; l'articolo sulla ripaginazione XFA dinamica e i conteggi di pagina in PDFium Component mostra cosa comporta quel layout dal vivo

Il rifiuto è deliberato. Una subform flowed non ha coordinate fisse, quindi qualunque "conversione" dovrebbe inventare posizioni, e un modulo con script spesso calcola totali o commuta visibilità che un AcroForm statico non può riprodurre. Produrre un PDF che sembra completo ma ha perso silenziosamente un totale calcolato è peggiore di un errore chiaro in Report.ErrorText. La specifica XFA 3.3 definisce molto più di questo sottoinsieme, e il convertitore non finge il contrario

Trappole nello scrivere e verificare la revisione AcroForm

Due problemi sono emersi costruendo il writer, ed entrambi riguardano chiunque produca aggiornamenti incrementali a mano. Il primo: il catalogo deve sempre far parte dell'insieme di aggiornamento, anche quando i suoi byte non cambiano perché l'AcroForm è un oggetto indiretto riscritto altrove. Il writer di aggiornamenti sparsi deriva il /Root del trailer dagli oggetti che trasporta, e un aggiornamento senza l'oggetto radice produce una revisione che i viewer non riescono ad ancorare

Il secondo riguarda la verifica. Caricare il file convertito attraverso il wrapper PDFium innesca l'inizializzazione dell'ambiente dei moduli non appena il documento ha un /AcroForm, e il pdfium.dll ospite nel setup di test era privo dell'export FORM_GetTextDirection, quindi l'inizializzazione falliva prima che un solo campo potesse essere controllato. I controlli di rilettura usano perciò il reader di oggetti del componente, che analizza la revisione attiva senza toccare il layer dei moduli

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);
    // il catalogo attivo deve referenziare un AcroForm; un AcroForm inline
    // scritto dal convertitore non porta alcuna voce /XFA
    Result := Pos('/AcroForm', string(Revision.CatalogDictionary)) > 0;
  finally
    Stream.Free;
  end;
end;

Gli appearance stream sono volutamente semplici: Helvetica a 10 punti per testo e valori di scelta, un quadrato con contorno per le caselle di controllo, un quadrato interno pieno per lo stato radio selezionato. Rendono il modulo convertito leggibile ovunque, ma non sono una copia al pixel del rendering XFA originale, quindi font, colori e bordi definiti nel template non vengono riportati. Se un modulo deve apparire identico all'output del designer, renderizzatelo una volta con la build XFA e confrontate prima di cambiare una pipeline di produzione

FAQ

FlattenPdfXfaToAcroForm richiede la build V8 o XFA di PDFium?

No. FlattenPdfXfaToAcroForm analizza i pacchetti e scrive la revisione con codice PDFium Component, quindi funziona con lo standard pdfium.dll e non esegue alcuno script XFA

Il convertitore gestisce documenti XFA cifrati?

Non direttamente. Una sorgente cifrata viene rifiutata con un errore, quindi decifrate prima il documento e convertite la copia decifrata

I campi convertiti restano modificabili?

Sì. L'output contiene veri campi AcroForm di testo, caselle di controllo, radio, scelta e pushbutton con valori e appearance stream, così gli utenti possono continuare a compilare il modulo in qualunque viewer con capacità AcroForm

La conversione XFA statica, l'estrazione dei pacchetti XFA e il runtime XFA abilitato V8 fanno tutti parte del PDFium Component per Delphi e C++Builder, che incapsula PDFium per progetti Delphi, C++Builder e Lazarus