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

Мультиселект-поля PDF в round trip FDF и XFDF (Delphi)

HotPDF гоняет значения мультиселект-листбокса через FDF и XFDF, удерживая значение поля массивом от начала до конца. Начиная с версии 2.755.0 ExportLoadedFormToFDF, ExportLoadedInterchangeToFDF и ExportLoadedFormToXFDF пишут каждую выбранную опцию собственной FDF-строкой или XFDF-элементом <value>, а парные методы импорта сверяют каждое значение с опциями поля и перестраивают индексы выбора /I, прежде чем что-то менять. Ничего по пути не склеивается в одну строку

Сбой, который это чинит, легко воспроизвести. Возьмите форму заказа с мультиселект-листбоксом опций продукта, дайте пользователю выбрать две из них, экспортируйте данные формы для бэк-офисной системы, затем импортируйте отредактированный файл обратно в PDF. До этого изменения листбокс возвращался пустым или неверным. Причина в том, что одна из экспортных значений содержала перевод строки, а старый путь сплющил выбор в одну строку с разделителями-переводами строк. Достать несколько выборов обратно из такой строки никогда не было надёжно, а с экспортным значением, которое само содержит перевод строки, это не работает в принципе

Почему склейка значений мультиселекта переводами строк ломает round trip?

Склейка выборов в одну строку выбрасывает границы между значениями, а значение может содержать разделитель, так что ни один импортёр не сможет правильно разрезать строку обратно. ISO 32000-1 §12.7.4.4 разрешает записи /V choice-поля быть либо единственной текстовой строкой, либо массивом текстовых строк, а листбокс с флагом MultiSelect (бит 22 в /Ff) использует массив, как только выбрано больше одной опции. Та же секция определяет /I как массив индексов опций с нуля в возрастающем порядке, которым вьюверы различают две опции, случайно разделившие экспортное значение. В HotPDF скалярный геттер GetFormFieldValue читает только строковую форму, так что прогон массива через него вырождал экспорт в пустую строку, а старый XFDF-импорт склеивал повторные элементы <value> через LF. Представьте опцию, экспортированную как Deep, перевод строки, Blue: после склейки Deep\nBlue\nRed — это два выбора или три, и файл не даёт способа узнать. Починка состояла в том, чтобы вовсе перестать использовать скаляр в середине round trip

Старый round trip мультиселекта в HotPDF, где две выбранные опции листбокса, одна со встроенным переводом строки, сплющиваются скалярным путём GetFormFieldValue в единственную строку Deep, перевод строки, Blue, перевод строки, Red, которую читатели ниже по цепочке могут разобрать и как два выбора, и как три
Склейка значений мультиселекта в одну строку уничтожает границы значений, а экспортное значение с собственным переводом строки делает сплющенную форму двусмысленной

Что содержат экспортированные файлы FDF и XFDF?

HotPDF пишет значение мультиселекта типизированным массивом в FDF и одним элементом <value> на выбор в XFDF, так что границы остаются видимыми на диске. В FDF каждый элемент хранит написание, какое имел в исходном PDF: шестнадцатеричные строки выходят hex, а литеральные экранируются единым хелпером, превращающим CR и LF в \r и \n. В XFDF корень несёт xml:space="preserve", как требует ISO 19444-1, — а это значит, что любой пробельный символ внутри текстового элемента считается данными. Поэтому HotPDF пишет открывающий тег, экранированный текст и закрывающий тег каждого <value> одним куском, держит отступы вне элемента и кодирует CR, LF и TAB символьными ссылками, чтобы XML-парсер, применяющий нормализацию конца строки, не мог изменить исходные байты

Формы экспорта, которые HotPDF пишет для мультиселект-листбокса с 2.755.0: FDF несёт один типизированный массив на поле с /V [(Deep перевод строки Blue) (Red)] и hex-значением региона, а XFDF несёт по элементу value на выбор под xml:space preserve, чтобы пробел считался данными
Границы остаются видимыми на диске: FDF хранит каждый выбор собственным элементом массива, а XFDF пишет каждый в отдельном элементе value, так что никакому импортёру не приходится гадать
<!-- FDF: один типизированный массив на поле -->
<< /T (options) /V [(Deep\nBlue) (Red)] >>
<< /T (region) /V [<45553132>] >>

<!-- XFDF: один <value> на выбор -->
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
  <fields>
    <field name="options">
      <value>Deep&#xA;Blue</value>
      <value>Red</value>
    </field>
  </fields>
</xfdf>

Два краевых случая экспорта стоит знать, прежде чем писать вызывающий код. Первое: ExportLoadedFormToFDF строит полное тело FDF в памяти, прежде чем создать целевой файл (починено в 2.755.1), так что значение, которое нельзя экспортировать, — например массив с чем-то кроме строк — поднимает исключение, не обрезав существующий файл. Второе: пустой выбор на листбоксе, который вдобавок предлагает экспортное значение в виде пустой строки, двусмыслен в XFDF, потому что <value/> может значить и «ничего не выбрано», и «выбрана пустая опция». ExportLoadedFormToXFDF в этом случае поднимает исключение, а не гадает, и поднимает его до открытия целевого файла. У FDF такой двусмысленности нет, поскольку /V [] и /V [()] различны. Оба FDF-экспортёра также пропускают терминалы «только виджет» без имени /T — вровень с XFDF-экспортёром, потому что ни один импортёр всё равно не сопоставил бы такие записи с полем

var
  Pdf: THotPDF;
  Written: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    begin
      // Мультиселект-листбоксы пишутся как /V [(...) (...)]
      Written := Pdf.ExportLoadedFormToFDF('order-form.fdf');
      try
        Pdf.ExportLoadedFormToXFDF('order-form.xfdf');
      except
        on E: Exception do
          // Пустой выбор плюс пустая экспортная опция: XFDF не различит
          // их, а существующий .xfdf останется нетронутым
          ShowMessage('XFDF export refused: ' + E.Message);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Как HotPDF валидирует значение мультиселекта при импорте?

HotPDF принимает импортированный массив, только когда цель — choice-поле с выставленным флагом MultiSelect и каждое значение в массиве совпадает с экспортным значением из массива /Opt поля. Каждый слот опции расходуется один раз, поэтому список с двумя опциями, разделившими экспортное значение b, принимает [<62> <62>] как два различных выбора и отвергает третий b. Перестроенный /I следует порядку /Opt, а не порядку входящих значений, поскольку §12.7.4.4 требует возрастающих индексов. HotPDF строит новые /V и /I как отсоединённые объекты и присваивает их только после того, как каждое значение прошло валидацию, так что отвергнутое значение никогда не оставит полмассива или протухших индексов. Копия пишется в импортируемое поле, а не в общий родительский массив, hex-написания, прибывшие из FDF, остаются hex насквозь до сохранения, а поля, чьи вычисления зависят от листбокса, помечаются к пересчёту. Если нужно задать лишь одно значение, задание одного значения поля формы в загруженном PDF идёт через скалярный путь, который по дизайну не умеет несколько выборов

Валидация импорта мультиселект-значений в HotPDF: цель обязана быть choice-полем с MultiSelect в /Ff, каждое входящее значение обязано совпасть с экспортным значением /Opt, где каждый слот тратится один раз, /I перестраивается по возрастанию в порядке /Opt, а отсоединённые /V и /I присваиваются только после прохождения всех значений
Каждое входящее значение сверяется с опциями поля до записи чего-либо, так что отвергнутое значение никогда не оставит полмассива или протухших индексов выбора

Некоторые другие инструменты пишут простые ASCII экспортные значения hex-строками без метки порядка байтов, например <416272>, а затем экспортируют XFDF, выписывая эти hex-цифры текстом. Строгое литеральное сравнение на обратном пути проваливается, и импорт прерывается. Версия 2.755.1 добавляет один ретрай: когда значение не совпало ни с одной опцией, HPDFHexSpellingText декодирует текст как hex-нагрузку и сравнивает результат снова. Ретрай применяется только к входу, который иначе поднял бы исключение, так что уже совпавшее значение он никогда не меняет. Тот же релиз заставил скалярный и массивный пути пользоваться одним Unicode-декодером, понимающим PDFDocEncoding, UTF-16 с любой меткой порядка байтов и UTF-8. До этого одно логическое значение могло совпасть на одном пути и провалиться на другом в документах со смешанными кодировками

Почему валидный FDF-файл всё ещё может терять поля при разборе?

FDF-сканер, не отслеживающий шестнадцатеричные строки, может располовинить словарь поля, когда hex-значение кончается прямо перед терминатором словаря. В << /T (region) /V <416273>>> первая > закрывает hex-строку, но наивный сканер читает её вместе со следующей > как конец словаря и молча теряет поле. FDF-импортёр файлового уровня уже вёл учёт, внутри ли hex-строки он находится, и в 2.755.1 сканеры массива и словаря за ImportLoadedInterchangeFromFDF делают то же. Вторая проблема касается косвенных ссылок. FDF-файл — маленький документ PDF-синтаксиса с собственной нумерацией объектов (ISO 32000-1 §12.7.7), так что значение вроде /V [11 0 R] указывает на объект 11 FDF-файла, а не на объект 11 заполняемого вами PDF. Упрощённый FDF-парсер HotPDF не разрешает ссылки внутри файла, поэтому отвергает такой массив вместо чтения того, чем объект 11 случайно окажется в целевом документе

Импорты из файла, потока и XFDF сообщают об ошибках по-разному

Три маршрута импорта валидируют одинаково, но сообщают о сбоях по-разному, и стоит выбрать один намеренно. ImportLoadedFormFromFDF пропускает любое поле, провалившее валидацию, и возвращает число применённых полей, так что счёт ниже ожидаемого — единственный признак проблемы. ImportLoadedInterchangeFromFDF и ImportLoadedFormFromXFDF поднимают исключение на первом отвергнутом поле. Каждое поле коммитится само по себе, так что поля, обработанные до исключения, сохраняют новые значения. Не считайте ни один из них транзакцией по всему файлу обмена: если нужно «всё или ничего», выбрасывайте загруженный документ при исключении вместо сохранения

var
  Pdf: THotPDF;
  Source: TMemoryStream;
  Status: AnsiString;
  Info: THPDFFDFInterchangeInfo;
begin
  Pdf := THotPDF.Create(nil);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile('order-form-reviewed.fdf');
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    try
      // Только поля; значение вне /Opt или немультиселектная цель поднимет исключение
      if Pdf.ImportLoadedInterchangeFromFDF(Source, True, False, Status, Info) then
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
    except
      on E: Exception do
        ShowMessage('Import rejected, nothing saved: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

Расширяем XFDF-колбэки, не ломая существующих вызывающих

Поддержка массивов в низкоуровневом XFDF-юните живёт в отдельной записи THPDFXFDFArrayAccess и в новых оверлоудах HPDFXFDFExportFields и HPDFXFDFImportFields, а не в дополнительных полях, дописанных в конец существующей записи THPDFXFDFAccess. Причина — бинарная совместимость. Код, заполняющий THPDFXFDFAccess как локальную переменную, часто выставляет только известные ему слоты и никогда не чистит остальные, так что новый указатель на функцию, дописанный в ту запись, содержал бы мусор со стека, и библиотека приняла бы его за настоящий колбэк. С отдельной записью старые вызывающие сохраняют прежнюю раскладку и прежние оверлоуды, а те оверлоуды внутри передают полностью нулевую запись массива. Исходный скалярный оверлоуд импорта по-прежнему склеивает повторные значения через LF ради совместимости, и только array-aware оверлоуд держит их раздельно. Привязывая собственное хранилище данных, стартуйте с Default(THPDFXFDFArrayAccess). Возвращайте True из GetFormFieldValueArray для любого поля со списочным значением, включая и то, где ничего не выбрано, и False для отката к скалярному колбэку

uses HPDFXFDF;

// Простой указатель на функцию, не «of object»: Context несёт ваше хранилище
function StoreGetSelections(Context: Pointer; FieldIndex: Integer;
  out Values: THPDFXFDFValueArray): Boolean;
begin
  Result := TFormStore(Context).IsListField(FieldIndex);
  if Result then
    Values := TFormStore(Context).Selections(FieldIndex);
end;

procedure ExportStore(Store: TFormStore; out Bytes: TBytes);
var
  Access: THPDFXFDFAccess;
  ArrayAccess: THPDFXFDFArrayAccess;
begin
  Access := MakeStoreAccess(Store);             // ваши существующие скалярные привязки
  ArrayAccess := Default(THPDFXFDFArrayAccess); // каждый неиспользуемый слот равен nil
  ArrayAccess.GetFormFieldValueArray := StoreGetSelections;
  HPDFXFDFExportFields(Access, ArrayAccess, Bytes);
end;

Обмен мультиселектом работает на листбоксах, которые уже существуют и держат бит MultiSelect в /Ff. О том, как choice-поля и их флаговые биты создаются в принципе, — в статье о добавлении ListBox и прочих полей AcroForm в загруженный PDF. О разметке комментариев, идущей через дерево <annots> XFDF, — в статье о импорте и экспорте аннотаций XFDF в HotPDF. Полный справочник API и пробная версия — на странице HotPDF Delphi PDF component