Когда динамическая XFA-форма во вьюере на Delphi добавляет или убирает страницы, PDFium Component с v3.126.1 сообщает новый итог через TPdf.PageCount и TPdf.OnXfaPageCountChanged, потому что нативное событие страницы несёт дельту «добавлено/удалено», а не итог. Windows V8-библиотеки в v3.126.1 также двигают области попадания ввода за переехавшими полями, а v3.126.2 перезагружает устаревшие хэндлы страниц после возврата коллбэка раскладки. Баг-репорт, с которого всё началось, — форма заявки на расходы: щёлкните Add Row дважды, форма вырастает до двух страниц, а индикатор страниц горделиво читает «1 из 1». Наберите в поле, переехавшем на страницу 2, — и нажатия проваливаются куда-то невидимое. Ни того ни другого не было на формах фиксированной длины, с которых все сперва тестируются, а причины того стоит знать, если вы встраиваете вьюер форм
Что происходит, когда динамическая XFA-форма перепагинирует?
У динамической XFA-формы нет фиксированного списка страниц, так что её число страниц — выход раскладки и может меняться при каждой правке данных пользователем. XFA 3.3 описывает форму как дерево subform; повторяющийся subform управляется instanceManager, а скрипт вроде _Row.addInstance() клонирует ещё одну строку. Процессор раскладки затем заново вливает содержимое в области страниц, что может добавить страницу, убрать страницу или вытолкнуть существующие поля на другую страницу. ISO 32000-1 §12.7.8 определяет лишь то, как XFA-пакеты едут внутри PDF; всё, что происходит дальше, — вотчина XFA-движка, которым в PDFium Component служит собственная XFA-раскладка PDFium, работающая в процессе хоста. Вьюер на Delphi потому имеет дело с документом, у которого число страниц, размеры страниц и позиции widget-ов — всё живое состояние. Три вещи ломаются, когда хост полагает иначе:
- Число страниц, закэшированное хостом для навигации, диапазонов прокрутки и спиннеров страниц, устаревает, а то и хуже — обновляется неверным числом
- Переехавшие поля рисуют рамку на новом месте, а редактор и область попадания мыши остаются на старых координатах
- Вьюер держит хэндл страницы, которую раскладка уже заменила, так что щелчки и отрисовка уходят на страницу, которой в той форме больше нет
Сохранение правок строк между save и повторным открытием — отдельная проблема со своими правилами; эта статья остаётся при том, что происходит в рантайме внутри вьюера
Какой runtime PDFium нужен dynamic XFA?
Dynamic XFA в PDFium Component требует сборки V8/XFA нативной библиотеки, выбираемой глобальной переменной EnableV8Engine в модуле PDFium до загрузки первого документа. Процесс обязывается одной DLL при первой загрузке библиотеки любым TPdf, а обычная сборка PDFium не способна запустить XFA-движок вовсе. При открытии документа TPdf действительно подглядывает в файл за XFA-маркерами и переключается на сборку V8 автоматически, но лишь если в процессе ещё не загружена обычная библиотека. Когда обязательство уже ушло не туда, единожды срабатывает TPdf.OnXfaRuntimeMissing, чтобы хост сказал пользователю перезапуститься. Явная установка флага при старте снимает гадание. Структура коллбэков FPDF_FORMFILLINFO, несущая XFA-события, тоже обязана совпадать с DLL; подоплёка — в статье о FPDF_FORMFILLINFO version 2 и ABI XFA-коллбэков, а обнаружение XFA-форм и чтение их пакетов разбирает, как различать типы форм до открытия вьюера
uses
PDFium;
procedure TClaimForm.FormCreate(Sender: TObject);
begin
// Решайте до того, как первый TPdf загрузит нативную библиотеку:
// процесс не может позже переключиться с 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 нативного события страницы как общий итог документа, а тот аргумент на самом деле — абсолютная разница между новым и старым числами страниц. PDFium поднимает FFI_PageEvent по завершении прохода раскладки с типом события «страница добавлена» или «страница удалена»; внутри он сперва обновляет своё сохранённое число страниц, а затем передаёт abs(new - old). На начальной раскладке старое число — ноль, так что дельта равна итогу, и трёхстраничный статический образец честно сообщает три страницы. Именно поэтому формы-тесты фиксированной длины никогда не вскрывали баг. Когда динамическая форма впервые вырастает с одной страницы до двух, дельта — 1, и обёртка записывала и в TPdf.PageCount, и в параметр NewCount у OnXfaPageCountChanged единицу. Удаление строки из трёхстраничной формы давало тот же сорт бессмыслицы в обратную сторону
Накопление дельты поверх предыдущего значения — тоже не безопасная починка. Порядок коллбэков инициализации и раскладки означает, что обёртка не всегда может доверять своему прежнему числу как базовой линии, так что бегущая сумма может уплыть. С v3.126.1 коллбэк игнорирует аргумент как счёт и зовёт FPDF_GetPageCount на документе, читающем итог из только что завершённой раскладки. Затем он чистит закэшированные сцены страниц, сохраняет тот итог как XFA-переопределение числа страниц за TPdf.PageCount и лишь после этого поднимает OnXfaPageCountChanged. К тому моменту, когда ваш обработчик бежит, NewCount и FPdf.PageCount согласны
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
// v3.126.1+: NewCount — итог завершённой раскладки, никогда не дельта.
// Это бежит внутри коллбэка раскладки PDFium: обновляйте лишь 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-форм, чья раскладка меняется в рантайме. Документы Static XFA и AcroForm никогда его не поднимают, так что вьюер, обслуживающий и те и другие, может оставить тот же обработчик привязанным. Оставить его непривязанным тоже безопасно; переопределение за TPdf.PageCount применяется в любом случае, а событие существует, чтобы хост мог освежить своё закэшированное
Почему окно ввода остаётся на старой странице, когда поле переезжает?
Рамка переехала, а редактор нет, потому что нативный XFA-нотификатор сравнивал прямоугольник с самим собой. Когда раскладка меняет геометрию уже загруженного widget-а, PDFium обязан заметить новый прямоугольник и позвать у widget-а PerformLayout, что переставляет текстовый редактор и его область попадания. Проверка сравнивала GetWidgetRect() с RecacheWidgetRect(). Обе функции возвращают const-ссылку на один и тот же член, а recache перезаписывает тот член на месте, так что сравнение всегда видело два одинаковых значения, и загруженные widget-ы пропускали свой relayout
Симптом всплыл, когда тест поменял высоту subform так, что существующие поля переехали на следующую страницу. На обеих V8-архитектурах рамка поля рисовалась на новом месте, а набранный текст и область попадания мыши оставались на прежней координате Y. Явный relayout не чинил, и перезагрузка страницы тоже, потому что widget продолжал верить, что его геометрия актуальна. Windows V8-библиотеки, поставляемые с v3.126.1, копируют старый прямоугольник по значению перед recache и сравнивают ту копию, так что переехавшие widget-ы делают relayout, и правленое значение появляется ровно там, где рамка. Это нативный фикс: он едет с DLL, так что обновление Pascal-юнитов при сохранении старой pdfium.v8.dll оставляет смещённые области попадания на месте. Регрессионная проверка, подвигавшая фикс, сперва правит выжившую строку на не-дефолтное значение, а затем требует то значение на новом месте поля, потому что строка, пересобранная дефолтами, иначе выглядела бы как успех
Как TPdfView перезагружает страницы, не вырывая хэндл из-под PDFium?
С v3.126.2 TPdfView откладывает перезагрузку страниц, следующую за сменой XFA-раскладки, до тех пор, пока нативный стек вызовов не раскрутится. Событие страницы обычно срабатывает, пока PDFium ещё обрабатывает ввод: пользователь щёлкнул кнопку Add Row, щелчок запустил скрипт, скрипт поменял счётчик инстансов, и раскладка завершилась внутри того же нативного вызова. Закрыть и переоткрыть хэндл страницы в тот момент значило бы освободить объект, которым звонящий ещё пользуется. До v3.126.2 вьюер лишь инвалидовал себя, так что показываемый хэндл страницы мог продолжать указывать на до-раскладочное состояние, а если пользователь стоял на последней странице, когда она исчезла, выбранный номер страницы вылезал за диапазон
Отложенный refresh работает в несколько маленьких шагов, и они объясняют поведение, которое вы видите с хоста:
- Коллбэк события страницы помечает вьюер как ожидающий XFA-refresh раскладки и постит приватное оконное сообщение; повторные события до прихода сообщения сливаются в один refresh
- Вьюер без оконного хэндла хранит ожидающий флаг и постит сообщение из
CreateWnd, а смена документа, деактивация вьюера или его уничтожение чистят флаг - Когда сообщение приходит, вьюер чистит выделение текста, подсветку поиска и индекс сфокусированного поля, потому что все три относились к старой раскладке
- Выбранная страница зажимается в новый
PageCount; сменившийся номер страницы идёт через обычное переключение страниц, иначе перезагружается текущая, и режим fit применяется заново - Если раскладка вовсе не оставляет страниц, вьюер выгружает старый хэндл страницы вместо отрисовки страницы, которой больше нет
То же ограничение касается вашего кода. OnXfaPageCountChanged бежит внутри того нативного коллбэка раскладки, так что обращайтесь с ним как с уведомлением: обновляйте там подписи, диапазоны спиннеров и состояние тулбара, а всё потяжелее — закрыть документ, открыть другой — ставьте в очередь постнутым сообщением, чтобы это бежало после возврата коллбэка. TPdfView.OnPageChange затем скажет, когда вьюер реально перезагрузил страницу, и чтение PdfView1.PageNumber в тот момент даст зажатое значение. Обход по Tab и проверки FormType, которые вьюер форм гоняет при открытии, разобраны в статье о навигации по полям форм PDF с PDFium Component
Почему щелчок по полю Full XFA бросает «Cannot open text page»?
У страниц Full XFA нет PDF-страницы текста, и до v3.126.2 вьюерные выделение текста и поиск ссылок по умолчанию пытались её загрузить всё равно. При TPdfView.AllowUserTextSelection в его дефолте True наведение спрашивало у текстового слоя символ под мышью, а mouse-up щелчок гнал автоматический URL-зонд по тексту страницы. На странице Full XFA страницу текста открыть нельзя, так что обычный щелчок в поле мог кончиться исключением Cannot open text page. С v3.126.2 оба внутренних пути не возвращают результата, когда TPdf.FormType — ftXfaFull и XFA-runtime доступен, так что дефолтные настройки работают, и ввод в поля остаётся доступным
Выключение AllowUserTextSelection для документов Full XFA остаётся разумным UI-решением: текста страницы для выделения нет, а жесты перетаскивания не должны стартовать режим выделения. Но это не замена обновлению: на ранних версиях URL-зонд по щелчку от того свойства не зависел, так что вьюер мог поймать то же исключение и с выключенным выделением
procedure TClaimForm.ConfigureViewerForForm;
begin
// FormType читает открытый документ, зовите это после FPdf.Active := True
if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
begin
// На страницах Full XFA нет PDF-текстового слоя; поля остаются редактируемыми
PdfView1.AllowUserTextSelection := False;
StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
[FPdf.PageCount]);
end
else
PdfView1.AllowUserTextSelection := True;
end;
Набору текста понадобился собственный ремонт в v3.126.2. Нативный XFA-редактор текста не заменяет выделение при получении символа: FORM_OnChar вставляет в каретку, а Backspace удаляет одиночный символ, так что выделение значения с набором поверх давало старый и новый текст бок о бок. Теперь PDFium Component помнит, что щелчок приземлился на XFA-текстовое поле, и направляет набранные символы, Backspace и Delete через FORM_ReplaceSelection, когда выделение существует, а документ дозволяет заполнение форм или модификацию. Может ли измениться read-only XFA-поле, по-прежнему решает нативный редактор, так что поле, помеченное в форме read-only, хранит своё значение даже в документе, который иначе дозволяет заполнение. Установка TPdfView.AllowFormEvents в False тоже останавливает эту клавиатурную маршрутизацию, что сохраняет read-only вьюер read-only
Шпаргалка: dynamic XFA во вьюере на Delphi
| Симптом | Причина | Исправлено в |
|---|---|---|
| Число страниц показывает 1 после того, как форма выросла до двух | Нативное событие страницы передаёт дельту добавленного/удалённого, а не итог | v3.126.1 (обёртка) |
| Рамка поля переезжает, набранный текст и область попадания остаются позади | Загруженный widget пропустил relayout после самосравнения | v3.126.1 (Windows V8-библиотеки) |
| Вьюер рисует или направляет ввод в до-раскладочное состояние страницы | Хэндл страницы не перезагружен после перепагинации | v3.126.2 (отложенный refresh) |
| Щелчок в поле бросает Cannot open text page | Выделение текста и URL-зонд на страницах без текстового слоя | v3.126.2 |
| Набор поверх выделенного значения дописывает вместо замены | Нативный XFA-редактор вставляет в каретку | v3.126.2 |
- Ставьте
EnableV8EngineвTrueдо загрузки любого документа и обрабатывайтеOnXfaRuntimeMissingна случай, когда сперва загрузилась обычная библиотека - Читайте итог из
TPdf.PageCountили параметраNewCountуOnXfaPageCountChanged; никогда не складывайте и не вычитайте числа страниц сами - Держите обработчик
OnXfaPageCountChangedлёгким, потому что он бежит внутри нативного коллбэка раскладки - Синхронизируйте индикатор текущей страницы в
TPdfView.OnPageChange, который срабатывает после того, как отложенная перезагрузка зажмёт номер страницы - Разворачивайте Windows V8 DLL v3.126.1 или новее вместе с юнитами; фикс relayout widget-ов живёт в нативном коде
- Тестируйтесь на форме, которая реально меняет число страниц и перетаскивает правленое поле через разрыв страницы, потому что образцы фиксированной длины прячут каждый баг из этого списка
Dynamic XFA превращает число страниц и геометрию полей в живые значения, и вьюер остаётся корректным, лишь когда берёт их из завершённой раскладки и перезагружает страницы в безопасный момент. PDFium Component ведает обоим внутри TPdf и TPdfView, так что хосту остаётся слушать. Детали и загрузки — на странице PDFium Component for Delphi product page