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

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

Экспортное значение листбокса из Deep, перевода строки и Blue — совершенно легальный PDF, и второй вариант под именем Red тоже. Выберите оба, сплющите выбор в одну LF-разделённую строку для файла обмена, и дальняя сторона получит Deep\nBlue\nRed: два выбора или три — байты об этом не говорят. Значения мультиселекта возвращаются из FDF или XFDF целыми, только пока они всю дорогу остаются массивами: с момента выхода из PDF до момента записи обратно

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

Склеенная строка теряет границы между значениями, а поскольку значение имеет право содержать разделитель, ни один импортёр не соберёт их обратно. ISO 32000-1 §12.7.4.4 позволяет записи /V поля выбора хранить либо одну текстовую строку, либо массив текстовых строк; листбокс с флагом MultiSelect (бит 22 в /Ff) переходит на форму массива, как только выбрано больше одного варианта. Та же секция определяет /I — массив нулевых индексов вариантов по возрастанию, по которому просмотрщик различает два варианта с общим экспортным значением. Ни одна из этих структур не пролезает через строковый API. GetFormFieldValue в HotPDF возвращает AnsiString и читает строковую форму /V, поэтому любой экспорт поверх неё может записать лишь один скаляр на поле

У импорта проблема зеркальная. XFDF пишет каждый выбор собственным элементом <value>, и импортёру, скармливающему их скалярному сеттеру, сперва приходится склеивать их — обычно через LF. Для варианта Deep/Blue выше этот клей неотличим от данных. Поэтому HotPDF держит скаляры подальше от середины round trip: ExportLoadedFormToFDF, ExportLoadedInterchangeToFDF и ExportLoadedFormToXFDF читают массив прямо из поля, а парные методы импорта строят новый массив, ни разу не пройдя через склеенную строку

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

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

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

Экспортные формы, которые HotPDF пишет для мультиселект-листбокса: FDF несёт один типизированный массив на поле с /V [(Deep, перевод строки, Blue) (Red)] и hex-значением region, а 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 в памяти и лишь затем создаёт целевой файл, поэтому значение, которое нельзя экспортировать (массив с чем-то кроме строк, например), поднимает исключение, пока предыдущий .fdf на диске ещё цел. Второй случай — настоящая дыра самого XFDF: пустой выбор на листбоксе, который к тому же предлагает экспортное значение пустой строки. <value/> может означать и «ничего не выбрано», и «выбрана пустая опция», и третьего написания у формата нет. ExportLoadedFormToXFDF в такой ситуации поднимает исключение до открытия целевого файла. У FDF такой проблемы нет, потому что /V [] и /V [()] — разные объекты. Все три экспортёра пропускают widget-only терминалы без имени /T, ведь ни один импортёр всё равно не смог бы сопоставить такую запись с полем

{{
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;
}}

Как входящий выбор сверяется с /Opt?

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

После присваивания поле получает свежий appearance stream. Этот шаг легко забыть в самодельном импортёре, и симптом сбивает с толку: просмотрщики рисуют сохранённый /AP, а не /V, так что старый выбор остаётся на экране и на печати, хотя данные изменились. Листбокс также попадает в набор изменённых полей, а значит вызов RecalculateLoadedFormFieldsIncremental без параметров перезапустит ровно те вычисления, которые его читают. Hex-написания, пришедшие из FDF, остаются hex до сохранения. Если нужно выставить лишь одно скалярное значение, установка одного значения поля формы в загруженном PDF описывает этот путь, который по дизайну не занимается множественными выборами

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

Один входной паттерн заслуживает особой обработки. PDF может хранить простое ASCII экспортное значение в виде hex-строки без маркера порядка байтов, <416272> для Abr, и экспортёр, копирующий тело PDF-строки дословно, выдаёт XFDF, чей текст элемента — сырые цифры 416272, а не декодированный текст. XFDF, несущий hex-закодированное экспортное значение сырыми цифрами, не может свериться с /Opt по написанию: строгое сравнение не находит опции с именем 416272, и импорт проваливается. Получив такой файл, XFDF-импортёр HotPDF один раз повторяет попытку для несовпавшего значения, декодируя его как hex-нагрузку. Повтор намеренно узкий. Он запускается только для значения, вообще не имеющего точного совпадения; каждое точное совпадение резервируется первым, поэтому спасённое написание никогда не займёт слот опции, который понадобится позднему значению с точным написанием; а в сохранённом /V лежит собственная PDF-строка опции, так что /V и /I описывают один и тот же выбор. Это правильная доза снисходительности: срабатывает лишь там, где импорт иначе поднял бы исключение, и совпавшее значение никогда не перетолковывается. Само сопоставление идёт через один Unicode-декодер, понимающий PDFDocEncoding, UTF-16 с любым маркером порядка байтов и UTF-8, поэтому один и тот же логический текст совпадает с одной и той же опцией даже в документах со смешанными кодировками

Две ловушки разбора FDF: hex-терминаторы и чужие номера объектов

Первая ловушка лексическая. В << /T (region) /V <416273>>> первый > закрывает hex-строку, а следующие два — словарь. Сканер, ищущий только пары >>, закончит словарь на символ раньше и потеряет поле. FDF-импортёр HotPDF файлового уровня отслеживает, внутри ли он hex-строки или литерала, пока нарезает массив /Fields, а потоковый ридер за ImportLoadedInterchangeFromFDF токенизирует весь файл как синтаксис PDF, так что оба видят три закрывающие скобки там, где наивный скан видит две

Вторая ловушка касается ссылок. Файл FDF — маленький документ синтаксиса PDF с собственной нумерацией объектов (ISO 32000-1 §12.7.7), поэтому /V [11 0 R] означает объект 11 файла FDF, а не объекта 11 заполняемого PDF. Разрешение его против целевого документа тихо втащит тот объект, который в нём носит этот номер. В сборках Delphi 2009 и позже interchange-ридер декодирует FDF как полный граф объектов, идёт по ссылке внутри этого графа, валидирует полученные строки как любой встроенный массив и поднимает исключение, если объекта 11 в FDF нет. Файловый ImportLoadedFormFromFDF читает каждый словарь поля сам по себе, без объектной таблицы FDF; массив ссылок проваливает его проверку «только строки», и поле пропускается

Выбор пути импорта: пропустить, поднять или откатить

Все три пути импорта валидируют мультиселект-значения одинаково. Различаются они в том, что происходит при отказе значения. ImportLoadedFormFromFDF пропускает любое провалившее поле и возвращает число применённых полей, так что счёт меньше числа отправленных записей — единственный признак беды. ImportLoadedFormFromXFDF и ImportLoadedInterchangeFromFDF поднимают исключение на первом отвергнутом поле, и каждый вызов идёт одной транзакцией по загруженному документу: когда исключение покидает метод, каждое поле (а для interchange-пути каждая аннотация), которое вызов уже успел изменить, откатывается. Interchange-импорт, вернувший False, тоже откатывается, со сбросом Status и информационной записи

Для данных, которые человек просмотрел и отправил обратно, используйте один из поднимающих путей. Полуприменённая форма заказа хуже отвергнутой: в сохранённом PDF ничто не покажет, какие листбоксы пришли из просмотренного файла, а какие сохранили старые значения. Поведение «пропусти и посчитай» у ImportLoadedFormFromFDF подходит для другой работы: заполнение по мере возможностей из FDF, который могли сгенерировать для чуть другой ревизии формы

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
      begin
        // Перезапустить только вычисления, читающие импортированные поля
        Pdf.RecalculateLoadedFormFieldsIncremental;
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
      end;
    except
      on E: Exception do
        ShowMessage('Import rejected, document unchanged: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

Подключение собственного хранилища к XFDF-коллбэкам массива

Модуль HPDFXFDF, конвертирующий между XFDF и любым хранилищем полей, описываемым коллбэками, несёт поддержку массива в отдельной записи THPDFXFDFArrayAccess и в дополнительных перегрузках HPDFXFDFExportFields и HPDFXFDFImportFields. Дописать новые слоты в существующую запись THPDFXFDFAccess было бы короче и неверно. Вызывающие часто объявляют ту запись локальной переменной и заполняют лишь знакомые слоты, так что новый указатель функции в конце держал бы мусор стека, который библиотека приняла бы за коллбэк. С отдельной записью существующий код сохраняет раскладку и перегрузки, а те перегрузки внутренне передают запись массива со всеми слотами nil

Две импортные перегрузки ведут себя по-разному при повторных элементах <value>. Исходная скалярная перегрузка по-прежнему склеивает их через LF и вызывает SetFormFieldValue, потому что существующие вызывающие зависят от этой конвенции. Перегрузка с поддержкой массива никогда не склеивает: она отдаёт список SetFormFieldValueArray, падает к скалярному сеттеру лишь для одиночного значения, от которого ваш коллбэк отказался, и поднимает исключение, когда коллбэк отказал списку из двух и более. При экспорте стартуйте с Default(THPDFXFDFArrayAccess), чтобы неназначенные слоты (включая опциональный SetFormFieldRV) остались nil. Возвращайте True из GetFormFieldValueArray для любого поля со списочным значением, включая пустой выбор, и False, чтобы скалярный коллбэк разобрался с полем сам. Пустой массив уходит единственным пустым <value/>, поэтому хранилище, предлагающее опцию пустой строки, должно отвергнуть этот случай внутри коллбэка — так же, как делает ExportLoadedFormToXFDF

uses HPDFXFDF;

// Обычный указатель на функцию, не "of object": Context несёт ваше хранилище.
// TFormStore и его методы — ваш код, не часть HotPDF
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. Создание полей выбора и установка их флаговых бит разобраны в добавлении ListBox и других полей AcroForm в загруженный PDF, а разметка комментариев, путешествующая через часть <annots> XFDF — в импорте и экспорте аннотаций XFDF в HotPDF. Справочник методов по каждому показанному здесь вызову плюс пробная сборка, чтобы прогнать их на своих формах, — на странице HotPDF Delphi PDF component