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

Конвертация статических форм XFA в поля AcroForm в Delphi

PDFium Component конвертирует статические формы XFA в обычные поля AcroForm из Delphi через FlattenPdfXfaToAcroForm в модуле FPdfDocumentXfaFlatten. Функция читает пакеты template и datasets из XFA, отображает каждое абсолютно позиционированное поле в терминальное поле AcroForm с собственным appearance stream и дописывает результат инкрементальной редакцией, так что конвертированный PDF открывается как обычная форма во вьюверах, никогда не поддерживавших XFA

Мотивация — обычно тикет поддержки, а не пункт роадмапа. Государственная или страховая форма была сделана в дизайнере, сохранившем её как XFA, работает в одном настольном ридере, а везде прочем открывается пустой страницей с сообщением, что документ требует более нового вьювера. XFA объявлена устаревшей в PDF 2.0 (ISO 32000-2), браузеры и мобильные вьюверы её так и не реализовали, а сам PDFium рендерит XFA только сборкой с включённым V8. Статические формы XFA — те, чья раскладка зафиксирована во время дизайна, — несут в template достаточно геометрии, чтобы быть перестроенными в поля AcroForm, и ровно этот срез обслуживает конвертер

Что FlattenPdfXfaToAcroForm реально производит?

FlattenPdfXfaToAcroForm производит байтовую AcroForm-редакцию, дописанную к неизменённой копии источника, но никогда перезапись исходного файла. PDFium не выставляет публичного API создания полей форм, поэтому компонент вовсе не идёт через PDFium для этой работы: он читает активную редакцию собственным объектным читателем, строит словари полей, widget-аннотации, appearance XObject и ресурс шрифта /Helv как сырые PDF-объекты и сериализует их разреженным инкрементальным обновлением в смысле ISO 32000-1 §7.5.6

Новый словарь /AcroForm несёт /Fields, /DA и /DR и не несёт записи /XFA. Когда источник держит свой AcroForm косвенным объектом, тот объект переписывается на месте; встроенный словарь заменяется внутри каталога. В любом случае активная редакция больше не указывает на пакеты XFA, поэтому вьювер, понимающий обе модели, открывает поля AcroForm вместо попытки сверстать форму XFA. Исходные байты XFA остаются в ранней редакции — на то инкрементальное обновление и есть, — но ничто в текущей редакции на них не ссылается

Конвейер конвертации статических XFA в AcroForm в PDFium Component для Delphi: FlattenPdfXfaToAcroForm читает пакеты template и datasets, отображает абсолютно позиционированные поля в словари полей и виджеты и дописывает AcroForm разреженной инкрементальной редакцией без записи /XFA
FlattenPdfXfaToAcroForm никогда не трогает исходные байты: конвертированные поля, виджеты и appearance stream дописываются редакцией, чей /AcroForm просто перестаёт указывать на пакеты XFA
uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentXfaFlatten;

procedure ConvertXfaForm(const SourceFile, TargetFile: string);
var
  Source: TFileStream;
  Options: TPdfXfaFlattenOptions;
  OutputPdf: TBytes;
  Report: TPdfXfaFlattenReport;
  Fields: TPdfXfaFlattenFields;
begin
  Options := TPdfXfaFlattenOptions.Default;
  Options.MaxFields := 1024;   // по умолчанию 512, допустимый диапазон 1..4096

  Source := TFileStream.Create(SourceFile, fmOpenRead or fmShareDenyWrite);
  try
    if not FlattenPdfXfaToAcroForm(Source, Options, OutputPdf, Report, Fields) then
      raise Exception.Create('XFA conversion failed: ' + Report.ErrorText);
  finally
    Source.Free;
  end;

  TFile.WriteAllBytes(TargetFile, OutputPdf);
  Writeln(Format('%d template fields, %d bound to datasets, %d written on %d pages',
    [Report.TemplateFields, Report.DatasetBound, Report.FieldsWritten, Report.PagesWritten]));
end;

Функция возвращает False и заполняет Report.ErrorText для любого отвергнутого или кривого входа, а на выходе восстанавливает позицию исходного потока. Отмена — единственный исключительный путь, который пробрасывается: если срабатывает Options.CancellationToken, EPdfOperationCancelled перебрасывается после заполнения отчёта. TPdfXfaFlattenReport заодно несёт SourceSha256 и OutputSha256, что удобно, когда пакетному заданию надо доказать, какой вход дал какой конвертированный файл. TPdfXfaFlattenOptions.Default выставляет бюджет пакетов 8 МиБ, 512 полей и потолок вывода 512 МиБ; источник обязан быть незашифрованным

Чтение пакетов template и datasets

PDFium Component принимает обе раскладки, которые ISO 32000-1 §12.7.8 допускает для записи /XFA: массивную форму, чередующую имена пакетов и ссылки на потоки, и однострочную форму XDP, где один поток держит весь документ <xdp:xdp>. Потоки пакетов могут быть несжатыми или /FlateDecode без предиктора, а декомпрессия ограничена MaxPacketBytes

Однострочный случай — то место, где ломается наивная обработка XML. Дизайнеры обычно пишут <xfa:datasets> с префиксом пространства имён, тогда как template сидит без префикса, поэтому нарезка по литеральному имени тега находит один пакет и пропускает другой. Конвертер сопоставляет имена элементов и атрибутов по локальному имени — после отдельной проверки, что открывающие и закрывающие теги сходятся по полному имени с префиксом. Если вам нужно лишь посмотреть на пакеты, а не конвертировать их, руководство по обнаружению форм XFA и извлечению их пакетов с PDFium VCL покрывает тот read-only путь

Значения полей приходят из двух мест. Элемент <value> в template даёт значение по умолчанию, а пакет datasets даёт заполненные данные, привязанные по имени поля. В реальных формах имена полей повторяются, поэтому связыватель сравнивает цепочку именованных предков <subform> с путём предков в данных и берёт самое длинное непротиворечащее совпадение. Когда два листовых поля делят имя под разными подформами, AcroForm /T становится SubformPath.Name, так что оба поля остаются различимы после конвертации

Почему choiceList нуждается в явном флаге Combo?

У XFA и PDF противоположные значения по умолчанию для полей выбора, поэтому конвертер, не копирующий флагов, превращает каждый дропдаун в список. XFA <choiceList> с умолчальным open="userControl" или с open="onEntry" — дропдаун; поле PDF /FT /Ch без флагов — прокручиваемый список, и только флаг Combo (бит 18 в ISO 32000-1 Table 230, значение 131072, тот же бит, что PDFium называет FPDF_FORMFLAG_CHOICE_COMBO) делает его комбинированным боксом. Конвертер потому выставляет флаг Combo для дропдаун-случаев и оставляет флаги пустыми, только когда template говорит open="always" или open="multiSelect", что держит список постоянно раскрытым. Сборки до 3.181.1 путали значения Combo и Pushbutton и искали нестандартное open="on", так что сплющенные ими формы стоит прогнать заново

Решение по флагам поля выбора в конвертации статических XFA в AcroForm в PDFium Component: атрибут open со значением userControl или onEntry выставляет флаг Combo, бит 18, значение 131072, давая дропдаун-комбобокс, тогда как open always или multiSelect оставляет флаги пустыми для постоянно раскрытого списка
Без флага Combo XFA-дропдаун сплющивается в прокручиваемый список, поэтому конвертер читает атрибут open и выставляет бит 18 только для дропдаун-случаев

Остальной маппинг интерфейса следует таблицам флагов полей из ISO 32000-1 §12.7.4:

  • textEdit, numericEdit и dateTimeEdit становятся /FT /Tx; многострочное текстовое поле получает флаг Multiline (/Ff 4096, Table 228) и appearance stream с разбивкой по строкам
  • passwordEdit становится /FT /Tx с флагом Password (/Ff 8192) и appearance, рисующим по одной звёздочке на Unicode-скаляр, а не на UTF-16 кодовую единицу
  • checkButton становится чекбоксом /FT /Btn, чьё значение равно /Yes, когда привязанные данные равны 1, и /Off в остальных случаях
  • <exclGroup> становится радиородителем /FT /Btn /Ff 32768 (Table 226) с /V, /DV, /Opt и /Kids, плюс по одному дочернему виджету на члена со своими /AS и двухпозиционным /AP
  • imageEdit становится кнопкой, чей appearance рисует изображение; базовый JPEG встраивается дословно под /DCTDecode, а PNG декодируется и перекодируется в пиксели /FlateDecode с дополнительным /SMask, когда PNG несёт альфу или прозрачность tRNS

Значения, не являющиеся обычным печатаемым ASCII, включая переводы строк, пишутся как UTF-16BE строки <FEFF...>, поэтому японское имя или двухстрочный адрес переживают поездку. Выход Fields зеркалит записанное, что делает его удобным журналом аудита

procedure DumpConvertedFields(const Fields: TPdfXfaFlattenFields);
const
  KindNames: array[TPdfXfaFlattenFieldKind] of string = (
    'text', 'check', 'choice', 'image', 'numeric', 'dateTime', 'password', 'radio');
var
  I: Integer;
  F: TPdfXfaFlattenField;
begin
  for I := 0 to High(Fields) do
  begin
    F := Fields[I];
    Writeln(Format('%s.%s [%s] page %d at %.1f,%.1f size %.1f x %.1f',
      [F.SubformPath, F.Name, KindNames[F.Kind], F.PageIndex + 1, F.X, F.Y, F.W, F.H]));
    case F.Kind of
      fxfkChoice:
        Writeln(Format('  %d items, list box: %s', [Length(F.Choices), BoolToStr(F.ListBox, True)]));
      fxfkRadio:
        Writeln(Format('  %d radio widgets, value "%s"', [Length(F.RadioWidgets), F.Value]));
      fxfkImage:
        Writeln(Format('  %d image bytes', [Length(F.ImageData)]));
    else
      Writeln('  value "' + F.Value + '"');
    end;
  end;
end;

Геометрия, единицы измерения и маршрутизация страниц

Геометрия XFA конвертируется в пользовательское пространство PDF отражением каждого поля от высоты MediaBox его собственной страницы, потому что XFA меряет вниз от верха страницы, а PDF меряет вверх от низа. Смещения вложенных элементов <subform> накапливаются по мере спуска по template, и каждое поле обязано в итоге получить положительные ширину и высоту, иначе конвертация отвергается

Конвертация геометрии XFA в PDF в PDFium Component для Delphi: прямоугольник поля, отмеренный вниз от верхнего края в шаблоне XFA, отражается от высоты MediaBox его собственной страницы в пользовательское пространство PDF, где координата y растёт вверх от низа
Направленный вниз шаблон XFA и направленное вверх пользовательское пространство PDF встречаются в одном вычитании от высоты MediaBox — именно оно кладёт каждый конвертированный виджет на его спроектированную позицию

Измерения принимают единицы, которые реально выпускают дизайнеры: голые числа и pt, плюс in (72 пункта), mm (72 / 25.4), cm (72 / 2.54), pc (12), px (0.75) и mp (0.001, миллипункты). Разбор не зависит от локали, поэтому немецкая установка Windows не превращает 8.5in в бессмыслицу. Неизвестный суффикс единицы отвергается, вместо того чтобы читаться как пункты, что молча сместило бы поле

Маршрутизация страниц следует за <pageSet>: братские элементы <pageArea> нумеруются с единицы в порядке объявления и забирают вложенные в них поля, тогда как поля без предка <pageArea> остаются на первой странице. Каждая страница получает собственное обновление /Annots. Поле, маршрутизированное за последнюю страницу PDF, — ошибка, а не виджет, брошенный на первую страницу

Какие формы XFA конвертер отказывается брать?

Конвертер отказывает всему, чью раскладку нельзя узнать без запуска движка вёрстки XFA. Это значит: атрибут layout, отличный от position, на <subform> или <exclGroup> (проточные tb, lr-tb и компания, из которых строятся динамические формы), любой элемент <script> и любой вид интерфейса вне семи перечисленных выше. Динамические формы, растящие страницы во время выполнения, принадлежат другому инструментарию; статья о динамической репагинации XFA и счёте страниц в PDFium Component показывает, что влечёт та живая вёрстка

Отказ нарочит. У проточной подформы нет фиксированных координат, так что любая «конвертация» должна была бы выдумывать позиции, а скриптованная форма часто считает итоги или переключает видимость, чего статическая AcroForm воспроизвести не может. Выпуск PDF, выглядящего полным, но молча потерявшего вычисленный итог, хуже ясной ошибки в Report.ErrorText. Спецификация XFA 3.3 определяет куда больше этого подмножества, и конвертер не притворяется обратным

Грабли при записи и проверке AcroForm-редакции

Две проблемы вскрылись при постройке писателя, и обе применимы ко всякому, кто руками производит инкрементальные обновления. Первая: каталог обязан всегда входить в набор обновления, даже когда его байты не меняются, потому что AcroForm — косвенный объект, переписанный в другом месте. Разреженный писатель обновлений выводит trailer /Root из несомых им объектов, и обновление без корневого объекта даёт редакцию, которую вьюверы не могут заякорить

Вторая касается верификации. Загрузка конвертированного файла через обёртку PDFium триггерит инициализацию окружения форм, как только у документа появился /AcroForm, а хостовый pdfium.dll в тестовой установке не имел экспорта FORM_GetTextDirection, поэтому инициализация проваливалась раньше, чем хоть одно поле успевало провериться. Проверки обратного чтения потому используют объектный читатель компонента, разбирающий активную редакцию, не трогая формовый слой

uses
  System.SysUtils, System.Classes, FPdfCompress;

function HasActiveAcroForm(const Pdf: TBytes): Boolean;
var
  Stream: TBytesStream;
  Objects: TPdfActiveObjects;
  Revision: TPdfSparseRevisionInfo;
  ErrorText: string;
begin
  Stream := TBytesStream.Create(Pdf);
  try
    if not ReadPdfActiveObjects(Stream, TPdfActiveObjectReadOptions.Default,
      Objects, Revision, ErrorText) then
      raise Exception.Create(ErrorText);
    // активный каталог обязан ссылаться на AcroForm; встроенный AcroForm,
    // записанный конвертером, записи /XFA не несёт
    Result := Pos('/AcroForm', string(Revision.CatalogDictionary)) > 0;
  finally
    Stream.Free;
  end;
end;

Appearance stream нарочно просты: Helvetica на 10 пунктов для текстовых значений и значений выбора, обведённый квадрат для чекбоксов, закрашенный внутренний квадрат для выбранного состояния радио. Они делают конвертированную форму читаемой везде, но это не попиксельная копия исходного рендера XFA, поэтому шрифты, цвета и рамки, определённые в шаблоне, не переносятся. Если форма обязана выглядеть идентично выводу дизайнера, отрендерите её раз XFA-сборкой и сравните, прежде чем переводить боевой конвейер

FAQ

Нужна ли FlattenPdfXfaToAcroForm сборка PDFium с V8 или XFA?

Нет. FlattenPdfXfaToAcroForm разбирает пакеты и пишет редакцию кодом PDFium Component, поэтому работает со стандартным pdfium.dll и не исполняет ни одного XFA-скрипта

Справится ли конвертер с зашифрованными документами XFA?

Не напрямую. Зашифрованный источник отвергается с ошибкой, поэтому сперва расшифруйте документ и конвертируйте расшифрованную копию

Остаются ли конвертированные поля редактируемыми?

Да. Вывод содержит настоящие текстовые поля AcroForm, чекбоксы, радио, поля выбора и кнопки со значениями и appearance stream, поэтому пользователи могут продолжать заполнять форму в любом вьювере, умеющем AcroForm

Конвертация статических XFA, извлечение пакетов XFA и XFA-рантайм с включённым V8 — всё это части PDFium Component for Delphi and C++Builder, оборачивающего PDFium для проектов на Delphi, C++Builder и Lazarus