Technický článek

PDFium Component a dynamické XFA: počet stránek je delta

Když dynamický formulář XFA v prohlížeči v Delphi přidá nebo ubere stránky, hlásí PDFium Component nový součet přes TPdf.PageCount a TPdf.OnXfaPageCountChanged od v3.126.1, protože nativní stránková událost nese přidanou/odebranou deltu, nikoli součet. Windows V8 knihovny ve v3.126.1 taky stěhují vstupní oblasti s přemístěnými poli a v3.126.2 znovu načítá zastaralé handly stránek po návratu layout callbacku. Bug report, který to rozpoutal, byl formulář vyúčtování: klikněte dvakrát na Add Row, formulář naroste na dvě stránky a indikátor stránek hrdě hlásí 1 z 1. Píšete-li do pole, které se přestěhovalo na stránku 2, dopadnou klávesy někam neviditelně. Nic z toho se neukázalo na vzorových formulářích s pevnou délkou, kterými se testuje každý nejdřív, a důvody stojí za to znát, když do aplikace vkládáte prohlížeč formulářů

Co se stane, když se dynamický formulář XFA přestránkuje?

Dynamický formulář XFA nemá pevný seznam stránek, takže jeho počet stránek je výstup layoutu a může se změnit pokaždé, když uživatel upraví data. XFA 3.3 popisuje formulář jako strom subformů; opakovaný subform řídí instanceManager a skript jako _Row.addInstance() naklonuje jednu řádku navíc. Layout procesor pak znovu protéká obsah do stránkových ploch, což může přidat stránku, ubrat stránku nebo tlačit stávající pole na jinou stránku. ISO 32000-1 §12.7.8 definuje jen to, jak XFA pakety cestují uvnitř PDF; všechno, co přijde potom, patří XFA jádru, které je v PDFium Component vlastní XFA layout PDFium běžící v hostitelském procesu. Prohlížeč v Delphi se proto potýká s dokumentem, jehož počet stránek, velikosti stránek i pozice widgetů jsou živý stav. Tři věci se pokazí, když hostitel předpokládá opak:

  • Počet stránek, který hostitel kešuje pro navigaci, rozsahy scrollování a přepínače stránek, zastará, nebo hůř, aktualizuje se špatným číslem
  • Pole, která se přestěhují, ukazují rámeček na nové pozici, zatímco editor a oblast zásahů myši zůstávají na starých souřadnicích
  • Prohlížeč drží handle stránky, který layout vystřídal, takže kliky a kreslení míří na stránku, která v tom formuláři už neexistuje

Udržet úpravy řádků napříč uložením a znovuotevřením je zvláštní problém s vlastními pravidly; tenhle článek zůstává u toho, co se děje za běhu uvnitř prohlížeče

Které PDFium runtime dynamické XFA potřebuje?

Dynamické XFA v PDFium Component vyžaduje build V8/XFA nativní knihovny, vybraný globální proměnnou EnableV8Engine v unitě PDFium před načtením prvního dokumentu. Proces se na jedno DLL zaváže v okamžiku, kdy jakýkoli TPdf knihovnu načte, a prostý build PDFium nedokáže XFA jádro roztáčet vůbec. Když se dokument otevře, TPdf se do souboru podívá po XFA značkách a na V8 build přepne automaticky, ale jen když v procesu ještě nebyla načtena žádná prostá knihovna. Když už se závazek rozhodl špatným směrem, TPdf.OnXfaRuntimeMissing jednou zabliká, aby mohl hostitel říct uživateli, ať aplikaci restartuje. Explicitní nastavení příznaku při startu vyhazuje dohadování ze hry. Struktura callbacků FPDF_FORMFILLINFO, která nese XFA události, musí navíc sedět na DLL; pozadí najdete v FPDF_FORMFILLINFO verze 2 a XFA callback ABI a rozpoznávání formulářů XFA a čtení jejich paketů pokrývá rozeznávání typů formulářů, než otevřete prohlížeč

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // Rozhodněte se, než první TPdf načte nativní knihovnu:
  // proces už nedokáže později přepnout z pdfium.dll na pdfium.v8.dll
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

Proč hlásil PageCount 1 u dvoustránkového formuláře?

Před v3.126.1 si PDFium Component ukládal argument page_count nativní stránkové události jako součet dokumentu, a ten argument je doopravdy absolutní rozdíl mezi novým a starým počtem stránek. PDFium vyhodí FFI_PageEvent po dokončení layout průchodu s typem události stránka přidána nebo stránka odebrána; uvnitř si nejdřív aktualizuje uložený počet stránek a pak podá abs(new - old). Při počátečním layoutu je starý počet nula, takže delta se rovná součtu a třístránkový statický vzorek hlásí tři stránky, jak se čeká. Přesně proto formuláře s pevnou délkou tenhle bug nikdy neodhalily. Jakmile dynamický formulář poprvé naroste z jedné stránky na dvě, delta je 1 a wrapper nastavil TPdf.PageCount i parametr NewCount události OnXfaPageCountChanged na 1. Odebrání řádku ze třístránkového formuláře vyrobilo stejný druh nesmyslů opačným směrem

Sčítat deltu k předchozí hodnotě není bezpečná oprava taky. Pořadí inicializačních a layout callbacků znamená, že wrapper si nemůže vždycky vzít svůj dřívější počet jako základ, takže běžící součet může bloudit. Od v3.126.1 ignoruje callback argument jako počet a zavolá FPDF_GetPageCount nad dokumentem, který čte součet z layoutu, který právě doběhl. Pak vyčistí kešované stránkové scény, uloží ten součet jako XFA přepsání počtu stránek za TPdf.PageCount a teprve pak vyhodí OnXfaPageCountChanged. V okamžiku, kdy běží váš handler, souhlasí NewCount a FPdf.PageCount

Diagram dynamického XFA v PDFium Component, kde přidání řádku přestránkuje jednostránkový formulář na dva a FFI_PageEvent podá abs(new minus old) jako deltu, takže starý wrapper hlásil TPdf.PageCount 1, zatímco v3.126.1 čte FPDF_GetPageCount a hlásí správný součet
Nativní stránková událost hlásí přidanou nebo odebranou deltu, ne součet, takže v3.126.1 argument ignoruje a přečte dokončený layout, dřív než vyhodí OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+: NewCount je součet dokončeného layoutu, nikdy delta.
  // Tohle běží uvnitř layout callbacku PDFium: aktualizujte jen UI stav hostitele,
  // nezavírejte odtud dokument ani znovu nenačítejte stránky
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // Vyhodí se po každém znovunačtení stránky, včetně odloženého XFA refresh
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

Událost se vyhodí jen pro Full XFA formuláře, jejichž layout se mění za běhu. Statické XFA a AcroForm dokumenty ji nevyhodí nikdy, takže prohlížeč, který obsluhuje obojí, může nechat přiřazený tentýž handler. Nechat ho nepřiřazený je taky bezpečné; přepsání za TPdf.PageCount se aplikuje stejně a událost existuje proto, aby si hostitel mohl osvěžit všechno, co kešoval

Proč zůstává vstupní box na staré stránce, když se pole přestěhuje?

Rámeček se přestěhoval a editor ne, protože nativní XFA notifier porovnával obdélník sám se sebou. Když layout změní geometrii už načteného widgetu, PDFium má zpozorovat nový obdélník a zavolat na widgetu PerformLayout, což přemístí textový editor i jeho oblast zásahů. Kontrola porovnávala GetWidgetRect() s RecacheWidgetRect(). Obě funkce vrací const referenci na téhož člena a recache ten člena přepíše na místě, takže srovnání vždycky vidělo dvě identické hodnoty a načtené widgety přeskočily svůj relayout

Příznak se vynořil, když test změnil výšku subformu tak, aby se stávající pole přehoupla na další stránku. Na obou V8 architekturách se rámeček pole kreslil na nové pozici, zatímco napsaný text a oblast zásahů myši zůstávaly na předchozí souřadnici Y. Explicitní relayout to neopravil a znovunačtení stránky taky ne, protože widget si pořád myslel, že jeho geometrie je aktuální. Windows V8 knihovny dodané s v3.126.1 zkopírují starý obdélník hodnotou před recache a porovnávají tu kopii, takže přestěhované widgety projdou relayoutem a upravená hodnota se objeví přesně tam, kde je rámeček. Tahle oprava je nativní: cestuje s DLL, takže aktualizace Pascal unit při zachování staršího pdfium.v8.dll nechá špatně umístěné oblasti zásahů na místě. Regresní test, který ji vypátral, nejdřív upraví přeživší řádek na ne-výchozí hodnotu a pak vyžaduje tu hodnotu na nové pozici pole, protože řádek postavený znovu s výchozími hodnotami by jinak vypadal jako úspěch

Diagram relayoutu widgetů v PDFium Component staví proti sobě staré samosrovnání, kde GetWidgetRect a RecacheWidgetRect vracely jednoho sdíleného člena, takže přestěhované widgety přeskočily PerformLayout, a kontrolu kopírování hodnotou z Windows V8 knihoven v3.126.1, která přemístí editor a oblast zásahů myši na překreslený rámeček
Srovnání obdélníku se sebou samým nikdy nevyjde špatně, takže rámeček se přestěhoval, zatímco napsaný text a kliky zůstávaly pozadu, dokud kontrola neuložila nejdřív kopii hodnotou

Jak znovunačítá TPdfView stránky, aniž by PDFium vytáhl handle pod rukama?

Od v3.126.2 odkládá TPdfView znovunačtení stránky, které následuje po změně XFA layoutu, dokud se nativní call stack nesbalí. Stránková událost většinou vyhodí, když PDFium pořád zpracovává vstup: uživatel klikl na tlačítko Add Row, klik spustil skript, skript změnil počet instancí a layout doběhl uvnitř téhož nativního volání. Zavřít a znovu otevřít handle stránky v ten okamžik by uvolnilo objekt, se kterým volající pořád pracuje. Před v3.126.2 se prohlížeč jen zneplatnil, takže zobrazený handle stránky mohl ukazovat na stav před layoutem, a byl-li uživatel na poslední stránce, když zmizela, bylo vybrané číslo stránky mimo rozsah

Odložený refresh běží v pár malých krocích a ty vysvětlují chování, které vidíte z hostitele:

  1. Callback stránkové události označí pohled jako mající nevyřízený XFA layout refresh a pošle soukromou window zprávu; opakované události, než zpráva dorazí, se sloučí do jednoho refresh
  2. Pohled bez window handlu si drží nevyřízený příznak a pošle zprávu z CreateWnd, zatímco změna dokumentu, deaktivace pohledu nebo jeho zničení příznak vymaže
  3. Když zpráva dorazí, pohled vymaže výběr textu, zvýraznění hledání a index zaměřeného pole, protože všechny tři odkazovaly na starý layout
  4. Vybraná stránka se upne na nový PageCount; změněné číslo stránky jde normálním přepnutím stránky, jinak se znovu načte aktuální stránka a znovu se aplikuje režim fit
  5. Nechá-li layout vůbec žádné stránky, pohled vyloží svůj starý handle stránky místo kreslení stránky, která už neexistuje
Diagram odloženého XFA refresh TPdfView v PDFium Component, kde stránková událost uvnitř nativního call stacku layoutu jen označí nevyřízený refresh a pošle window zprávu, která později vymaže zastaralý stav výběru, upne stránku na nový PageCount a znovu načte nebo vyloží handle stránky
Znovunačtení počká, dokud se nativní call stack nesbalí: poslaná zpráva sloučí opakované události, pak pohled upne stránku, znovu ji načte a vyhodí OnPageChange

Táž omezení platí pro váš vlastní kód. OnXfaPageCountChanged běží uvnitř toho nativního layout callbacku, takže k němu přistupujte jako k notifikaci: aktualizujte tam popisky, rozsahy spinneru a stav toolbaru a cokoli těžšího, jako zavření dokumentu nebo otevření jiného, dejte do fronty přes poslanou zprávu, aby to běželo po návratu callbacku. TPdfView.OnPageChange vám pak řekne, kdy pohled doopravdy znovu načetl stránku a přečtení PdfView1.PageNumber v ten okamžik dá upnutou hodnotu. Procházení klávesou Tab a kontrola FormType, které prohlížeč formulářů pustí při otevření, pokrývá navigace formulářových polí PDF s PDFium Component

Proč klik do pole Full XFA hodí „Cannot open text page“?

Full XFA stránky nemají textovou stránku PDF a před v3.126.2 se výchozí výběr textu a detekce odkazů prohlížeče přesto snažily jednu načíst. S TPdfView.AllowUserTextSelection na výchozí hodnotě True se hover ptal textové vrstvy na znak pod myší a mouse-up klik pustil automatickou URL sondu nad textem stránky. Na stránce Full XFA se textová stránka otevřít nedá, takže obyčejný klik do pole mohl skončit výjimkou Cannot open text page. Od v3.126.2 vracejí obě interní cesty žádný výsledek, když je TPdf.FormType ftXfaFull a XFA runtime je dostupný, takže výchozí nastavení funguje a vstup do polí zůstává dostupný

Vypnout AllowUserTextSelection u dokumentů Full XFA zůstává rozumnou volbou UI, protože není co vybírat a tažení gestem nemá spouštět režim výběru. Není to ale náhrada za upgrade: na starších verzích URL sonda při kliku nezávisela na té vlastnosti, takže prohlížeč mohl narazit na tutéž výjimku i se zakázaným výběrem

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormType čte otevřený dokument, takže volejte po FPdf.Active := True
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // Full XFA stránky nemají textovou vrstvu PDF; pole zůstávají editovatelná
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

Psaní si ve v3.126.2 řeklo o vlastní opravu. Nativní XFA textový editor nenahradí výběr, když dostane znak: FORM_OnChar vkládá na kurzor a Backspace maže jediný znak, takže vybrat hodnotu a napsat přes ni vyprodukovalo starý a nový text bok po boku. PDFium Component si teď pamatuje, že klik dopadl na textové pole XFA, a routuje napsané znaky, Backspace a Delete přes FORM_ReplaceSelection, kdykoli existuje výběr a dokument dovoluje vyplňování formulářů nebo úpravu. O tom, zda se smí měnit read-only XFA pole, pořád rozhoduje nativní editor, takže pole označené ve formuláři read-only si drží hodnotu i v dokumentu, který jinak vyplňování dovoluje. Nastavení TPdfView.AllowFormEvents na False navíc zastaví tohle keyboard routing, což udrží read-only prohlížeč read-only

Rychlý přehled: dynamické XFA v prohlížeči v Delphi

PříznakPříčinaOpraveno ve
Počet stránek ukazuje 1 poté, co formulář naroste na dvě stránkyNativní stránková událost podá přidanou/odebranou deltu, ne součetv3.126.1 (wrapper)
Rámeček pole se přestěhuje, napsaný text a oblast zásahů zůstávají pozaduNačtený widget přeskočil relayout po samosrovnánív3.126.1 (Windows V8 knihovny)
Prohlížeč kreslí nebo routuje vstup na stav stránky před layoutemHandle stránky se po přestránkování znovu nenačetlv3.126.2 (odložený refresh)
Klik do pole hodí Cannot open text pageVýběr textu a URL sonda na stránkách bez textové vrstvyv3.126.2
Psaní přes vybranou hodnotu přidává místo nahrazeníNativní XFA editor vkládá na kurzorv3.126.2
  • Nastavte EnableV8Engine na True, než se načte jakýkoli dokument, a obsluhujte OnXfaRuntimeMissing pro případ, že se nejdřív načetla prostá knihovna
  • Čtěte součet z TPdf.PageCount nebo z parametru NewCount události OnXfaPageCountChanged; nikdy si počty stránek nepřičítejte ani neodečítejte sami
  • Držte handler OnXfaPageCountChanged lehký, protože běží uvnitř nativního layout callbacku
  • Synchronizujte indikátor aktuální stránky v TPdfView.OnPageChange, který se vyhodí poté, co odložené znovunačtení upne číslo stránky
  • Nasazujte Windows V8 DLL ve v3.126.1 a novější spolu s unitami; oprava relayoutu widgetů bydlí v nativním kódu
  • Testujte s formulářem, který doopravdy mění počet stránek a přestěhuje upravené pole přes zlom stránky, protože vzorky s pevnou délkou skryjí každý bug z tohohle seznamu

Dynamické XFA mění počet stránek a geometrii polí v živé hodnoty a prohlížeč zůstává správný jen tehdy, když si je bere z dokončeného layoutu a znovu načítá stránky v bezpečném okamžiku. PDFium Component obsluhuje oboje uvnitř TPdf a TPdfView, takže hostitel už jen naslouchá. Detaily a stažení najdete na stránce produktu PDFium Component pro Delphi