Техническа статия

PDFium Component Dynamic XFA: броят страници е дельта

Когато dynamic XFA форма в Delphi viewer добавя или маха страници, PDFium Component докладва новия общ брой чрез TPdf.PageCount и TPdf.OnXfaPageCountChanged от v3.126.1 насам, защото native page событието носи добавена/премахната дельта, а не общ брой. Windows V8 библиотеките в v3.126.1 местят и input hit areas заедно с преместените полета, а v3.126.2 презарежда остарелите page handles, след като layout callback-ът се върне. Бъг репортът, който го подхвърли, беше форма за разходни отчети: кликни Add Row два пъти, формата порасне до две страници, а индикаторът за страници горделиво пише 1 of 1. Пишеш в поле, преместено на страница 2, и натиснатите клавиши кацат някъде невидимо. Нищо от това не се показа с примерните форми с фиксирана дължина, с които всички първи тестват, а причините си заслужават да ги знаете, ако вграждате form viewer

Какво става, когато dynamic XFA форма преномерира страниците си?

Dynamic XFA форма няма фиксиран списък страници, така че броят ѝ страници е изход от layout и може да се мени при всяка редакция на данните от потребителя. XFA 3.3 описва формата като дърво от subforms; повторяем subform се управлява от instanceManager, а скрипт като _Row.addInstance() клонира още един ред. Layout процесорът после отново разпределя съдържанието по page areas, което може да добави страница, да махне страница или да избута съществуващи полета на друга страница. ISO 32000-1 §12.7.8 дефинира само как XFA пакетите пътуват в PDF; всичко, което става след това, е работа на XFA двигателя, който в PDFium Component е собственият XFA layout на PDFium, въртящ се в host процеса. Delphi viewer затова се разправя с документ, чийто брой страници, размери на страници и позиции на widget-и са всички живо състояние. Три неща се чупят, когато host-ът предположи обратното:

  • Броят страници, който host-ът кешира за навигация, scroll диапазони и page spinner-и, остарява или, по-зле, се обновява с грешното число
  • Полетата, които се преместват, показват рамката си на новата позиция, докато редакторът и mouse hit area остават на старите координати
  • Viewer-ът пази page handle, който layout-ът е заменил, така че кликовете и рисуването отиват на страница, която вече не съществува в тази форма

Запазването на редакциите на редовете през save и повторно отваряне е отделен проблем със свои правила; тази статия стои при това, което става по време на изпълнение в viewer-а

Кой PDFium runtime трябва на dynamic XFA?

Dynamic XFA в PDFium Component изисква V8/XFA билдът на native библиотеката, избран чрез глобалната променлива EnableV8Engine в unit-а PDFium преди първият документ да се зареди. Процесът се обвързва с една DLL при първото зареждане на библиотеката от който и да е TPdf, а чист PDFium билд изобщо не може да пусне XFA двигателя. При отваряне на документ TPdf наднича във файла за XFA маркери и превключва автоматично към V8 билда, но само ако в процеса все още не е заредена чиста библиотека. Когато обвързването вече е тръгнало в грешната посока, TPdf.OnXfaRuntimeMissing изстрелва веднъж, така че host-ът да посъветва потребителя да рестартира. Явното задаване на флага при стартиране маха отгатването. Callback структурата FPDF_FORMFILLINFO, която носи XFA събитията, също трябва да съвпада с DLL-а; фонът е в FPDF_FORMFILLINFO version 2 и XFA callback ABI, а разпознаването на XFA форми и четенето на техните пакети покрива как да различите типовете форми, преди да отворите viewer

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // Реши преди първия TPdf да зареди native библиотеката:
  // процесът не може после да смени pdfium.dll с 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;

Защо PageCount докладваше 1 за форма с две страници?

Преди v3.126.1 PDFium Component пазеше аргумента page_count на native page събитието като общ брой на документа, а този аргумент всъщност е абсолютната разлика между новия и стария брой страници. PDFium вдига FFI_PageEvent след като layout проход завърши с тип събитие страница добавена или страница премахната; вътрешно той първо обновява своя записан брой страници и после подава abs(new - old). При първоначалното layout старият брой е нула, така че дельтата съвпада с общия брой, и тристраничен static пример докладва три страници, както се очаква. Точно затова примерните форми с фиксирана дължина никога не показаха бъга. Първия път, когато dynamic форма порасне от една на две страници, дельтата е 1, а wrapper-ът зададе и TPdf.PageCount, и параметъра NewCount на OnXfaPageCountChanged на 1. Махането на ред от тристранична форма даваше същия вид безсмислие в другата посока

Натрупването на дельтата върху предишната стойност също не е безопасна поправка. Редът на инициализация и layout callback-овете означава, че wrapper-ът не може винаги да вярва на предишния си брой като база, така че текущата сума може да заплува. От v3.126.1 насам callback-ът игнорира аргумента като брой и вика FPDF_GetPageCount върху документа, който чете общия брой от току-що завършилото layout. После изчиства кешираните page scenes, записва този общ брой като XFA override зад TPdf.PageCount и чак тогава вдига OnXfaPageCountChanged. До момента, в който вашият handler тръгне, NewCount и FPdf.PageCount съвпадат

Диаграма на dynamic XFA в PDFium Component, в която добавянето на ред преномерира едностранична форма на две страници и FFI_PageEvent подава abs(new минус old) като дельта, така че старият wrapper докладваше TPdf.PageCount 1, докато v3.126.1 чете FPDF_GetPageCount и докладва правилния общ брой
Native page событието докладва добавена-или-премахната дельта, не общ брой, затова v3.126.1 игнорира аргумента и чете завършеното layout, преди да вдигне OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+: NewCount е общият брой на завършеното layout, никога дельта.
  // Това върви в layout callback на PDFium: обновявай само host UI състояние,
  // не затваряй документа и не презареждай страници оттук
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // Изстрелва се след всяко презареждане на страница, вкл. отложените 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;

Събитието изстрелва само за Full XFA форми, чиито layout се мени по време на изпълнение. Static XFA и AcroForm документи никога не го вдигат, така че viewer, който обслужва и двете, може да остави същия handler закачен. Да го оставите незакачен също е безопасно; override-ът зад TPdf.PageCount се прилага въпреки всичко, а събитието съществува, за да може host-ът да опресни това, което е кеширал

Защо input полето остава на старата страница, когато се мести едно поле?

Рамката се измести, а редакторът не, защото native XFA notifier-ът сравняваше правоъгълник със самия него. Когато layout смени геометрията на вече зареден widget, PDFium би трябвало да забележи новия правоъгълник и да вика PerformLayout върху widget-а, който премества текстовия редактор и hit area му. Проверката сравняваше GetWidgetRect() с RecacheWidgetRect(). И двете функции връщат const референция към един и същ член, а recache-ът презаписва този член на място, така че сравнението винаги виждаше две еднакви стойности и заредените widget-и прескачаха своя relayout

Симптомът излезе наяве, когато тест смени височината на subform така, че съществуващи полета прескочат на следващата страница. И на двете V8 архитектури рамката на полето се рисуваше на новата ѝ позиция, докато въведеният текст и mouse hit area оставаха на предишната Y координата. Изричен relayout не го оправяше, а и презареждането на страницата не, защото widget-ът все още вярваше, че геометрията му е актуална. Windows V8 библиотеките, доставени с v3.126.1, копират стария правоъгълник по стойност преди recache и сравняват това копие, така че преместените widget-и си правят relayout и въведената стойност се появява точно там, където е рамката. Това е native поправка: пътува с DLL-ите, така че обновяването на Pascal unit-ите при по-стар pdfium.v8.dll оставя разместването на hit areas на място. Regression проверката, която го проведе, първо редактира оцелял ред до не-default стойност и после изисква тази стойност на новата позиция на полето, защото ред, възстановен с default стойности, иначе би изглеждал като успех

Диаграма на widget relayout в PDFium Component, противопоставяща старото самосравнение, при което GetWidgetRect и RecacheWidgetRect връщаха един споделен член и преместените widget-и прескачаха PerformLayout, с copy-by-value проверката на Windows V8 в v3.126.1, която премества редактора и mouse hit area върху новоприснетата рамка
Сравнението на правоъгълник със самия него никога не проваля, така че рамката се местеше, докато въведеният текст и кликовете изоставаха, чак проверката първо запази копие по стойност

Как TPdfView презарежда страници, без да дръпва handle под краката на PDFium?

От v3.126.2 насам TPdfView отлага презареждането на страницата, което следва XFA layout промяна, докато native call стекът се разгъне. Page событието обикновено изстрелва, докато PDFium все още обработва вход: потребителят е кликнал бутон Add Row, кликът е пуснал скрипт, скриптът е сменил броя инстанции, а layout-ът е завършил в същото native извикване. Затварянето и повторното отваряне на page handle в този момент би освободило обект, който извикващият все още ползва. Преди v3.126.2 viewer-ът само се инвалидираше, така че показваният page handle можеше да продължи да сочи pre-layout състояние, а ако потребителят е бил на последната страница, когато тя изчезна, избраният номер на страница излизаше от диапазона

Отложения refresh работи на няколко малки стъпки, и те обясняват поведението, което виждате от host:

  1. Callback-ът на page събитието маркира изгледа като имащ чакащ XFA layout refresh и пуска приватно window съобщение; повторни събития преди пристигането на съобщението се сливат в един refresh
  2. Изглед без още window handle пази чакащия флаг и пуска съобщението от CreateWnd, докато смяната на документ, деактивирането или унищожаването на изгледа изчистват флага
  3. Когато съобщението пристигне, изгледът изчиства текстовата селекция, search highlight-а и индекса на фокусираното поле, защото и трите сочеха старото layout
  4. Избраната страница се clamp-ва към новия PageCount; сменен номер на страница минава през нормалното превключване, иначе текущата страница се презарежда, а fit режимът се прилага пак
  5. Ако layout-ът не остави нито една страница, изгледът разтоварва стария си page handle вместо да рисува страница, която вече не съществува
Диаграма на отложен XFA refresh в TPdfView на PDFium Component, в която page събитие вътре в native layout call стек само маркира чакащ refresh и пуска window съобщение, което по-късно изчиства остарялото селекционно състояние, clamp-ва страницата към новия PageCount и презарежда или разтоварва page handle
Презареждането чака native call стекът да се разгъне: пуснато съобщение слива повторните събития, после изгледът clamp-ва страницата, презарежда я и вдига OnPageChange

Същото ограничение важи и за вашия код. OnXfaPageCountChanged върви вътре в този native layout callback, затова се отнасяйте към него като към известие: обновявайте там етикети, spinner диапазони и toolbar състояние, а всичко по-тежко — като затваряне на документа или отваряне на друг — сложете на опашка с пуснато съобщение, за да тръгне след като callback-ът се върне. TPdfView.OnPageChange после ви казва кога изгледът реално е презаредил страницата, а четенето на PdfView1.PageNumber в този момент ви дава clamp-натата стойност. Обхождането с Tab и FormType проверките, които form viewer върти при отваряне, са разгледани в навигацията по PDF form полета с PDFium Component

Защо клик върху Full XFA поле вдига "Cannot open text page"?

Full XFA страниците нямат PDF text страница, а преди v3.126.2 default-ната text селекция и разпознаване на връзки в viewer-а пак се опитваха да заредят такава. С TPdfView.AllowUserTextSelection на неговия default True посочването питаше text слоя за знак под мишката, а mouse-up клик пускаше автоматична URL проба върху текста на страницата. На Full XFA страница text страницата не може да се отвори, така че обикновен клик в поле можеше да завърши с exception Cannot open text page. От v3.126.2 насам и двата вътрешни пътя връщат без резултат, когато TPdf.FormType е ftXfaFull и XFA runtime-ът е наличен, така че default настройките работят, а въвеждането в полетата остава налично

Изключването на AllowUserTextSelection за Full XFA документи си остава разумен UI избор, защото няма текст на страницата за селектиране и drag жестовете не бива да пускат режим на селекция. Не е заместител на обновяването обаче: на по-ранни версии URL пробата при клик не зависеше от това свойство, така че viewer можеше да се блъсне в същия exception и със селекцията изключена

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormType чете отворения документ, затова викай това след FPdf.Active := True
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // На Full XFA страници няма PDF text слой; полетата остават редактируеми
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

Писането си поиска собствена поправка в v3.126.2. Native XFA текстовият редактор не заменя селекция, когато получи знак: FORM_OnChar вмъква на каретката, а Backspace изтрива единичен знак, така че селектиране на стойност и писане върху нея даваше стар и нов текст един до друг. PDFium Component вече помни, че кликът е кацнал на XFA текстово поле, и насочва въведените знаци, Backspace и Delete през FORM_ReplaceSelection, когато съществува селекция и документът дава fill-forms или modify разрешение. Дали read-only XFA поле може да се смени си решава native редакторът, така че поле, маркирано read-only във формата, пази стойността си дори в документ, който иначе позволява попълване. Задаването на TPdfView.AllowFormEvents на False също спира това клавиатурно насочване, което пази read-only viewer read-only

Бърза справка: dynamic XFA в Delphi viewer

СимптомПричинаПоправено в
Броят страници показва 1, след като формата порасне до две странициNative page событието предава добавена/премахната дельта, не общ бройv3.126.1 (wrapper)
Рамката на полето мести, въведеният текст и hit area изоставатЗареден widget прескочи relayout след самосравнениеv3.126.1 (Windows V8 библиотеки)
Viewer рисува или насочва input към pre-layout състояние на страницатаPage handle не е презареден след repaginationv3.126.2 (отложен refresh)
Клик в поле вдига Cannot open text pageТекстова селекция и URL проба върху страници без text слойv3.126.2
Писане върху селектирана стойност долепя вместо да замениNative XFA редакторът вмъква на кареткатаv3.126.2
  • Задайте EnableV8Engine на True преди който и да е документ да се зареди, и обработете OnXfaRuntimeMissing за случая, в който чистата библиотека е била заредена първа
  • Четете общия брой от TPdf.PageCount или от параметъра NewCount на OnXfaPageCountChanged; никога не събирайте и не вадете бройки страници сами
  • Държите OnXfaPageCountChanged handler-а лек, защото той върви вътре в native layout callback
  • Синхронизирайте индикатора на текущата страница в TPdfView.OnPageChange, който изстрелва след като отложеното презареждане clamp-не номера на страницата
  • Деплойвайте Windows V8 DLL-ите v3.126.1 или по-нови заедно с unit-ите; поправката на widget relayout живее в native кода
  • Тествайте с форма, която реално мени броя си страници и мести редактирано поле през граница между страници, защото примерите с фиксирана дължина крият всеки бъг от този списък

Dynamic XFA превръща броя страници и геометрията на полетата в живи стойности, а viewer остава верен само когато ги взима от завършеното layout и презарежда страници в безопасен момент. PDFium Component се грижи и за двете вътре в TPdf и TPdfView, така че host-ът само трябва да слуша. Детайли и изтегляния са на страницата на PDFium Component за Delphi