Articolo tecnico

Salvataggio XFA in PDFium Component: newline e restoreState

PDFium Component salva i valori modificati dei form XFA esattamente, attraverso salvataggio e riapertura, quando gira con il runtime Windows V8 pdfium.v8.dll spedito dalla v3.125.2 in poi. I runtime più vecchi aggiungevano line feed ai valori dei campi, riducevano le emoji a un carattere BMP imparentato, saltavano in silenzio i salvataggi XFA a stream singolo e potevano ingoiare una scrittura finale fallita. Un sintomo di riapertura non è per niente un difetto della libreria: un form dinamico il cui subform radice manca di restoreState="auto" ricostruisce il proprio layout dal template

Le segnalazioni di bug su tutto questo sembravano tutte uguali. Un cliente compila un form XFA di richiesta in un viewer Delphi, salva, riapre, e qualcosa è leggermente storto. Una casella commenti vuota ora contiene una riga vuota, e dopo un secondo salvataggio ne contiene due. Un nome digitato con una emoji torna con un glifo a uso privato. Nessuno riceve un errore, e questo è ciò che rende questi bug costosi: la divergenza si mostra settimane dopo nell'esportazione di qualcun altro

Che cosa va storto quando un form XFA viene salvato e riaperto?

Quattro difetti separati nel percorso nativo di salvataggio XFA causavano divergenze di valore, e ognuno si nascondeva dietro a un salvataggio dall'aria riuscita. Due venivano dalla serializzazione, uno dal layout di memorizzazione a stream singolo, e uno dal writer PDF stesso. La tabella mappa ogni sintomo sulla sua causa e sulla release in cui PDFium Component l'ha sistemato

Sintomo dopo la riaperturaCausaSistemato in
Il campo vuoto contiene un line feed; i valori guadagnano una newline per salvataggioEntrambi i writer XFA inserivano newline di layout dopo i tag di aperturav3.125.2, pdfium.v8.dll
U+1F642 torna come U+F642, oppure l'emoji sparisce dal pacchetto formTroncamento wchar_t a 16 bit in decodifica; filtraggio dei surrogate nel serializzatore del formv3.125.2, pdfium.v8.dll
Le modifiche in un documento XFA a stream singolo sono semplicemente spariteIl salvataggio nativo rifiutava il layout a stream, ma il valore di ritorno veniva ignoratov3.125.2; commenti e istruzioni di elaborazione conservati dalla v3.126.0
File troncato benché il salvataggio avesse riportato successoLa scrittura finale bufferizzata è fallita dopo che il writer aveva già riportato successov3.125.2 runtime V8; v3.125.3 pdfium.dll ordinario
Un form dinamico di tre pagine si riapre come due pagineIl subform radice non richiede restoreState="auto"Scrittura del form, non un difetto della libreria

Scritti precedenti concludevano che le modifiche ai campi XFA non si potessero affatto conservare con PDFium, il che era accurato per i runtime di allora. Il runtime V8 più recente salva i valori XFA nativamente, quindi una modifica fatta nel form vivo arriva al pacchetto datasets salvato senza chirurgia sui pacchetti da parte tua

Quale runtime PDFium salva i valori XFA?

La fedeltà del salvataggio XFA dipende dalla DLL nativa, non dal wrapper Delphi, quindi il primo controllo è quale runtime il tuo processo ha realmente caricato. PDFium Component spedisce due build Windows per architettura: la pdfium.dll ordinaria, compilata senza V8 e senza XFA, e la pdfium.v8.dll, che porta il motore JavaScript e il runtime dei form XFA. Solo la pdfium.v8.dll può far girare un form XFA, quindi ogni correzione XFA descritta qui vive lì, a cominciare dalle librerie V8 Win32 e Win64 ricompilate nella v3.125.2

La correzione della scrittura finale è codice generico del writer PDF, quindi conta anche per i documenti ordinari. La v3.125.3 ha ricompilato le librerie pdfium.dll ordinarie per portare quella stessa riparazione. Sorgente condiviso non è prova di comportamento condiviso: finché il binario non viene ricompilato, la vecchia DLL conserva il vecchio bug

Una seconda trappola stava nel loader. Prima della v3.125.2, impostare EnableV8Engine a True faceva sì che il binding prendesse il nome predefinito pdfium.v8.dll e ignorasse un percorso completo in LibraryName. Un'applicazione che puntava a un runtime appena distribuito poteva continuare a caricare una copia più vecchia da un'altra cartella. Dalla v3.125.2, un LibraryName che contiene una directory seleziona esattamente quel file in entrambe le modalità motore, e un percorso mancante fallisce invece di ricadere su un'altra libreria inclusa

uses
  System.SysUtils, PDFium;

procedure SelectXfaRuntime;
begin
  // Una directory in LibraryName blocca questo file esatto (v3.125.2 e successive);
  // se il file manca, il caricamento solleva invece di ripiegare altrove
{$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;  // fallisci all'avvio, non al primo salvataggio
end;

Dopo l'apertura di un documento, TPdf.XFA ti dice che il file contiene XFA e TPdf.XfaRuntimeAvailable ti dice che la DLL caricata può davvero eseguirlo. Se ti serve anche distinguere i form statici dai dinamici, TPdf.FormType restituisce ftXfaFull o ftXfaForeground; l'articolo su rilevare i form XFA ed estrarre i pacchetti XFA in Delphi copre quella esplorazione nel dettaglio

Perché i campi XFA salvati guadagnano line feed extra?

I campi XFA salvati guadagnavano line feed perché entrambi i writer XFA nativi, il writer XML generico di elementi e il serializzatore del pacchetto form, facevano il pretty-print del proprio output con una newline dopo i tag di apertura. Nella maggior parte dell'XML quello spazio è cosmetico. Nei dati XFA no: quando il pacchetto datasets viene parseato di nuovo, il testo tra <Comments> e </Comments> è il valore del campo, newline compresa. Un campo vuoto quindi si riapriva contenente un singolo LF, e ogni ulteriore ciclo salva-e-riapri poteva aggiungerne un altro

Diagramma del ciclo di salvataggio XFA PDFium Component in cui il writer aggiunge una newline dopo i tag di apertura, il parser alla riapertura legge l'LF tra i tag Comments come valore del campo, e ogni salvataggio successivo appende un altro line feed finché la v3.125.2 non rimuove solo lo spazio sintetizzato dal serializzatore
Un ciclo salva-riapri pianta il primo line feed e ogni round ulteriore ne aggiunge un altro, ecco perché la divergenza mostrava la sua forma completa solo alla seconda generazione

La riparazione ovvia, tagliare i valori al caricamento, sarebbe sbagliata. Gli utenti digitano spazi iniziali, spazi finali e testo multi-riga deliberato nei campi XFA, e un blocco indirizzo o un codice a larghezza fissa deve sopravvivere byte per byte. La correzione v3.125.2 quindi rimuove solo lo spazio che il serializzatore stesso ha sintetizzato attorno ai tag. I valori utente, i nodi di testo esistenti e le sezioni CDATA passano intoccati, così " indented" resta indentato e un campo volutamente vuoto resta vuoto

Perché un'emoji torna come un carattere diverso?

Un'emoji tornava sbagliata perché su Windows wchar_t è largo 16 bit, e due percorsi di decodifica memorizzavano un intero valore scalare Unicode in un singolo wchar_t. Lo facevano il decoder di stream UTF-8 e il parser dei riferimenti a caratteri numerici come &#x1F642;. U+1F642, la faccina sorridente, non sta in 16 bit, così i bit alti cadevano e al loro posto compariva U+F642: un code point nella Private Use Area che la maggior parte dei font renderizza come un riquadro o niente

Il serializzatore del form aveva il problema opposto. Filtrava i caratteri un wchar_t alla volta, vedeva due code unit surrogate non valide in isolamento, e le buttava entrambe, così l'emoji spariva dal pacchetto form del tutto. Nella v3.125.2 il decoder consuma ogni valore scalare completamente ed emette una coppia surrogate appropriata. Quando resta un solo slot di uscita, tiene la surrogate bassa in sospeso e non riporta fine-stream finché quell'unità è ancora bufferizzata. Una sequenza UTF-8 spezzata tra blocchi di lettura viene riportata alla lettura successiva anziché scartata. L'esportatore del form ora tiene insieme le coppie surrogate valide, e i riferimenti a caratteri numerici producono coppie corrette a loro volta

Diagramma della gestione surrogate PDFium Component in cui U+1F642 arriva come coppia UTF-16 D83D DE42 e due percorsi difettosi lo corrompono: i decoder wchar_t a 16 bit troncano lo scalare a U+F642 nella private use area, mentre il serializzatore del form filtra le surrogate isolate e fa sparire del tutto l'emoji
Il wchar_t di Windows è largo 16 bit, così uno scalare che richiede una coppia surrogate o perdeva la sua metà alta o spariva dal pacchetto finché entrambi i percorsi non hanno imparato a tenere insieme le coppie

Dati di test Latin-1 non mostrano mai nulla di tutto ciò, quindi ogni test di round trip XFA ha bisogno di almeno un carattere del piano supplementare

XFA a stream singolo e fallimenti di salvataggio che nessuno vedeva

Un documento XFA a stream singolo perdeva le proprie modifiche perché l'helper di salvataggio nativo rifiutava quel layout di memorizzazione e il suo chiamante ignorava il fallimento. ISO 32000-1 §12.7.8 consente che l'entry /XFA del dizionario del form interattivo sia o un array di nomi di pacchetti e stream, o un singolo stream che contiene l'intero documento XDP. Gli array di pacchetti sono il caso comune, ma gli stream singoli sono perfettamente legali, e il salvataggio PDF si completava come se niente fosse mentre i dati del form restavano ai loro vecchi valori

Dalla v3.125.2, il runtime V8 gestisce il sottoinsieme supportato degli stream singoli. Per prima cosa esporta entrambi i pacchetti vivi, datasets e form, in un'area di staging e li valida, e solo allora sostituisce i pacchetti corrispondenti nell'XDP originale. Gli altri pacchetti e le dichiarazioni di namespace della radice vengono conservati. Se lo staging fallisce, lo stream XFA persistente non viene mai toccato e il documento conserva il suo marchio di modifica

Commenti XML e istruzioni di elaborazione richiedevano un'attenzione extra perché il DOM XML interno li scarta. Nella v3.125.2 la loro presenza faceva fallire il salvataggio sul posto anziché perdere contenuto in silenzio. La v3.126.0 li conserva: prima del parsing, ogni commento o istruzione di elaborazione viene scambiato con un marker costruito da un prefisso che non compare in nessun punto del testo originale. Dopo che i pacchetti vivi sono stati sostituiti, ogni marker deve comparire esattamente una volta prima che il token originale venga restituito e lo stream scritto. I token fuori dai pacchetti sostituiti quindi conservano testo e ordine, compresi i token nel prologo, nel template e negli altri pacchetti

Alcuni input vengono comunque rifiutati di proposito, e ogni rifiuto è un fallimento di salvataggio esplicito:

  • Commenti o istruzioni di elaborazione dentro i pacchetti vivi datasets o form, dato che le loro posizioni originali non si possono mappare su contenuto appena esportato
  • Dichiarazioni DTD e firme XMLDSig, dato che riscrivere l'XDP non può mantenere valida una firma XML
  • Codifica UTF-8 o UTF-16 non valida, tag incompleti, riferimenti a caratteri non validi, entità sconosciute e istruzioni di elaborazione malformate, che vengono rifiutate anziché riparate in silenzio
Pipeline di salvataggio XFA a stream singolo PDFium Component in cui i pacchetti vivi datasets e form vengono esportati in staging, validati, poi sostituiti nell'XDP originale con i commenti conservati tramite marker, mentre i fallimenti di staging e input come DTD o XMLDSig rifiutano il salvataggio esplicitamente
L'esportazione in staging viene validata prima che qualsiasi cosa venga sostituita, così un salvataggio fallito lascia intatto lo stream XFA persistente e il documento conserva il suo marchio di modifica

L'output a stream singolo è UTF-8 e conserva il modello di contenuto XML, non il layout di byte originale né la dichiarazione di codifica

L'ultimo difetto stava sotto l'XFA. Il writer di file nativo bufferizza l'output in blocchi da 32 KB e scaricava l'ultimo blocco parziale solo nel suo distruttore, dopo che il writer del documento aveva già riportato successo. Un disco pieno o un errore I/O su quell'ultimo blocco era invisibile al chiamante. Dalla v3.125.2 nel runtime V8 e dalla v3.125.3 nel runtime ordinario, quel flush finale fa parte del risultato del salvataggio, e il marchio di modifica XFA viene pulito solo dopo un successo vero. Sul lato Delphi, TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean scrive su un file temporaneo accanto al bersaglio e lo sposta al suo posto solo quando il salvataggio restituisce True, così un salvataggio fallito lascia intatto il file precedente

Perché un form XFA dinamico si riapre con meno pagine?

Un form XFA dinamico si riapre con meno pagine quando il suo subform radice non dichiara restoreState="auto", e quella è una decisione di scrittura del form anziché un difetto di PDFium Component. In XFA 3.3, restoreState sul subform radice ha default manual. Sotto manual, il processore XFA ripristina solo uno stato limitato dal pacchetto form salvato e lascia il resto agli script dell'autore. I valori dei campi salvati e i conteggi di istanze dei subform ripetuti tornano comunque, ma le proprietà geometriche impostate a runtime no

Il caso che ha smascherato questo era un form di tre pagine il cui script cresceva un subform a h="450pt". Il pacchetto form salvato conteneva la nuova altezza, i valori e i conteggi di istanze. Alla riapertura, però, il layout veniva ricostruito dalle altezze del template e il form riscorreva su due pagine. Il runtime aveva ragione: il template non aveva mai chiesto il ripristino automatico. Dichiararlo sul subform radice sistema la riapertura:

<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">
      <!-- campi; gli script possono cambiare h o aggiungere istanze a runtime -->
    </subform>
  </subform>
</template>

Se il template non è tuo, non fare patch intorno ad esso nel viewer: un form che conta sulla modalità manual si aspetta che siano i suoi script a ricostruire lo stato. La ripaginazione in vivo mentre l'utente digita è un argomento a parte, coperto in come PDFium Component traccia i conteggi di pagina XFA dinamici e i campi spostati

Come si verifica un salvataggio XFA in Delphi?

L'unico controllo affidabile di un salvataggio XFA è riaprire il file salvato in un'istanza TPdf fresca e rileggere i dati memorizzati. TPdf.GetXfaDatasets restituisce il pacchetto datasets così com'è memorizzato nel documento, non il modello dati XFA vivo, quindi chiamarlo prima del salvataggio mostra i valori vecchi. Dopo la riapertura mostra esattamente ciò che è stato scritto. Un documento a stream singolo non ha pacchetti con nome separato: PDFium riporta l'intero XDP come un pacchetto con nome vuoto, così GetXfaPacketByName('datasets') e GetXfaDatasets non restituiscono niente, e il fallback legge lo stream completo attraverso 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 ad array di pacchetti
    if Length(Bytes) = 0 then
    begin
      Packets := Pdf.GetXfaFormPackets;   // stream singolo: un pacchetto senza 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);  // l'output XDP salvato è UTF-8
  finally
    Pdf.Free;
  end;
end;

La routine di salvataggio poi commette la modifica in sospeso, controlla il risultato di SaveAs e confronta il valore riaperto. TPdf.ClearFormFieldFocus toglie il focus dal form, che è il momento in cui PDFium commette il buffer di modifica del campo con focus. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean riempie programmaticamente il campo con focus, ma conta su un focus che il wrapper traccia attraverso FocusFormField, che percorre le annotazioni widget. Una pagina XFA dinamica normalmente non ne ha, quindi lì il testo di solito arriva tramite input da tastiera in TPdfView, e la funzione restituisce False quando nessun campo tracciato ha il focus

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
  // Riempimento scriptato opzionale; False significa nessun campo tracciato con focus
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // commette il buffer di modifica
  if not Pdf.SaveAs(FileName) then      // include il flush finale (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;

Tratta il test della sottostringa come uno smoke test. Un elemento vuoto può essere serializzato come <Tag/>, gli attributi possono comparire sugli elementi dati, e l'escaping oltre & e < è una scelta del serializzatore. Per controlli di produzione, carica l'XML riaperto con un vero parser XML e confronta il nodo di testo dell'elemento dati legato. Esegui il controllo anche due volte di fila, perché il difetto della newline mostrava la sua forma completa solo alla seconda generazione

Riferimento rapido: checklist di fedeltà del salvataggio XFA

  • Distribuisci pdfium.v8.dll dalla v3.125.2 o successiva per i form XFA, e dalla v3.125.3 o successiva per la pdfium.dll ordinaria, così la correzione della scrittura finale è in entrambe
  • Punta LibraryName a un percorso completo e imposta EnableV8Engine a True; un percorso mancante fallisce invece di caricare un'altra copia
  • Conferma TPdf.XFA e TPdf.XfaRuntimeAvailable dopo aver aperto il documento
  • Chiama ClearFormFieldFocus prima di SaveAs così il campo con focus viene commesso
  • Ignora mai il risultato booleano di SaveAs; un risultato False lascia al suo posto il file precedente
  • Verifica riaprendo in un nuovo TPdf e leggendo GetXfaDatasets, ricadendo su GetXfaFormPackets per l'XFA a stream singolo
  • Prova con valori vuoti, spazi iniziali, testo multi-riga, & e un carattere del piano supplementare, su due generazioni di salvataggio
  • Aspettati fallimenti di salvataggio espliciti per DTD, XMLDSig e commenti dentro i pacchetti vivi dell'XFA a stream singolo
  • Se un form dinamico perde geometria a runtime alla riapertura, controlla il subform radice per restoreState="auto" prima di sospettare la libreria

Per la struttura di callback che il runtime XFA si aspetta da un'applicazione host, vedi FPDF_FORMFILLINFO versione 2 e l'ABI XFA in Delphi. Il runtime V8, il wrapper Delphi e C++Builder e il controllo viewer sono tutti parte di PDFium Component for Delphi and C++Builder, che include entrambi i runtime Windows per Win32 e Win64