Teknisk artikel

Dynamisk XFA-formulärkörning i Delphi: HotPDF-transaktioner

HotPDF fyller dynamiska XFA-formulär i Delphi via TXFAWidgetRuntime, ett värdneutralt widgetlager som behandlar varje fältredigering som en transaktion: ögonblicksbild, validera, beräkna, flyta om och sedan publicera eller rulla tillbaka helt. Den kör entrådigt inuti din egen VCL- eller FMX-värd, kräver ingen installerad Acrobat och driver varje budget innan den allokerar något

Scenariot är bekant för alla som har skeppat dokumentmjukvara till offentlig sektor eller försäkringsarbete. Ett skadeanmälningsformulär eller en deklaration anländer som en PDF vars sidinnehåll är en enda notis om ”Please wait... if this message is not eventually replaced”, och alla verkliga fält ligger i ett XFA-paket som bara Adobe Acrobat renderar. Dina användare vill fylla det i din applikation. Du kan inte rasterisera dig ur det heller, eftersom formuläret växer rader när data matas in, och layouten efter tredje raden inte är den layout som följde med filen

Varför dynamisk XFA fortfarande är ett problem värt att lösa

Dynamisk XFA består för att utplacerade formulär överlever formatet som bar dem. ISO 32000-1 §12.7.8 beskriver XFA som en /XFA-post på AcroForm-ordlistan som håller en XDP-paketström, och ISO 32000-2 utfasar hela mekanismen; utfasningen tog bort den från färdplanen, inte från fältet, och formulär skapade mot specifikationen XFA 3.3 utfärdas fortfarande och är fortfarande juridiskt bindande. Statisk XFA kan reduceras till vanliga widgetannotationer, och HotPDF gör det när du anropar ApplyXFAAsAcroForm, med de avvägningar som tas upp i att platta ut XFA-formulär till AcroForm-fält. Dynamisk XFA är ett annat djur: dess occur-intervall, växande text och calculate-skript gör fältuppsättningen till en funktion av datan, så det finns ingen fast annotationslista att platta ut till förrän användaren har skrivit klart. Det är luckan som TXFAWidgetRuntime fyller, genom att hålla XFA-DOM:en levande, beräkna om layouten efter varje accepterad redigering och lämna din värd en platt array av positionerade widgets att rita och träfftesta

Vad lämnar körtiden till en värdapplikation?

Den lämnar dig geometri och tillstånd, och inget som förutsätter ett UI-verktyg. TXFAWidgetRuntime exponerar WidgetCount och Widgets[I] som TXFAWidgetState-poster som bär ID, Name, Kind, PageIndex, Bounds i PDF-punkter, Value, EditValue och flaggorna Focused, Editing, ReadOnly, Valid, medan målning, markörritning och tangentbordsdirigering stannar i din kod. Widgetidentiteten är stabil och ordningsbaserad: varje widget får ett ID av formen name[n], där n räknar tidigare förekomster av det fältnamnet i layoutordning, så andra raden i ett upprepande subformulär är amount[1]. Den identiteten är vad som överlever en ombyggnad, och det är vad FocusWidget, BeginEdit, DispatchEvent och HitTest alla talar. För ett dokument som redan är öppet i en THotPDF-instans extraherar CreateLoadedXFAWidgetRuntime XDP-paketen, tar första sidboxen som layoutens sidstorlek och returnerar nil när filen inte har någon XFA alls

var
  Pdf: THotPDF;
  Runtime: TXFAWidgetRuntime;
  WidgetID: AnsiString;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('claim-dynamic.pdf');
    Runtime := Pdf.CreateLoadedXFAWidgetRuntime;   // nil när det inte finns någon /XFA
    if Runtime = nil then
      Exit;
    try
      for I := 0 to Runtime.WidgetCount - 1 do
        Memo1.Lines.Add(Format('%s p%d [%.1f %.1f %.1f %.1f] = %s',
          [string(Runtime.Widgets[I].ID), Runtime.Widgets[I].PageIndex,
           Runtime.Widgets[I].Bounds.Left, Runtime.Widgets[I].Bounds.Top,
           Runtime.Widgets[I].Bounds.Right, Runtime.Widgets[I].Bounds.Bottom,
           string(Runtime.Widgets[I].Value)]));
      // träfftest i sidrymd, översta widgeten vinner
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Vad måste vara atomärt när ett fält bekräftas?

Allt som redigeringen kan röra, vilket är betydligt mer än fältvärdet. CommitEdit anropar CaptureSnapshot innan den skriver något, och den ögonblicksbilden täcker fyra saker: den serialiserade XFA-DOM:en från TXFADocument.SaveToBytes, hela arrayen av TXFAWidgetState-interaktionsposter, räknarna LastCalculationPasses och LastReflowPasses samt nuvarande Warnings.Count. Att spara bara nodvärden är den frestande genvägen och den är fel, eftersom ett calculate-skript eller en olöst bindning kan anropa EnsureValueNode och materialisera datanoder som inte fanns när redigeringen började; en återställning av bara värden har inget sätt att ta bort dem, så en avvisad redigering skulle lämna permanent strukturell residual i datasets-paketet. Bekräftelsesekvensen i sig är strikt — skriv kandidatvärdet, kör validate för det redigerade fältet, kör calculate till en fixpunkt, flyt sedan om tills layouten är stabil — och varje fel i varje steg går via FailAndRestore, som läser in ögonblicksbildens byte i ett färskt TXFADocument, bygger om widgetlistan, återapplicerar de registrerade interaktionstillstånden, nollställer räknarna och stycker Warnings tillbaka till sin längd vid ögonblicksbilden. LastDiagnostic håller orsaken vid fel och håller den litterala XFA transaction rollback failed i det patologiska fall där återställningen i sig kastar

HotPDF behandlar en XFA-fältbekräftelse som en transaktion och fångar den serialiserade DOM:en, varje widgettillstånd, passräknarna och varningsantalet innan validering, beräkning och reflow, och publicerar eller återställer sedan alla fyra tillsammans
CommitEdit tar ögonblicksbilder av fyra slags tillstånd innan den skriver något, så en misslyckad validate, calculate eller reflow inte lämnar någon strukturell residual
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // skrivskyddat, eller ingen sådan widget
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // ogiltigt intervall, eller delat surrogatpar
    Exit;
  end;
  Result := Runtime.CommitEdit;             // allt eller inget
  if not Result then
    // dokument, widgets, räknare och varningar är redan tillbaka i
    // tillståndet före redigeringen; den fokuserade widgeten markeras bara ogiltig
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection förtjänar en notis i egen rätt, eftersom det är där felformaterad indata är billigast att avvisa. Den vägrar ett urval som delar ett UTF-16-surrogatpar, vägrar ersättningstext som innehåller ett oparat högt eller lågt surrogat och vägrar varje resultat längre än MaxValueChars. Att fånga det på tangentnedslagslagret innebär att transaktionsmaskineriet aldrig behöver rulla tillbaka en halvskriven astralplanskaraktär

Bygg om i en privat lista, publicera i ett byte

En widgetombyggnad får aldrig vara observerbar halvfärdig, så RebuildWidgets bygger en helt separat ägande TObjectList och byter in den på plats med en enda tilldelning i slutet. Orsaken är inte estetik: TXFALayoutEngine.ComputeLayout körs medan ombyggnaden är på väg och anropar tillbaka till värdkod via den MeasureText-funktion du levererat, och den kan kasta EXFAWidgetRuntimeError när widgetgränsen nås. Om körtiden muterade sin aktiva lista på plats skulle någon av vägarna lämna värden med en lista som till delar är gammal layout och till delar ny, med DataNode-pekare in i ett dokument som snart ska rullas tillbaka. Reflow-konvergensen avgörs därefter av LayoutSignature, en sträng byggd från widgetantalet plus varje ID, sidindex och begränsningsruta avrundad till fyra decimaler: CommitEdit bygger om, jämför signaturer och upprepar tills två på varandra följande signaturer stämmer eller passbudgeten är slut. När signaturen aldrig ändrades alls stannar LastReflowPasses på 0, vilket är hur du skiljer en redigering som bara ändrar värde från en som faktiskt växte formuläret, och interaktionstillståndet bärs över varje ombyggnad via widget-ID, så fokus och pågående redigering överlever en radinsättning

HotPDF XFA-körtid bygger om sin widgetlista till en separat ägande lista medan layouten körs och anropar tillbaka till värdens mätkod, och publicerar sedan den färdiga listan med en enda tilldelning som värden inte kan observera halvfärdig
Ombyggnaden sker i en privat lista eftersom ComputeLayout kan kasta mitt i förloppet, och LayoutSignature avgör när två på varandra följande reflows har konvergerat

Varför skulle ett bundet fält läsa fel post?

För att skriptet kördes utan en datakontext. Ett fält med en uttrycklig <bind match="dataRef" ref="$record.actual"/> och ett fält uppkallat efter samma datanod är två olika widgets som pekar på ett värde, och ett upprepande subformulär med <occur max="2"/> producerar flera widgets som delar ett namn och skiljer sig bara åt i vilken datarad de tillhör; utvärderar man validering och beräkning mot dokumentroten löser alla av dem this till den första matchande noden i hela datasets-paketet, så rad två validerar i tysthet rad ett. HotPDF undviker det genom att lagra den lösta DataNode på varje widgetpost när layouten producerar den och sedan tråda den noden genom båda HPDFXFAEvaluateFieldScript-anropen, för xfskValidate och xfskCalculate lika. Samma kontext avgör mot vilken nod EnsureValueNode skapar när en beräkning riktar sig mot en bindning som inte finns ännu, och när ingen bindning kan lösas misslyckas bekräftelsen rent med XFA calculation target is not bound i stället för att skriva i fel rad. FormCalc-semantiken bakom de skripten speglar vad AcroForm-dokument får från de åtgärder som beskrivs i AcroForm-format- och calculate-skript, men upplösningsreglerna här är XFA-avgränsade i stället för fältnamnsavgränsade

Budgetar kontrolleras före sideffekter, inte efter

Varje gräns i körtiden är ett förvillkor, eftersom en budget som drivs igenom efter att allokeringen redan skett inte är en budget. TXFAWidgetRuntimeOptions.Default levereras med MaxWidgets på 10000, MaxValueChars på 1048576, MaxCalculationPasses på 16 och MaxReflowPasses på 4, och standard TXFAFormScriptOptions bär MaxOperations på 100000 med MaxElapsedMilliseconds på 500. Under detta tillämpar XFA-DOM:en sina egna TXFADOMLimits: tak på 128 MB för dekomprimerad in- och utdata, högst 1024 ihopsydda paket, 1000000 noder och ett häckningsdjup på 256. Två detaljer betyder mer än talen i sig. För det första är skriptbudgeterna transaktionsvida i stället för per skript: CommitEdit sår en enda räknare för kvarvarande operationer och en monoton tidsgräns, och varje validate- och calculate-anrop drar på samma räknare och får bara de millisekunder som återstår, så ett formulär med tvåhundra beräknande fält inte kan spendera hela 500 ms tvåhundra gånger. För det andra kommer tidsgränsen från en injicerbar funktion MonotonicMilliseconds, vilket är det som gör tidsbeteendet reproducerbart i en testsvit i stället för ett myntkast på en upptagen byggagent

Budgetlager i HotPDF XFA-körtid, från widget- och värdegränser via skriptoperations- och tidsgränser ner till XFA-DOM-taken, med en operationsräknare och en tidsgräns som delas av varje anrop i en transaktion
Skriptbudgeterna gäller för hela transaktionen i stället för per skript, så tvåhundra beräknande fält kan inte var och en kräva färska 500 ms
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // standard 10000
  Options.MaxCalculationPasses := 8;                       // standard 16
  Options.MaxReflowPasses := 2;                            // standard 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // hela transaktionen
  Options.ScriptOptions.Limits.MaxElapsedMilliseconds := 200;
  Options.MeasureText :=
    function(const AText: UnicodeString; const AFont: TXFAFontSpec;
      AMaxWidth: Double): TXFATextExtent
    begin
      Result := MeasureWithHostCanvas(AText, AFont, AMaxWidth);
    end;
  Runtime := TXFAWidgetRuntime.Create(XDPBytes, 612, 792, Options);
  try
    Runtime.OnLayoutChanged :=
      procedure
      begin
        RepaintAllPages;   // utlöses bara när reflow faktiskt flyttade widgets
      end;
    // ... driva formuläret ...
  finally
    Runtime.Free;
  end;
end;

Där körtiden stannar, och varför den säger det högt

Körtiden är medvetet ingen allmän XFA-skriptmotor. DispatchEvent hanterar aktiviteterna enter och exit internt genom att flytta fokus, och för varje annan aktivitet som bär ett skript vägrar den med en specifik, stabil diagnos i stället för att låtsas: skript som nämner addInstance, removeInstance eller instanceManager returnerar XFA runtime does not support event-driven instance mutation, skript som rör .presence returnerar presence-motsvarigheten och allt annat returnerar XFA runtime does not support this event script. En förutsägbar vägran du kan grensöka på slår en partiell emulering som fungerar på din exempelfil och avviker på kundens

Trådmodellen är lika beskedlig: en körtidsinstans tillhör en tråd, utan internt låsning, eftersom layoutmotorn når tillbaka in i värdens mätåteranropningar och ett lås omkring det är en dödlåge som väntar på en omritning. Rikt innehåll inuti fält följer samma konservativa linje som annars i biblioteket, där exData-nyttolaster hanteras som beskrivs i XFA exData-richtext och hyperlänkar, och signatur- och knappwidgets kommer tillbaka som ReadOnly medan UI-typer utan stöd ytas som xwkUnsupported i stället för som en redigerbar textruta som tyst tappar data

Sammanlagt är det ett brukbart svar på dynamisk XFA i Delphi: håll DOM:en levande, gör varje redigering till en transaktion som antingen landar helt eller inte lämnar något kvar, sätt gränser för varje pass och var explicit om vad som ligger utanför omfattningen. Utvärderar du detta för ett flöde för skadeärenden, skatt eller förmåner levereras XFA-körtiden som en del av HotPDF Delphi PDF component, vid sidan av AcroForm-, utplattnings- och renderingssökvägarna som de projekten oftast ändå behöver tillsammans