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

Dynamic XFA в PDFium Component: число страниц — это дельта

Когда динамическая 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 согласны

Схема dynamic XFA в PDFium Component, где добавление строки перепагинирует форму с одной страницы в две и FFI_PageEvent передаёт abs(new минус old) как дельту, так что старая обёртка сообщала TPdf.PageCount 1, а v3.126.1 читает FPDF_GetPageCount и докладывает верный итог
Нативное событие страницы докладывает дельту добавленного-или-удалённого, а не итог, поэтому v3.126.1 игнорирует аргумент и читает завершённую раскладку, прежде чем поднять OnXfaPageCountChanged
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 оставляет смещённые области попадания на месте. Регрессионная проверка, подвигавшая фикс, сперва правит выжившую строку на не-дефолтное значение, а затем требует то значение на новом месте поля, потому что строка, пересобранная дефолтами, иначе выглядела бы как успех

Схема relayout widget-ов в PDFium Component, противопоставляющая старое самосравнение, где GetWidgetRect и RecacheWidgetRect возвращали один общий член и переехавшие widget-ы пропускали PerformLayout, и проверку копированием по значению в Windows V8 от v3.126.1, переставляющую редактор и область попадания мыши на перерисованную рамку
Сравнение прямоугольника с самим собой не проваливается никогда, поэтому рамка переезжала, пока набранный текст и щелчки оставались позади, — пока проверка не стала сперва сохранять копию по значению

Как TPdfView перезагружает страницы, не вырывая хэндл из-под PDFium?

С v3.126.2 TPdfView откладывает перезагрузку страниц, следующую за сменой XFA-раскладки, до тех пор, пока нативный стек вызовов не раскрутится. Событие страницы обычно срабатывает, пока PDFium ещё обрабатывает ввод: пользователь щёлкнул кнопку Add Row, щелчок запустил скрипт, скрипт поменял счётчик инстансов, и раскладка завершилась внутри того же нативного вызова. Закрыть и переоткрыть хэндл страницы в тот момент значило бы освободить объект, которым звонящий ещё пользуется. До v3.126.2 вьюер лишь инвалидовал себя, так что показываемый хэндл страницы мог продолжать указывать на до-раскладочное состояние, а если пользователь стоял на последней странице, когда она исчезла, выбранный номер страницы вылезал за диапазон

Отложенный refresh работает в несколько маленьких шагов, и они объясняют поведение, которое вы видите с хоста:

  1. Коллбэк события страницы помечает вьюер как ожидающий XFA-refresh раскладки и постит приватное оконное сообщение; повторные события до прихода сообщения сливаются в один refresh
  2. Вьюер без оконного хэндла хранит ожидающий флаг и постит сообщение из CreateWnd, а смена документа, деактивация вьюера или его уничтожение чистят флаг
  3. Когда сообщение приходит, вьюер чистит выделение текста, подсветку поиска и индекс сфокусированного поля, потому что все три относились к старой раскладке
  4. Выбранная страница зажимается в новый PageCount; сменившийся номер страницы идёт через обычное переключение страниц, иначе перезагружается текущая, и режим fit применяется заново
  5. Если раскладка вовсе не оставляет страниц, вьюер выгружает старый хэндл страницы вместо отрисовки страницы, которой больше нет
Схема отложенного XFA-refresh у TPdfView в PDFium Component, где событие страницы внутри нативного стека вызовов раскладки лишь помечает ожидающий refresh и постит оконное сообщение, которое позже чистит устаревшее состояние выделения, зажимает страницу в новый PageCount и перезагружает или выгружает хэндл страницы
Перезагрузка ждёт, пока нативный стек вызовов раскрутится: постнутое сообщение сливает повторные события, затем вьюер зажимает страницу, перезагружает её и поднимает OnPageChange

То же ограничение касается вашего кода. 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