Технічна стаття

Dynamic XFA в PDFium Component: page count — це delta

Коли dynamic XFA форма в Delphi viewer додає чи прибирає сторінки, PDFium Component від v3.126.1 повідомляє нову суму через TPdf.PageCount і TPdf.OnXfaPageCountChanged, бо native page event несе added/removed delta, а не суму. Windows V8 бібліотеки в v3.126.1 також пересувають input hit areas за переїхалими fields, а v3.126.2 перезавантажує застарілі page handles після повернення layout callback. Bug-звіт, з якого все почалося, — форма expense claim: клацніть Add Row двічі, форма росте до двох сторінок, а індикатор сторінки гордо читає 1 of 1. Наберіть текст у field, що переїхав на сторінку 2, — і натискання клавіш приземляються десь невидимо. Нічого з цього не проявлялося на фіксованих зразках форм, з якими всі тестують спершу, а причини того варто знати, якщо ви вбудовуєте form viewer

Що стається, коли dynamic XFA форма репагінує?

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

  • Кількість сторінок, яку host кешує для навігації, scroll-діапазонів і page spinner-ів, застаріває, або, що гірше, оновлюється неправильним числом
  • Fields, що переїжджають, показують рамку в новій позиції, тоді як редактор і mouse hit area лишаються на старих координатах
  • Viewer тримає page handle, який layout замінив, тож кліки й малювання йдуть на сторінку, якої в тій формі більше немає

Збереження правок рядків крізь save та reopen — окрема проблема з власними правилами; ця стаття лишається з тим, що стається під час роботи всередині viewer

Кого PDFium runtime потребує dynamic XFA?

Dynamic XFA в PDFium Component потребує V8/XFA збірку native бібліотеки, яку обирає глобальна змінна EnableV8Engine в юніті PDFium до завантаження першого документа. Процес зобов’язується одній DLL, щойно будь-який TPdf уперше завантажує бібліотеку, і plain PDFium build взагалі не може запустити XFA engine. Коли документ відкривається, TPdf зазирає у файл за XFA-маркерами і перемикається на V8 build автоматично, але лише якщо жодна plain бібліотека ще не була завантажена в тому процесі. Коли зобов’язання вже пішло не туди, 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 event як загальну суму документа, а той аргумент насправді — абсолютна різниця між новою та старою кількістю сторінок. PDFium підіймає FFI_PageEvent після завершення layout pass з типом події page added чи page removed; внутрішньо він спершу оновлює свою збережену кількість сторінок, а потім передає abs(new - old). На початковому layout стара кількість — нуль, тож delta дорівнює сумі, і трьохсторінковий статичний зразок звітує три сторінки, як очікувалося. Саме тому фіксовані тестові форми ніколи не викривали bug. Перший раз, коли dynamic форма росте з однієї сторінки до двох, delta — 1, і wrapper ставив і TPdf.PageCount, і parameter NewCount у OnXfaPageCountChanged на 1. Прибирання рядка з трьохсторінкової форми продукувало той самий рід нісенітниці в інший бік

Нагромадження delta на попередньому значенні — теж не безпечний ремонт. Порядок ініціалізації та layout callback означає, що wrapper не завжди може довіряти своїй ранішій кількості як базовій, тож поточна сума може дрейфувати. Від v3.126.1 callback ігнорує аргумент як кількість і викликає FPDF_GetPageCount на документі, який читає суму з layout, що щойно завершився. Потім він чистить кешовані page scenes, зберігає ту суму як XFA page-count override за TPdf.PageCount, і лише після цього підіймає OnXfaPageCountChanged. До моменту, коли ваш handler працює, NewCount і FPdf.PageCount згодні

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

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // Спрацьовує після кожного перезавантаження сторінки, зокрема deferred 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 box лишається на старій сторінці, коли field переїжджає?

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

Симптом вийшов на поверхню, коли тест змінив висоту subform так, що наявні fields переїхали на наступну сторінку. На обох V8 архітектурах рамка field малювалася в новій позиції, тоді як набраний текст і mouse hit area лишалися на попередній координаті Y. Явний relayout не виправляв, і перезавантаження сторінки теж, бо widget все ще вірив, що його геометрія актуальна. Windows V8 бібліотеки, що йдуть із v3.126.1, копіюють старий прямокутник за значенням перед recache і порівнюють ту копію, тож переїхалі widget-и роблять relayout, і відредаговане значення з’являється рівно там, де рамка. Це native fix: він подорожує з DLL, тож оновлення Pascal-юнітів зі старішою pdfium.v8.dll лишає неправильно поставлені hit areas на місці. Regression-перевірка, що призвела до виправлення, спершу редагує рядок, що вижив, у не-дефолтне значення, а потім вимагає того значення в новій локації field, бо рядок, перебудований дефолтними значеннями, інакше виглядав би як pass

Діаграма relayout widget у PDFium Component, що протиставляє старе self-порівняння, де GetWidgetRect і RecacheWidgetRect повертали один спільний member, тож переїхалі widget-и пропускали PerformLayout, із copy-by-value перевіркою Windows V8 у v3.126.1, що перепозиціоновує редактор і mouse hit area на перемальовану рамку
Порівняння прямокутника з самим собою ніколи не провалюється, тож рамка переїжджала, поки набраний текст і кліки лишалися позаду, доки перевірка не почала зберігати копію за значенням

Як TPdfView перезавантажує сторінки, не витягуючи handle з-під PDFium?

Від v3.126.2 TPdfView відкладає перезавантаження сторінки, що йде за зміною XFA layout, доки native call stack не розкрутиться. Page event зазвичай спрацьовує, поки PDFium усе ще опрацьовує ввід: користувач клацнув кнопку Add Row, клік прогнав скрипт, скрипт змінив лічильник instance-ів, і layout завершився всередині того самого native виклику. Закриття та повторне відкриття page handle у той момент звільнило б об’єкт, який caller усе ще вживає. До v3.126.2 viewer лише інвалідував себе, тож показаний page handle міг продовжувати вказувати на до-layout стан, а якщо користувач був на останній сторінці, коли та зникла, обраний номер сторінки виходив за межі

Відкладений refresh працює кількома малими кроками, і вони пояснюють поведінку, яку ви бачите з host:

  1. Callback page event мітить view як такий, що має pending XFA layout refresh, і постить приватне window message; повторні події до прибуття повідомлення зливаються в один refresh
  2. View без window handle поки що тримає pending-прапорець і постить повідомлення з CreateWnd, тоді як зміна документа, деактивація view чи його знищення чистять прапорець
  3. Коли повідомлення приходить, view чистить виділення тексту, search highlight та індекс focused field, бо всі три стосувалися старого layout
  4. Обрана сторінка обрізається до нової PageCount; номер сторінки, що змінився, йде через звичайне переключення сторінки, інакше поточна сторінка перезавантажується, і fit mode застосовується знову
  5. Якщо layout залишає взагалі без сторінок, view вивантажує свій старий page handle замість малювання сторінки, якої більше немає
Діаграма deferred XFA refresh у PDFium Component TPdfView, де page event всередині native layout call stack лише мітить pending refresh і постить window message, який пізніше чистить застарілий стан виділення, обрізає сторінку до нової PageCount і перезавантажує чи вивантажує page handle
Перезавантаження чекає, доки native call stack розкрутиться: пост-нуте повідомлення зливає повторні події, потім view обрізає сторінку, перезавантажує її і підіймає OnPageChange

Та сама вимога стосується й вашого коду. OnXfaPageCountChanged працює всередині того native layout callback, тож ставтеся до нього як до нотифікації: оновлюйте там лейбли, діапазони spinner-ів і стан тулбара, а все важче — як-от закриття документа чи відкриття іншого — ставте в чергу через пост-нуте повідомлення, щоб воно працювало після повернення callback. TPdfView.OnPageChange тоді каже вам, коли view справді перезавантажив сторінку, і читання PdfView1.PageNumber у той момент дає обрізане значення. Tab-key обхід і перевірки FormType, які form viewer робить на відкритті, покриті в навігації PDF form field з PDFium Component

Чому клік у Full XFA field підіймає «Cannot open text page»?

Full XFA сторінки не мають PDF text page, і до v3.126.2 усталені text selection та link detection viewer усе одно намагалися її завантажити. З TPdfView.AllowUserTextSelection на його усталеному True наведення питало text layer про символ під мишею, а mouse-up клік гнав автоматичний URL probe по тексту сторінки. На Full XFA сторінці text page відкрити неможливо, тож звичайний клік у field міг завершитися exception Cannot open text page. Від v3.126.2 обидва внутрішні шляхи повертають без результату, коли TPdf.FormType — ftXfaFull і XFA runtime доступний, тож усталені налаштування працюють, а field input лишається доступним

Вимикати AllowUserTextSelection для Full XFA документів — усе ще розумний UI-вибір, бо тексту сторінки немає, чим виділяти, і drag-жести не мусять запускати режим виділення. Але це не заміна оновленню: на раніших версіях URL probe на клік не залежав від тієї властивості, тож 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 layer; fields лишаються редагованими
    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 text editor не замінює виділення, коли отримує символ: FORM_OnChar вставляє в каретку, а Backspace стирає один символ, тож виділення значення й набирання поверх продукувало старий і новий текст поруч. PDFium Component тепер пам’ятає, що клік приземлився на XFA text field, і маршрутизує набрані символи, Backspace і Delete через FORM_ReplaceSelection щоразу, коли виділення існує і документ дарує fill-forms чи modify дозвіл. Чи може змінюватися read-only XFA field, усе ще вирішує native editor, тож field, позначений read-only у формі, тримає значення навіть у документі, який інакше дозволяє заповнення. Постановка TPdfView.AllowFormEvents у False теж зупиняє той keyboard-маршрутизатор, що тримає read-only viewer read-only

Швидка довідка: dynamic XFA в Delphi viewer

СимптомПричинаВиправлено в
Кількість сторінок показує 1 після того, як форма росте до двох сторінокNative page event передає added/removed delta, а не сумуv3.126.1 (wrapper)
Рамка field переїжджає, набраний текст і hit area лишаються позадуЗавантажений widget пропускав relayout після self-порівнянняv3.126.1 (Windows V8 бібліотеки)
Viewer малює чи маршрутизує ввід у до-layout стан сторінкиPage handle не перезавантажено після репагінаціїv3.126.2 (deferred refresh)
Клік у field підіймає Cannot open text pageText selection і URL probe на сторінках без text layerv3.126.2
Набирання поверх виділеного значення додає замість заміниNative XFA editor вставляє в кареткуv3.126.2
  • Поставте EnableV8Engine у True до завантаження будь-якого документа і опікуйтеся OnXfaRuntimeMissing на випадок, коли plain бібліотека була завантажена першою
  • Читайте суму з TPdf.PageCount чи parameter NewCount у OnXfaPageCountChanged; ніколи не додавайте й не віднімайте кількості сторінок самі
  • Тримайте handler OnXfaPageCountChanged легким, бо він працює всередині native layout callback
  • Синхронізуйте індикатор поточної сторінки в TPdfView.OnPageChange, який спрацьовує після того, як deferred reload обріже номер сторінки
  • Розгортайте Windows V8 DLL v3.126.1 чи новіші разом з юнітами; fix relayout widget живе в native коді
  • Тестуйте формою, що справді змінює свою кількість сторінок і пересуває відредагований field крізь розрив сторінки, бо фіксовані зразки ховають кожен bug із цього списку

Dynamic XFA перетворює кількість сторінок і геометрію field на живі значення, і viewer лишається правильним лише коли бере їх із завершеного layout і перезавантажує сторінки в безпечний момент. PDFium Component опікується обома всередині TPdf і TPdfView, тож host-у лишається лише слухати. Деталі та завантаження — на сторінці продукту PDFium Component for Delphi