PDFium Component сохраняет правленые значения XFA-форм в точности, сквозь save и reopen, когда работает на Windows V8-рантайме pdfium.v8.dll, поставляемом с v3.125.2 и новее. Старые рантаймы добавляли к значениям полей переводы строк, урезали emoji до постороннего BMP-символа, беззвучно пропускали save-ы single-stream XFA и могли проглотить сорвавшийся финальный write. Один симптом reopen — вовсе не дефект библиотеки: динамическая форма, чей корневой subform не имеет restoreState="auto", пересобирает раскладку из шаблона
Баг-репорты по этому поводу выглядели одинаково. Клиент заполняет XFA-форму заявки во вьюере на Delphi, сохраняет, переоткрывает — и что-то слегка не так. Пустое поле комментариев теперь держит пустую строку, а после второго save — две. Имя, набранное с emoji, возвращается с глифом из private use area. Никто не получает ошибки — и именно это делает эти баги дорогими: уплывание всплывает неделями позже в чужом экспорте
Что ломается, когда XFA-форму сохраняют и переоткрывают?
Четыре отдельных дефекта нативного XFA save-пути вызывали уплывание значений, и каждый прятался за save-ом, выглядевшим успешным. Два пришли из сериализации, один — из раскладки хранения single-stream, и один — из самого PDF-писателя. Таблица отображает каждый симптом на его причину и на релиз, где PDFium Component его починил
| Симптом после reopen | Причина | Исправлено в |
|---|---|---|
| Пустое поле держит перевод строки; значения отращивают по новой строке за save | Оба XFA-писателя вставляли раскладочные новые строки после открывающих тегов | v3.125.2, pdfium.v8.dll |
| U+1F642 возвращается как U+F642, или emoji исчезает из form-пакета | 16-битное усечение wchar_t при декодировании; фильтрация суррогатов в сериализаторе формы | v3.125.2, pdfium.v8.dll |
| Правки в single-stream XFA-документе просто исчезли | Нативный save отверг потоковую раскладку, но возвращаемое значение проигнорировали | v3.125.2; комментарии и инструкции обработки хранятся с v3.126.0 |
| Обрезанный файл, хотя save отрапортовал успех | Финальная буферизованная запись сорвалась после того, как писатель уже вернул успех | рантайм V8 v3.125.2; обычный pdfium.dll v3.125.3 |
| Динамическая форма из трёх страниц переоткрывается как две | Корневой subform не запрашивает restoreState="auto" | Авторинг формы, не дефект библиотеки |
Более ранние материалы заключали, что правки полей XFA вообще нельзя персистировать с PDFium, что было верно для рантаймов того времени. Новый V8-рантайм сохраняет значения XFA нативно, так что правка, сделанная в живой форме, доходит до сохраняемого пакета datasets без хирургии пакетов на вашей стороне
Какой runtime PDFium сохраняет значения XFA?
Точность XFA save зависит от нативной DLL, а не от Delphi-обёртки, так что первая проверка — какой рантайм ваш процесс реально загрузил. PDFium Component поставляет по две Windows-сборки на архитектуру: обычный pdfium.dll, собранный без V8 и XFA, и pdfium.v8.dll, несущий JavaScript-движок и рантайм XFA-форм. XFA-форму способна исполнить только pdfium.v8.dll, так что всякий XFA-фикс, описанный здесь, живёт там — начиная с пересобранных Win32 и Win64 V8-библиотек в v3.125.2
Фикс финальной записи — общий код PDF-писателя, так что важен он и для обычных документов. v3.125.3 пересобрал обычные библиотеки pdfium.dll, чтобы нести ту же правку. Общий исходник — не доказательство общего поведения: пока бинарник не пересобран, старая DLL хранит старый баг
Вторая ловушка сидела в загрузчике. До v3.125.2 установка EnableV8Engine в True заставляла биндинг брать дефолтное имя pdfium.v8.dll и игнорировать полный путь в LibraryName. Приложение, целившееся в свежеразвёрнутый рантайм, могло продолжать грузить старую копию из другой папки. С v3.125.2 LibraryName, содержащий каталог, выбирает ровно тот файл в любом режиме движка, а отсутствующий путь даёт отказ вместо отката к другой поставляемой библиотеке
uses
System.SysUtils, PDFium;
procedure SelectXfaRuntime;
begin
// Каталог в LibraryName прибивает ровно этот файл (v3.125.2 и новее);
// если файла нет, загрузка поднимает исключение вместо отката
{$IFDEF WIN64}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
PDFium.EnableV8Engine := True;
PDFium.LoadLibrary; // падайте на старте, а не на первом save
end;
После открытия документа TPdf.XFA сообщает, что файл содержит XFA, а TPdf.XfaRuntimeAvailable — что загруженная DLL способна его исполнить. Если нужно ещё и отличать статические формы от динамических, TPdf.FormType возвращает ftXfaFull или ftXfaForeground; статью о обнаружении XFA-форм и извлечении XFA-пакетов в Delphi тот зонд разбирает подробно
Почему сохранённые XFA-поля обрастают лишними переводами строк?
Сохранённые XFA-поля обрастали переводами строк, потому что оба нативных XFA-писателя — общий писатель XML-элементов и сериализатор form-пакета — pretty-printили вывод с новой строкой после открывающих тегов. В большинстве XML тот пробел — косметика. В данных XFA — нет: когда пакет datasets разбирается заново, текст между <Comments> и </Comments> и есть значение поля, вместе с новой строкой. Пустое поле потому переоткрывалось с одиночным LF, а каждый следующий цикл save-reopen мог добавить ещё один
Очевидная починка — тримить значения при загрузке — была бы неверной. Пользователи вводят в XFA-поля ведущие пробелы, хвостовые пробелы и нарочитый многострочный текст, а блок адреса или код фиксированной ширины обязан выжить побайтно. Фикс v3.125.2 потому убирает лишь пробел, который сериализатор сам синтезировал вокруг тегов. Пользовательские значения, существующие текстовые узлы и секции CDATA проходят нетронутыми, так что " indented" остаётся с отступом, а нарочно пустое поле остаётся пустым
Почему emoji возвращается другим символом?
Emoji возвращался испорченным, потому что Windows wchar_t 16-битный, а два пути декодирования складывали полное скалярное значение Unicode в одиночный wchar_t. Так делали и UTF-8-декодер потока, и парсер числовых символьных ссылок вроде 🙂. U+1F642, слегка улыбающееся лицо, в 16 бит не влезает, старшие биты отваливались, и вместо него появлялся U+F642 — кодовая точка в Private Use Area, которую большинство шрифтов рисует коробкой или ничем
У сериализатора формы беда была обратная. Он фильтровал символы по одному wchar_t, видел две суррогатные кодовые единицы, недопустимые поодиночке, и выбрасывал обе, так что emoji исчезал из form-пакета целиком. В v3.125.2 декодер съедает каждое скалярное значение целиком и выдаёт правильную суррогатную пару. Когда остаётся лишь один выходной слот, он держит младший суррогат в ожидании и не рапортует конец потока, пока та единица буферизована. UTF-8-последовательность, разрезанная между блоками чтения, переезжает в следующее чтение вместо выбрасывания. Экспортёр формы теперь держит валидные суррогатные пары вместе, и числовые символьные ссылки тоже дают корректные пары
Тестовые данные из Latin-1 ничего из этого не показывают никогда, так что всякому XFA round-trip тесту нужен хотя бы один символ из дополнительной плоскости
Single-stream XFA и отказы save, которых никто не видел
Single-stream XFA-документ терял правки, потому что нативный save-хелпер отвергал ту раскладку хранения, а его звонящий игнорировал отказ. ISO 32000-1 §12.7.8 дозволяет записи /XFA словаря интерактивной формы быть либо массивом имён пакетов и потоков, либо единственным потоком, держащим весь XDP-документ. Массивы пакетов — обычный случай, но одиночные потоки совершенно легальны, и PDF save завершался, как будто ничего не случилось, при данных формы на старых значениях
С v3.125.2 V8-рантайм ведает поддерживаемым подмножеством single-stream. Он сперва экспортирует оба живых пакета, datasets и form, в область подготовки и валидирует их, и лишь затем подменяет совпадающие пакеты в исходном XDP. Прочие пакеты и объявления корневых namespace сохраняются. Если подготовка сорвалась, персистентный XFA-поток не трогается вовсе, и документ хранит отметку модификации
XML-комментариям и инструкциям обработки понадобился особый уход, потому что внутренний XML DOM их выбрасывает. В v3.125.2 их присутствие валило save целиком, а не теряло содержимое беззвучно. v3.126.0 хранит их: до разбора каждый комментарий или инструкцию обработки подменяют маркером, построенным из префикса, который не встречается нигде в исходном тексте. После подмены живых пакетов всякий маркер обязан встретиться ровно один раз, прежде чем восстановится исходный токен и запишется поток. Токены вне подменяемых пакетов потому хранят свой текст и порядок — включая токены в прологе, шаблоне и прочих пакетах
Некоторые входы по-прежнему отвергаются нарочно, и всякий отказ — явный провал save:
- Комментарии или инструкции обработки внутри живых пакетов
datasetsилиform— их исходные позиции невозможно отобразить в свежеэкспортированное содержимое - Объявления DTD и подписи XMLDSig — переписывание XDP не способно сохранить подпись XML валидной
- Некорректная кодировка UTF-8 или UTF-16, незакрытые теги, неверные символьные ссылки, неизвестные сущности и битые инструкции обработки — отвергаются вместо беззвучной починки
Вывод single-stream — UTF-8 и хранит содержательную модель XML, а не исходную байтовую раскладку или объявление кодировки
Последний дефект сидел ниже XFA. Нативный файловый писатель буферизует вывод 32-КиБ блоками и сбрасывал финальный неполный блок лишь в деструкторе — после того, как писатель документа уже рапортовал успех. Переполнение диска или I/O-ошибка на том последнем блоке были звонящему невидимы. С v3.125.2 в V8-рантайме и v3.125.3 в обычном тот финальный сброс — часть результата save, а отметка модификации XFA чистится лишь после настоящего успеха. На стороне Delphi TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean пишет во временный файл рядом с целью и подвигает его на место, лишь когда save вернёт True, так что сорвавшийся save оставляет прежний файл целым
Почему динамическая XFA-форма переоткрывается с меньшим числом страниц?
Динамическая XFA-форма переоткрывается с меньшим числом страниц, когда её корневой subform не объявляет restoreState="auto", а это решение при авторинге формы, а не дефект PDFium Component. В XFA 3.3 restoreState на корневом subform по умолчанию manual. Под manual XFA-процессор восстанавливает из сохранённого form-пакета лишь ограниченное состояние, а остальное оставляет скриптам автора. Сохранённые значения полей и счётчики инстансов повторяющихся subform всё же возвращаются, а геометрические свойства, выставленные в рантайме, — нет
Случай, вскрывший это, — трёхстраничная форма, чей скрипт вырастил subform до h="450pt". Сохранённый form-пакет держал новую высоту, значения и счётчики инстансов. Но на reopen раскладка пересобралась по шаблонным высотам, и форма перетекла на две страницы. Рантайм был прав: шаблон никогда не просил автоматического восстановления. Объявить его на корневом subform — и reopen чинится:
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form1" layout="tb" restoreState="auto">
<pageSet>
<pageArea name="Page1">
<contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
<medium stock="letter"/>
</pageArea>
</pageSet>
<subform name="Details" layout="tb" w="7.5in">
<!-- поля; скрипты могут менять h или добавлять инстансы в рантайме -->
</subform>
</subform>
</template>
Если шаблон не ваш, не латаите вокруг него во вьюере: форма, полагающаяся на режим manual, ждёт, что состояние пересоберут её собственные скрипты. Живая репагинация во время набора — отдельная тема, разобранная в статье о том, как PDFium Component следит за числом страниц dynamic XFA и переехавшими полями
Как проверить XFA save в Delphi?
Единственная надёжная проверка XFA save — переоткрыть сохранённый файл в свежем инстансе TPdf и прочитать сохранённые данные назад. TPdf.GetXfaDatasets возвращает пакет datasets так, как он хранится в документе, а не живую модель данных XFA, так что вызов до сохранения покажет старые значения. После переоткрытия он показывает ровно то, что записано. У single-stream документа нет отдельно названных пакетов: PDFium рапортует весь XDP одним пакетом с пустым именем, так что GetXfaPacketByName('datasets') и GetXfaDatasets не возвращают ничего, а фоллбэк читает полный поток через GetXfaFormPackets
uses
System.SysUtils, PDFium, FPdfXfa;
function ReadSavedXfaData(const FileName: string): string;
var
Pdf: TPdf;
Packets: TXfaPacketList;
Bytes: TBytes;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
Bytes := Pdf.GetXfaDatasets; // раскладка с массивом пакетов
if Length(Bytes) = 0 then
begin
Packets := Pdf.GetXfaFormPackets; // одиночный поток: один безымянный пакет
if Length(Packets) = 1 then
begin
SetLength(Bytes, Length(Packets[0].Content));
if Length(Bytes) > 0 then
Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
end;
end;
Result := TEncoding.UTF8.GetString(Bytes); // сохранённый вывод XDP — UTF-8
finally
Pdf.Free;
end;
end;
Затем save-подпрограмма коммитит отложенную правку, проверяет результат SaveAs и сравнивает переоткрытое значение. TPdf.ClearFormFieldFocus убивает фокус формы — тот момент, когда PDFium коммитит буфер правки сфокусированного поля. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean заполняет сфокусированное поле программно, но опирается на фокус, который обёртка ведёт через FocusFormField, обходящий аннотации widget-ов. У динамической XFA-страницы их обычно нет, так что туда текст обычно приходит клавиатурным вводом в TPdfView, а функция возвращает False, когда ни одно отслеживаемое поле не в фокусе
function XmlText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
end;
procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
Expected: string);
var
Saved: string;
begin
// Опциональное скриптовое заполнение; False значит, ни одно отслеживаемое поле не в фокусе
if (Pdf.FocusedFormFieldIndex >= 0) and
not Pdf.SetFocusedFormFieldText(Expected) then
raise EPdfError.Create('Could not write the focused field');
Pdf.ClearFormFieldFocus; // коммитит буфер правки
if not Pdf.SaveAs(FileName) then // включает финальный сброс (v3.125.2+)
raise EPdfError.CreateFmt('Saving %s failed', [FileName]);
Saved := ReadSavedXfaData(FileName);
if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
Saved) = 0 then
raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;
Считайте substring-тест дымовым. Пустой элемент может сериализоваться как <Tag/>, атрибуты могут появиться на элементах данных, а экранирование за пределами & и < — выбор сериализатора. Для продовых проверок грузите переоткрытый XML настоящим XML-парсером и сравнивайте текстовый узел привязанного элемента данных. Прогоните проверку и дважды подряд — дефект новой строки показал полную форму лишь во втором поколении
Шпаргалка: чек-лист точности XFA save
- Разворачивайте
pdfium.v8.dllиз v3.125.2 или новее для XFA-форм, а v3.125.3 или новее для обычногоpdfium.dll, чтобы фикс финальной записи был в обоих - Цельте
LibraryNameв полный путь и ставьтеEnableV8Engineв True; отсутствующий путь даёт отказ вместо загрузки другой копии - Подтверждайте
TPdf.XFAиTPdf.XfaRuntimeAvailableпосле открытия документа - Зовите
ClearFormFieldFocusпередSaveAs, чтобы сфокусированное поле закоммитилось - Никогда не игнорируйте Boolean-результат
SaveAs; False оставляет прежний файл на месте - Проверяйте переоткрытием в новом
TPdfи чтениемGetXfaDatasets, с фоллбэком наGetXfaFormPacketsдля single-stream XFA - Тестируйтесь на пустых значениях, ведущих пробелах, многострочном тексте,
&и символе дополнительной плоскости, через два поколения save - Ждите явных отказов save для DTD, XMLDSig и комментариев внутри живых пакетов single-stream XFA
- Если динамическая форма теряет рантаймовую геометрию при reopen, проверьте корневой subform на
restoreState="auto", прежде чем подозревать библиотеку
О структуре коллбэков, которой XFA-рантайм ждёт от хост-приложения, — FPDF_FORMFILLINFO version 2 и ABI XFA в Delphi. V8-рантайм, обёртка для Delphi и C++Builder и контрол вьюера — всё части PDFium Component for Delphi and C++Builder, включающего оба Windows-рантайма для Win32 и Win64