Технічна стаття

Multi-select поля PDF у round-trip FDF і XFDF (Delphi)

HotPDF проганяє значення multi-select list box через FDF і XFDF, тримаючи значення поля масивом від початку до кінця. З версії 2.755.0 ExportLoadedFormToFDF, ExportLoadedInterchangeToFDF і ExportLoadedFormToXFDF пишуть кожен вибраний варіант як власний FDF-рядок чи XFDF-елемент <value>, а відповідні методи імпорту звіряють кожне значення з опціями поля і перебудовують індекси вибору /I, перш ніж що-небудь змінити. Нічого не склеюється в один рядок по дорозі

Збій, який це виправляє, легко відтворити. Візьміть форму замовлення з multi-select list box-ом продуктових опцій, дайте користувачеві вибрати дві з них, експортуйте дані форми для бекофісної системи, а потім імпортуйте відредагований файл назад у PDF. До цієї зміни list box повертався порожнім або неправильним. Причина в тому, що одне з export-значень містило розрив рядка, а старий шлях сплощив вибір в один рядок із розділенням по рядках. Витягнути кілька виборів назад із того рядка ніколи не було надійно, а з export-значенням, яке саме містить розрив рядка, це не може працювати взагалі

Чому склеювання multi-select значень розривами рядків ламає round trip?

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

Старий multi-select round trip HotPDF, де два вибрані варіанти list box, один із вбудованим розривом рядка, сплощуються скалярним шляхом GetFormFieldValue в один рядок Deep, розрив рядка, Blue, розрив рядка, Red, який читачі нижче по течії можуть розпарсити і як два вибори, і як три
Склеювання multi-select значень в один рядок знищує межі значень, а export-значення, яке саме містить розрив рядка, робить сплощену форму двозначною

Що містять експортовані файли FDF і XFDF?

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

Форми експорту, які HotPDF пише для multi-select list box з версії 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), тож значення, яке неможливо експортувати, як-от масив, що тримає щось крім рядків, підніме виняток, не обрізавши наявний файл. Друге: порожній вибір на list box-і, який ще й пропонує export-значення-порожній-рядок, двозначний у 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
      // Multi-select list box-и пишуться як /V [(...) (...)]
      Written := Pdf.ExportLoadedFormToFDF('order-form.fdf');
      try
        Pdf.ExportLoadedFormToXFDF('order-form.xfdf');
      except
        on E: Exception do
          // Порожній вибір плюс порожня export-опція: XFDF не може відрізнити
          // їх, а наявний .xfdf-файл лишається недоторканим
          ShowMessage('XFDF export refused: ' + E.Message);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Як HotPDF валідує multi-select значення при імпорті?

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

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

Деякі інші інструменти пишуть звичайні ASCII export-значення як hex-рядки без byte order mark, наприклад <416272>, а потім експортують XFDF, викидаючи ті hex-цифри як текст. Суворе літеральне порівняння на зворотному шляху фейлиться, і імпорт переривається. Версія 2.755.1 додає один ретрай: коли значення не збігається з жодною опцією, HPDFHexSpellingText декодує текст як hex-payload і порівнює результат ще раз. Ретрай застосовується лише до входу, який інакше підняв би помилку, тож він ніколи не змінює значення, що вже збіглося. Той самий реліз також змусив скалярний і масивний шляхи використовувати той самий Unicode-декодер, який розуміє PDFDocEncoding, UTF-16 з будь-яким byte order mark і 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 чи не-multi-select ціль піднімає помилку
      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 callback-ів без ламання наявних викликачів

Підтримка масивів у нижчому XFDF-юніті живе в окремому записі THPDFXFDFArrayAccess і в нових перевантаженнях HPDFXFDFExportFields і HPDFXFDFImportFields, а не в додаткових полях, доданих у кінець наявного запису THPDFXFDFAccess. Причина — бінарна сумісність. Код, що заповнює THPDFXFDFAccess як локальну змінну, часто ставить лише слоти, про які знає, і ніколи не очищає решту, тож новий покажчик на функцію, доданий у той запис, містив би сміття зі стеку, і бібліотека прийняла б його за справжній callback. З окремим записом старі викликачі тримають старе розташування і старі перевантаження, а ті перевантаження всередині передають запис масиву, весь у nil. Оригінальне скалярне перевантаження імпорту досі склеює повторені значення через LF заради сумісності, і лише масивно-обізнане перевантаження тримає їх окремо. Коли прив'язуєте власне сховище даних, стартуйте з Default(THPDFXFDFArrayAccess). Повертайте True з GetFormFieldValueArray для будь-якого поля зі значенням-списком, включно з полем, де нічого не вибрано, і False — щоб упасти назад у скалярний callback

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;

Multi-select-обмін працює на list box-ах, які вже існують і мають біт MultiSelect у /Ff. Про те, як choice-поля та їхні біти прапорців створюються в першу чергу, читайте в додаванні ListBox та інших полів AcroForm до завантаженого PDF. Про розмітку коментарів, що йде крізь дерево <annots> XFDF, — у імпорті-експорті анотацій XFDF у HotPDF. Повна довідка API і завантаження пробної версії — на сторінці HotPDF Delphi PDF component