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

Чому редагування полів XFA зникають при збереженні в PDFium для Delphi

TPdf.SetFocusedFormFieldText у компоненті PDFium пише в живий буфер редагування поточного сфокусованого поля форми, і для форми XFA цей буфер ніколи не доходить до пакета datasets, що серіалізується на диск, — тож значення, яке ввів користувач, і яке ваш код підтвердив як прийняте, мовчки зникає наступного разу, коли файл відкривається. У полів AcroForm цієї проблеми немає: той самий виклик фіксується в запис /V поля тієї миті, коли фокус переходить далі. Користувач, що заповнює форму прийому XFA, зберігає й повторно відкриває, щоб знайти поле суми знову порожнім, натрапляє не на глюк рендерингу — він натрапляє на межу того, що сам рушій PDFium відкриває для запису даних форми

Це вужче питання, ніж виявлення форми XFA взагалі, чи запуск її JavaScript: не «чи підтримує PDFium XFA» і не «як мені виконати скрипти AcroForm», а конкретно те, що відбувається зі значенням після того, як SetFocusedFormFieldText повідомляє про успіх. Коротка версія: AcroForm і XFA — не два діалекти однієї моделі форми, наскільки стосується шляху запису PDFium, — це дві моделі форм із двома цілком різними відносинами між тим, що вводить користувач, і тим, що справді захоплює збереження, і змішування цих двох — те, що перетворює однорядковий виклик API на заявку в підтримку через три тижні після запуску пілотного розгортання клієнта. Стаття про JavaScript AcroForm показує цей однорядковий виклик і констатує результат AcroForm-проти-XFA у коментарі коду; ця стаття залишається на тому самому API й проходить внутрішнім шляхом запису, доказом через пакет datasets, що запис XFA ніколи не приземляється, чому прогалина сидить у самому PDFium, а не у прив'язці Delphi, і обхідним шляхом латання власного XML для документів, яким потрібно, щоб редагування пережило збереження

Як SetFocusedFormFieldText записує значення поля?

TPdf.SetFocusedFormFieldText працює, симулюючи редагування на рівні натискань клавіш, а не поштовхуючи значення в модель документа напряму. Внутрішньо він викликає FORM_SelectAllText, щоб вибрати поточний вміст сфокусованого поля, а потім FORM_ReplaceSelection, щоб перезаписати виділення новим рядком, — ті самі дві операції, які спровокувало б редагування з клавіатури «виділити все й ввести». Оскільки запис проходить через шлях інтерактивного текстового редагування PDFium, а не в обхід нього, будь-який скрипт натискання клавіші, форматування чи обчислення, прив'язаний до поля, спрацьовує точно так, як спрацював би для людини, що друкує, а це саме те, що робить API корисним для програмного заповнення форм у переглядачі, що тримає JavaScript живим. Аналог зі сторони читання — FocusedFormFieldText, підкріплений FORM_GetFocusedText, і він віддзеркалює той самий живий буфер, який щойно записав SetFocusedFormFieldText

if Pdf.FocusedFormFieldIndex >= 0 then
begin
  if Pdf.SetFocusedFormFieldText('1284.50') then
    Log('Buffer now reads: ' + Pdf.FocusedFormFieldText)
  else
    Log('No field is focused, or it does not accept text');
end
else
  Log('Focus a field first - FocusFormField or a real click');

Чому AcroForm зберігає значення, а XFA його втрачає?

Текстові та комбіновані поля AcroForm зберігаються, бо власне середовище заповнення форм PDFium фіксує буфер редагування за вас: тієї миті, коли поле втрачає фокус, буфер записується в запис /V поля, той самий ключ, на який дивиться кожен читач PDF, що відповідає специфікації, аби дізнатися збережене значення поля. TPdf.ClearFormFieldFocus — що викликає FORM_ForceToKillFocus під капотом — примусово змушує цю фіксацію на вимогу, тож коду, що встановлює значення програмно, не доводиться чекати на справжнє клацання миші деінде в інтерфейсі. Збережіть одразу після цього, і новий текст стає частиною графа об'єктів документа ще до того, як взагалі виконається TPdf.SaveAs, бо /V — справжній запис у справжньому словнику поля, не щось прив'язане заднім числом

Розділена діаграма SetFocusedFormFieldText у PDFium Component для Delphi: один живий буфер редагування розгалужується на шлях AcroForm, де втрата фокусу фіксує значення в /V і SaveAs зберігає його, і шлях XFA, де пакет datasets ніколи не оновлюється і збережений файл відкривається порожнім
Той самий запис осідає в одному живому буфері, а модель форми вирішує його долю. AcroForm фіксує в /V при втраті фокусу, тоді як XFA лишає пакет datasets недоторканим
Pdf.FocusFormField(FieldIndex);
Pdf.SetFocusedFormFieldText('1284.50');
Pdf.ClearFormFieldFocus;              // примусове негайне закріплення у /V
Pdf.SaveAs('invoice-acroform.pdf');

// Повторно відкрити й підтвердити - це документ AcroForm, тому він тримає
Pdf.Active := False;
Pdf.FileName := 'invoice-acroform.pdf';
Pdf.Active := True;
Pdf.FocusFormField(FieldIndex);
Assert(Pdf.FocusedFormFieldValue = '1284.50');   // проходить

Де насправді живе редагування поля XFA?

Поля XFA не мають такого підключення. Текст, який вводить користувач, приземляється в буфері CPWL_Edit, що належить шару рендерингу та взаємодії XFA у PDFium, і цей шар не має жодного шляху коду, що копіює буфер назад у пакет datasets, збережений у PDF. TPdf.GetXfaDatasets робить цю прогалину видимою: викличте його до і після редагування поля XFA, і байти, які він повертає, ідентичні, бо метод читає оригінальний пакет, з яким документ був відкритий, ніколи живий стан віджета, який ви щойно редагували. Ніщо в цьому не є помилкою кешування чи проблемою часу оновлення — пакет datasets на диску та буфер редагування в пам'яті просто дві різні частини стану, які публічний API PDFium ніколи не з'єднує

var
  Before, After: TBytes;
begin
  Before := Pdf.GetXfaDatasets;
  Pdf.FocusFormField(FieldIndex);
  Pdf.SetFocusedFormFieldText('1284.50');
  After := Pdf.GetXfaDatasets;
  // Before і After побайтово ідентичні в документі XFA -
  // редагування ніколи не торкнулося пакета, який читає GetXfaDatasets
end;

Це помилка компонента PDFium чи обмеження самого PDFium?

Відсутня частина сидить у самому PDFium, не в прив'язці Delphi поверх нього. Публічний API PDFium не має FPDF_SetXFAPacket для введення оновленого пакета й не має FPDF_SaveAsXFA, щоб попросити рушій XFA серіалізувати свій поточний DOM назад у XML datasets перед збереженням. FPDF_SaveAsCopy — експорт, що підкріплює TPdf.SaveAs — записує граф об'єктів документа, який уже має PDFium; у нього немає гачка, щоб попросити рушій XFA спочатку скинути свій живий стан, бо цього гачка не існує вище за течією. Компонент PDFium не може додати узгодження, яке сам PDFium ніколи не реалізував, а постачання саморобного серіалізатора DOM-у-XML, що вгадує внутрішній стан XFA у PDFium, було б гірше за чесну прогалину: воно виглядало б, ніби працює, доки наступна версія PDFium щось не змінить так, що ніхто поза проєктом цього не побачить

Шарова діаграма збереження XFA у Delphi: прив'язка PDFium Component обгортає публічний API PDFium, чиї експортовані функції форми закінчуються на FPDF_SaveAsCopy, тоді як FPDF_SetXFAPacket і FPDF_SaveAsXFA не існують нагорі, тож живий стан XFA-рушія ніколи не серіалізується назад у datasets перед збереженням
Прив'язка викликає кожен експорт, який пропонує PDFium, і не здатна вигадати відсутні. Без серіалізаційного гачка XFA-рушій тримає свій живий стан при собі

Ця межа проявилася під час того самого аудиту v2.13.2, що спершу побудував SetFocusedFormFieldText. FORM_ReplaceSelection була прив'язана в таблиці імпорту DLL протягом версій, ніколи не викликана з коду Pascal, і додавання шляху запису, що нарешті її використав, — те, що зробило прогалину персистентності достатньо конкретною, щоб задокументувати, а не теоретичною. Той самий раунд аудиту виявив непов'язану, але споріднену за духом прогалину: JavaScript AcroForm був мовчки вимкнений з v2.13.0, бо платформа JS була підключена лише всередині гілки ініціалізації XFA, тож звичайні документи AcroForm із app.alert чи обчислюваними полями взагалі ніколи не отримували рушія скриптів. Це можна було виправити — розширивши платформу JS на кожен документ незалежно від XFA, — і це з'явилося в тій самій версії; прогалину персистентності, розглянуту тут, виправити не можна було, з причин вище. Виправлення JavaScript та події вето хоста навколо нього розглянуті у статті запуск JavaScript AcroForm з компонентом PDFium

Що вам робити з цим у Delphi?

Для документів AcroForm виправлення — не більше, ніж хороша звичка: викликайте ClearFormFieldFocus (чи інакше перемістіть фокус) перед SaveAs щоразу, коли значення було встановлено програмно, а не припускайте, що пізніша взаємодія з інтерфейсом спричинить фіксацію за вас. Для документа, що може бути або AcroForm, або XFA — звичайний випадок у загального призначення переглядачі — перевіряйте FormType чи булеве XFA, перш ніж обіцяти викликачу, що збереження закріпиться, і читайте виявлення форм XFA та вилучення пакетів XFA для повного набору перевірок, включно з випадком XFAF, де вміст XFA накладений на в іншому звичайні віджети AcroForm, що справді дотримуються /V

Для справжньої динамічної форми XFA, де відредаговані значення мають пережити збереження, інтерактивний буфер редагування взагалі не той інструмент. Надійний шлях — трактувати GetXfaDatasets як вашу базову лінію, а не результат: прочитати її один раз, коли документ відкривається, вести власний облік того, що користувач змінив, поле за полем — точнісінько ті значення, які вже має ваш інтерфейс, оскільки PDFium не поверне їх вам назад постфактум, — латати їх у базовий XML самостійно й керувати власним виводом. Запис, що проходить через XML, яким керує ваш власний код, переживає збереження, яке буфер CPWL_Edit ніколи не міг би пережити

function ExportEditedXfaValue(Pdf: TPdf; const FieldPath,
  NewValue: string): TBytes;
var
  DatasetsXml: string;
begin
  // GetXfaDatasets постачається разом із PDFium Component; PatchXmlNode нижче -
  // ваш власний хелпер поверх вашої XML-бібліотеки, нічого PDFium не надає
  DatasetsXml := TEncoding.UTF8.GetString(Pdf.GetXfaDatasets);
  DatasetsXml := PatchXmlNode(DatasetsXml, FieldPath, NewValue);
  Result := TEncoding.UTF8.GetBytes(DatasetsXml);
end;

Виявлення прогалини ще до того, як це зробить клієнт

TPdf.SaveAs повертає True незалежно від того, чи пережило значення поля XFA, бо з точки зору PDFium збереження справді вдалося — воно записало кожен байт, який його попросили записати. Це робить це саме тим типом дефекту, що прослизає повз димовий тест і досягає клієнта: ніщо не піднімає виняток, ніщо не логується, файл відкривається нормально, лише конкретне значення неправильне. Тест туди-й-назад, що справді повторно відкриває збережений файл і порівнює значення поля — чи порівнює GetXfaDatasets до й після, за попереднім прикладом — належить до регресійного набору для будь-якого переглядача, що дозволяє користувачам редагувати вміст XFA, не лише шляхи AcroForm, що випадково працюють за замовчуванням

Стійкий конвеєр редагування XFA для Delphi: прочитайте базову лінію datasets через GetXfaDatasets, відстежуйте редагування користувача поле за полем у власному стані UI, латайте XML власним помічником і зберігайте вивід, у якому відредаговане значення виживає
Трактуйте GetXfaDatasets як базову лінію, а ваш UI — як запис правок. Самостійне латання XML пакета ставить байти, що доходять до диска, під ваш контроль

Ніщо з цього не дефект, який варто заявляти проти компонента PDFium, а радше межа, навколо якої варто проєктувати: SetFocusedFormFieldText робить точно те, що каже його назва, для обох моделей форм, а різниця в результаті чисто простежується до того, до чого кожна з AcroForm та XFA підключає цей буфер на боці PDFium. API, примітиви фокусу та збереження, а також читачі пакетів, згадані тут, — частина компонента PDFium для Delphi та C++Builder