Technisch artikel

PDFium Component XFA Save: newlines, emoji en restoreState

PDFium Component slaat bewerkte XFA-formulierwaarden exact op, over opslaan en heropenen heen, wanneer hij de Windows V8-runtime pdfium.v8.dll draait die met v3.125.2 of later wordt meegeleverd. Oudere runtimes voegden line feeds toe aan veldwaarden, kaptten emoji af tot een ongerelateerd BMP-teken, sloegen single-stream XFA-saves stilletjes over en konden een mislukte slotwrite doorslikken. Eén heropen-symptoom is helemaal geen librarydefect: een dynamisch formulier wiens root-subform geen restoreState="auto" heeft, bouwt zijn layout weer op vanuit de template

De bugmeldingen hierover zagen er allemaal hetzelfde uit. Een klant vult een XFA-claimformulier in een Delphi-viewer, slaat op, heropent, en er klopt iets net niet. Een leeg opmerkingenvak bevat nu een blanco regel, en na een tweede opslag bevat hij er twee. Een naam getypt met een emoji komt terug met een private-use-glyph. Niemand krijgt een fout, en dat maakt deze bugs duur: de drift komt weken later boven water in de export van iemand anders

Wat gaat er mis wanneer een XFA-formulier wordt opgeslagen en heropend?

Vier aparte defecten in het native XFA-savepad veroorzaakten waardedrift, en elk verstopte zich achter een succesvol ogende opslag. Twee kwamen uit serialisatie, één uit de single-stream-opslaglayout en één uit de PDF-writer zelf. De tabel mapt elk symptoom op zijn oorzaak en op de release waarin PDFium Component hem heeft gefixt

Symptoom na heropenenOorzaakGefixt in
Leeg veld bevat een line feed; waarden groeien per opslag een newlineBeide XFA-writers plaatsten layout-newlines na start-tagsv3.125.2, pdfium.v8.dll
U+1F642 komt terug als U+F642, of de emoji verdwijnt uit het form-packet16-bit wchar_t-afkapping bij decodering; surrogate-filtering in de form-serializerv3.125.2, pdfium.v8.dll
Bewerkingen in een single-stream XFA-document zijn simpelweg wegDe native save wees de streamlayout af, maar de retourwaarde werd genegeerdv3.125.2; comments en processing instructions behouden sinds v3.126.0
Afgekapt bestand hoewel de opslag succes melddeDe laatste gebufferde write faalde nadat de writer al succes had teruggegevenv3.125.2 V8-runtime; v3.125.3 gewone pdfium.dll
Dynamisch formulier van drie pagina's heropent als twee pagina'sRoot-subform vraagt geen restoreState="auto"Formulierontwerp, geen librarydefect

Eerdere stukken concludeerden dat XFA-veldbewerkingen met PDFium helemaal niet konden worden bewaard, wat voor de runtimes van destijds klopte. De nieuwere V8-runtime slaat XFA-waarden native op, dus een bewerking in het live formulier komt het opgeslagen datasets-packet binnen zonder packetchirurgie aan uw kant

Welke PDFium-runtime slaat XFA-waarden op?

XFA-save-fidelity hangt af van de native DLL, niet van de Delphi-wrapper, dus de eerste controle is welke runtime uw proces werkelijk heeft geladen. PDFium Component levert per architectuur twee Windows-builds: de gewone pdfium.dll, gebouwd zonder V8 en XFA, en pdfium.v8.dll, die de JavaScript-engine en de XFA-formulieruntime meedraagt. Alleen pdfium.v8.dll kan een XFA-formulier draaien, dus elke hier beschreven XFA-fix woont daar, te beginnen met de herbouwde Win32- en Win64-V8-bibliotheken in v3.125.2

De slot-write-fix is generieke PDF-writercode, dus hij doet er ook voor gewone documenten toe. v3.125.3 bouwde de gewone pdfium.dll-bibliotheken opnieuw om diezelfde reparatie te dragen. Gedeelde source is geen bewijs van gedeeld gedrag: tot de binary opnieuw is gebouwd houdt de oude DLL de oude bug vast

Een tweede val zat in de loader. Vóór v3.125.2 liet het zetten van EnableV8Engine op True de binding de defaultnaam pdfium.v8.dll kiezen en een volledig pad in LibraryName negeren. Een applicatie die naar een vers gedeployde runtime wees kon zo een oudere kopie uit een andere map blijven laden. Sinds v3.125.2 selecteert een LibraryName die een directory bevat exact dat bestand in beide engine-modi, en een ontbrekend pad faalt in plaats van terug te vallen op een andere meegeleverde bibliotheek

uses
  System.SysUtils, PDFium;

procedure SelectXfaRuntime;
begin
  // Een directory in LibraryName legt precies dit bestand vast (v3.125.2 en later);
  // ontbreekt het bestand, dan werpt het laden in plaats van terug te vallen
{$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;  // faal bij de opstart, niet bij de eerste opslag
end;

Na het openen van een document vertelt TPdf.XFA u dat het bestand XFA bevat, en TPdf.XfaRuntimeAvailable dat de geladen DLL hem ook werkelijk kan uitvoeren. Moet u ook statische en dynamische formulieren uit elkaar houden, dan geeft TPdf.FormType ftXfaFull of ftXfaForeground terug; het artikel XFA-formulieren detecteren en XFA-packets extraheren in Delphi behandelt dat aftasten in detail

Waarom krijgen opgeslagen XFA-velden extra line feeds?

Opgeslagen XFA-velden kregen line feeds omdat beide native XFA-writers, de generieke XML-element-writer en de form-packet-serializer, hun output pretty-printten met een newline na start-tags. In de meeste XML is die whitespace cosmetisch. In XFA-data is dat niet zo: wanneer het datasets-packet opnieuw wordt geparsed is de tekst tussen <Comments> en </Comments> de veldwaarde, newline inbegrepen. Een leeg veld heropende daardoor met één enkele LF, en elke verdere save-en-heropen-cyclus kon er nog een toevoegen

PDFium Component-diagram van de XFA-save-cyclus waarin de writer een newline na start-tags toevoegt, de heropende parser de LF tussen de Comments-tags als veldwaarde leest, en elke verdere opslag nog een line feed toevoegt tot v3.125.2 alléén de door de serializer gesynthetiseerde whitespace verwijdert
Eén save-heropen-cyclus plant de eerste line feed en elke volgende ronde voegt er nog een toe, wat de reden is dat de drift pas in de tweede generatie zijn volle vorm liet zien

De voor de hand liggende reparatie, waarden bij het laden trimmen, zou verkeerd zijn. Gebruikers typen voorloopspaties, volgspaties en bewuste meerregelige tekst in XFA-velden, en een adresblok of een code met vaste breedte moet byte voor byte overleven. De fix in v3.125.2 verwijdert daarom alléén de whitespace die de serializer zelf rond tags heeft gesynthetiseerd. Gebruikerswaarden, bestaande tekstnodes en CDATA-secties gaan onaangeroerd door, dus " indented" blijft ingesprongen en een met opzet leeg veld blijft leeg

Waarom komt een emoji terug als een ander teken?

Een emoji kwam verkeerd terug omdat Windows wchar_t 16 bits breed is, en twee decodeerpaden een volledige Unicode-scalarwaarde in één enkele wchar_t bewaarden. De UTF-8-streamdecoder en de parser voor numerieke character references zoals &#x1F642; deden dat beide. U+1F642, het licht glimlachende gezicht, past niet in 16 bits, dus vielen de hoge bits weg en verscheen U+F642 in plaats daarvan: een code point in de Private Use Area dat de meeste fonts als een kadertje of niets renderen

De form-serializer had het tegenovergestelde probleem. Ze filterde tekens één wchar_t per keer, zag twee surrogaat-code units die op zichzelf ongeldig zijn, en liet beide vallen, dus verdween de emoji volledig uit het form-packet. In v3.125.2 consumeert de decoder elke scalarwaarde volledig en emitteert hij een net surrogaatpaar. Wanneer er nog maar één outputslot over is, houdt hij de lage surrogaat in de wacht en meldt hij geen end-of-stream zolang die unit nog gebufferd is. Een UTF-8-reeks die over leesblokken heen is gesplitst wordt meegenomen naar de volgende lees in plaats van te worden weggegooid. De form-exporteur houdt geldige surrogaatparen nu bij elkaar, en numerieke character references leveren eveneens correcte paren op

PDFium Component-diagram van surrogaatafhandeling waarin U+1F642 binnenkomt als het UTF-16-paar D83D DE42 en twee defecte paden hem corrumperen: 16-bit wchar_t-decoders kappen de scalar af tot U+F642 in de private use area, terwijl de form-serializer losse surrogaten filtert en de emoji volledig laat vallen
Windows wchar_t is 16 bits breed, dus een scalar die een surrogaatpaar nodig heeft verloor zijn hoge helft of verdween uit het packet totdat beide paden leerden paren bij elkaar te houden

Latin-1-testdata laten hiervan nooit iets zien, dus elke XFA-round-trip-test heeft minstens één teken uit een supplementair vlak nodig

Single-stream XFA en save-falingen die niemand zag

Een single-stream XFA-document verloor zijn bewerkingen omdat de native save-helper die opslaglayout weigerde en zijn aanroeper de faling negeerde. ISO 32000-1 §12.7.8 staat toe dat de entry /XFA van de interactive form-dictionary ofwel een array van packetnamen en streams is ofwel één enkele stream die het hele XDP-document bevat. Packetarrays zijn het gebruikelijke geval, maar single streams zijn volkomen legaal, en de PDF-opslag klaarde alsof er niets was gebeurd terwijl de formulierdata op zijn oude waarden bleef

Sinds v3.125.2 behandelt de V8-runtime de ondersteunde single-stream-subset. Ze exporteert eerst beide live packets, datasets en form, naar een staging-gebied en valideert ze, en pas daarna vervangt ze de overeenkomende packets in de originele XDP. Andere packets en de root-namespace-declaraties blijven behouden. Faalt de staging, dan wordt de persistente XFA-stream nooit aangeraakt en houdt het document zijn modificatiemarkering

XML-comments en processing instructions vroegen extra zorg omdat de interne XML-DOM ze laat vallen. In v3.125.2 liet hun aanwezigheid de opslag ronduit falen in plaats van stilletjes inhoud te verliezen. v3.126.0 behoudt ze: vóór het parsen wordt elk comment of elke processing instruction verwisseld voor een marker gebouwd vanuit een prefix die nergens in de originele tekst voorkomt. Nadat de live packets zijn vervangen, moet elke marker exact één keer voorkomen voordat het originele token wordt hersteld en de stream wordt weggeschreven. Tokens buiten de vervangen packets houden daardoor hun tekst en volgorde, ook tokens in de prolog, de template en andere packets

Sommige inputs worden nog steeds met opzet geweigerd, en elke weigering is een expliciete save-faling:

  • Comments of processing instructions binnen de live packets datasets of form, want hun originele posities zijn niet te mappen op vers geëxporteerde inhoud
  • DTD-declaraties en XMLDSig-handtekeningen, want het herschrijven van de XDP kan een XML-handtekening niet geldig houden
  • Ongeldige UTF-8- of UTF-16-codering, onvolledige tags, ongeldige character references, onbekende entities en misvormde processing instructions, die worden geweigerd in plaats van stilletjes gerepareerd
PDFium Component-pipeline voor het opslaan van single-stream XFA waarin live datasets- en form-packets naar staging worden geëxporteerd, gevalideerd en daarna binnen de originele XDP worden vervangen met comments behouden via markers, terwijl staging-falingen en inputs zoals DTDs of XMLDSig de opslag expliciet weigeren
De geënsceneerde export wordt gevalideerd voordat er iets wordt vervangen, dus een mislukte opslag laat de persistente XFA-stream onaangeroerd en het document houdt zijn modificatiemarkering

De single-stream-output is UTF-8 en behoudt het XML-inhoudsmodel, niet de originele bytelayout of coderingsdeclaratie

Het laatste defect zat onder XFA. De native file writer buffert output in blokken van 32 KB en spoelde het laatste partiële blok pas door in zijn destructor, nadat de documentwriter al succes had gemeld. Een disk-full- of I/O-fout op dat laatste blok was onzichtbaar voor de aanroeper. Sinds v3.125.2 in de V8-runtime en v3.125.3 in de gewone runtime hoort die laatste flush bij het saveresultaat, en wordt de XFA-modificatiemarkering pas gewist na echt succes. Aan de Delphi-kant schrijft TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean naar een tijdelijk bestand naast het doel en verplaatst het pas op zijn plek wanneer de opslag True teruggeeft, dus laat een mislukte opslag het vorige bestand intact

Waarom heropent een dynamisch XFA-formulier met minder pagina's?

Een dynamisch XFA-formulier heropent met minder pagina's wanneer zijn root-subform geen restoreState="auto" declareert, en dat is een formulierontwerpbeslissing in plaats van een defect van PDFium Component. In XFA 3.3 staat restoreState op het root-subform by default op manual. Onder manual herstelt de XFA-processor slechts beperkte toestand uit het opgeslagen form-packet en laat de rest over aan de scripts van de auteur. Opgeslagen veldwaarden en instance-aantallen van herhalende subforms komen nog wel terug, maar geometrische eigenschappen die tijdens run time zijn gezet niet

Het geval dat dit aan het licht bracht was een formulier van drie pagina's waarvan het script een subform liet groeien naar h="450pt". Het opgeslagen form-packet bevatte de nieuwe hoogte, de waarden en de instance-aantallen. Bij het heropenen werd de layout echter weer opgebouwd vanuit de templatehoogten en vloeide het formulier over twee pagina's. De runtime had gelijk: de template had nooit om automatische restauratie gevraagd. Het op het root-subform declareren fixt het heropenen:

<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">
      <!-- velden; scripts kunnen h wijzigen of instances toevoegen tijdens run time -->
    </subform>
  </subform>
</template>

Is de template niet van u, plak er dan niet in de viewer omheen: een formulier dat op manual-modus vertrouwt verwacht dat zijn eigen scripts de toestand herbouwen. Live herpaginering terwijl de gebruiker typt is een apart onderwerp, behandeld in hoe PDFium Component dynamische XFA-pagina-aantallen en verplaatste velden volgt

Hoe verifieert u een XFA-opslag in Delphi?

De enige betrouwbare XFA-save-controle is het opgeslagen bestand in een verse TPdf-instantie heropenen en de opgeslagen data teruglezen. TPdf.GetXfaDatasets geeft het datasets-packet terug zoals het in het document is opgeslagen, niet het live XFA-datamodel, dus een aanroep vóór het opslaan toont de oude waarden. Na het heropenen toont hij exact wat is weggeschreven. Een single-stream document heeft geen afzonderlijk benoemde packets: PDFium meldt de hele XDP als één packet met een lege naam, dus GetXfaPacketByName('datasets') en GetXfaDatasets geven niets terug, en de fallback leest de volledige stream via 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;          // packet-array-layout
    if Length(Bytes) = 0 then
    begin
      Packets := Pdf.GetXfaFormPackets;   // single stream: één naamloos packet
      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);  // opgeslagen XDP-output is UTF-8
  finally
    Pdf.Free;
  end;
end;

De saveroutine commit daarna de wachtende bewerking, controleert het resultaat van SaveAs en vergelijkt de heropende waarde. TPdf.ClearFormFieldFocus doodt de formulierfocus, en dat is het moment waarop PDFium de editbuffer van het gefocuste veld commit. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean vult het gefocuste veld programmatisch, maar hij leunt op een focus die de wrapper volgt via FocusFormField, dat langs widget-annotaties loopt. Een dynamische XFA-pagina heeft die normaal niet, dus daar komt de tekst meestal binnen via toetsenbordinvoer in TPdfView, en geeft de functie False terug wanneer geen gevolgd veld focus heeft

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
  // Optionele scripted invulling; False betekent dat geen gevolgd veld focus heeft
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // commit de editbuffer
  if not Pdf.SaveAs(FileName) then      // omvat de laatste flush (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;

Behandel de substringtest als een rooktest. Een leeg element kan worden geserialiseerd als <Tag/>, attributen kunnen op data-elementen voorkomen, en escapen voorbij & en < is een keuze van de serializer. Voor productiecontroles laadt u de heropende XML met een echte XML-parser en vergelijkt u de tekstnode van het gebonden data-element. Draai de controle ook twee keer achter elkaar, want het newline-defect liet pas in de tweede generatie zijn volle vorm zien

Snelnaslag: de XFA save fidelity-checklist

  • Deploy pdfium.v8.dll van v3.125.2 of later voor XFA-formulieren, en v3.125.3 of later voor de gewone pdfium.dll, zodat de slot-write-fix in beide zit
  • Wijs LibraryName naar een volledig pad en zet EnableV8Engine op True; een ontbrekend pad faalt in plaats van een andere kopie te laden
  • Bevestig TPdf.XFA en TPdf.XfaRuntimeAvailable na het openen van het document
  • Roep ClearFormFieldFocus aan vóór SaveAs zodat het gefocuste veld wordt gecommit
  • Negeer nooit het Boolean-resultaat van SaveAs; een False-resultaat laat het vorige bestand op zijn plek
  • Verifieer door te heropenen in een nieuwe TPdf en GetXfaDatasets te lezen, met terugval op GetXfaFormPackets voor single-stream XFA
  • Test met lege waarden, voorloopspaties, meerregelige tekst, & en een teken uit een supplementair vlak, over twee opslaggeneraties heen
  • Verwacht expliciete save-falingen voor DTD's, XMLDSig en comments binnen de live packets van single-stream XFA
  • Verliest een dynamisch formulier zijn run-time-geometrie bij het heropenen, controleer dan het root-subform op restoreState="auto" voordat u de library verdenkt

Voor de callbackstructuur die de XFA-runtime van een hostapplicatie verwacht, zie FPDF_FORMFILLINFO versie 2 en de XFA-ABI in Delphi. De V8-runtime, de Delphi- en C++Builder-wrapper en de viewer-control maken allemaal deel uit van PDFium Component for Delphi and C++Builder, dat beide Windows-runtimes voor Win32 en Win64 omvat