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

XFA в PDFium Component: новые строки, emoji и restoreState

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 save в PDFium Component, где писатель добавляет новую строку после открывающих тегов, переоткрывающий парсер читает LF между тегами Comments как значение поля, и каждый следующий save дописывает ещё один перевод строки, пока v3.125.2 не убирает лишь синтезированный сериализатором пробел
Один цикл save-reopen сажает первый перевод строки, и каждый следующий раунд добавляет ещё один — оттого уплывание показало полную форму лишь во втором поколении

Очевидная починка — тримить значения при загрузке — была бы неверной. Пользователи вводят в XFA-поля ведущие пробелы, хвостовые пробелы и нарочитый многострочный текст, а блок адреса или код фиксированной ширины обязан выжить побайтно. Фикс v3.125.2 потому убирает лишь пробел, который сериализатор сам синтезировал вокруг тегов. Пользовательские значения, существующие текстовые узлы и секции CDATA проходят нетронутыми, так что " indented" остаётся с отступом, а нарочно пустое поле остаётся пустым

Почему emoji возвращается другим символом?

Emoji возвращался испорченным, потому что Windows wchar_t 16-битный, а два пути декодирования складывали полное скалярное значение Unicode в одиночный wchar_t. Так делали и UTF-8-декодер потока, и парсер числовых символьных ссылок вроде &#x1F642;. U+1F642, слегка улыбающееся лицо, в 16 бит не влезает, старшие биты отваливались, и вместо него появлялся U+F642 — кодовая точка в Private Use Area, которую большинство шрифтов рисует коробкой или ничем

У сериализатора формы беда была обратная. Он фильтровал символы по одному wchar_t, видел две суррогатные кодовые единицы, недопустимые поодиночке, и выбрасывал обе, так что emoji исчезал из form-пакета целиком. В v3.125.2 декодер съедает каждое скалярное значение целиком и выдаёт правильную суррогатную пару. Когда остаётся лишь один выходной слот, он держит младший суррогат в ожидании и не рапортует конец потока, пока та единица буферизована. UTF-8-последовательность, разрезанная между блоками чтения, переезжает в следующее чтение вместо выбрасывания. Экспортёр формы теперь держит валидные суррогатные пары вместе, и числовые символьные ссылки тоже дают корректные пары

Схема обращения с суррогатами в PDFium Component, где U+1F642 приходит как UTF-16 пара D83D DE42 и два дефектных пути портят его: 16-битные декодеры wchar_t усекают скаляр до U+F642 в private use area, а сериализатор формы фильтрует одинокие суррогаты и выбрасывает emoji целиком
Windows wchar_t 16-битный, поэтому скаляр, требующий суррогатной пары, либо терял старшую половину, либо исчезал из пакета, пока оба пути не научились держать пары вместе

Тестовые данные из 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 XFA save в PDFium Component, где живые пакеты datasets и form экспортируются в область подготовки, валидируются, затем подменяются внутри исходного XDP с хранением комментариев через маркеры, тогда как срывы подготовки и входы вроде DTD или XMLDSig отвергают save явно
Подготовленный экспорт валидируется прежде, чем что-либо подменяется, так что сорвавшийся save оставляет персистентный XFA-поток нетронутым, а документ хранит отметку модификации

Вывод 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, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [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