PDFium Component зберігає відредаговані значення XFA форм точно, крізь save та reopen, коли працює Windows V8 runtime pdfium.v8.dll, що йде в v3.125.2 чи новішій. Старіші runtime-и додавали line feeds до значень field-ів, обрізали emoji до нерелевантного BMP символу, тихо пропускали single-stream XFA save і могли ковтнути провалений фінальний запис. Один із симптомів reopen взагалі не дефект бібліотеки: dynamic форма, у якої root subform не має restoreState="auto", перебудовує свій layout з шаблону
Bug-звіти до цього всі виглядали однаково. Клієнт заповнює XFA форму у Delphi viewer-і, зберігає, відкриває знову — і щось трохи не так. Порожнє поле коментарів тепер тримає порожній рядок, а після другого збереження — два. Ім’я, набране з emoji, повертається з private-use гліфом. Ніхто не отримує помилки — саме тому ці bug-и дорогі: дрейф вилазить тижнями пізніше в чиємусь чужому export
Що ламається, коли XFA форму зберігають і відкривають знову?
Чотири окремі дефекти в native XFA save шляху спричиняли value drift, і кожен ховався за виглядом успішного save. Два походили з серіалізації, один — зі single-stream сховища, і один — з самого PDF writer-а. Таблиця мапить кожен симптом на його причину та на реліз, у якому PDFium Component це виправив
| Симптом після reopen | Причина | Виправлено в |
|---|---|---|
| Порожнє поле тримає line feed; значення ростуть на newline за кожен save | Обидва XFA writer-и вставляли layout newline-и після start-тегів | v3.125.2, pdfium.v8.dll |
| U+1F642 повертається як U+F642, чи emoji зникає з form packet | 16-бітне усічення wchar_t у декодуванні; surrogate-фільтрування в form serializer-і | v3.125.2, pdfium.v8.dll |
| Правки в single-stream XFA документі просто зникають | Native save відхиляв stream layout, але return value ігнорували | v3.125.2; коментарі та processing instructions зберігаються від v3.126.0 |
| Обрізаний файл, хоч save звітував успіх | Фінальний буферизований запис провалився після того, як writer уже повернув успіх | v3.125.2 V8 runtime; v3.125.3 звичайний pdfium.dll |
| Тристорінкова dynamic форма відкривається як двосторінкова | Root subform не просить restoreState="auto" | Authoring форми, не дефект бібліотеки |
Ранішні write-up-и робили висновок, що правки XFA field-ів узагалі неможливо зберегти з PDFium, що було справедливим для runtime-ів того часу. Новіший V8 runtime зберігає XFA значення нативно, тож правка, зроблена в живій формі, дістає збереженого datasets packet без packet-хірургії на вашому боці
Кого PDFium runtime зберігає XFA значення?
XFA save fidelity залежить від native DLL, а не від Delphi wrapper-а, тож перша перевірка — який runtime ваш процес справді завантажив. PDFium Component постачає дві Windows збірки на архітектуру: звичайну pdfium.dll, збудовану без V8 і XFA, і pdfium.v8.dll, яка несе JavaScript engine та XFA form runtime. Лише pdfium.v8.dll може гнати XFA форму, тож кожен XFA fix, описаний тут, живе там, починаючи з перезбудованих Win32 і Win64 V8 бібліотек у v3.125.2
Fix фінального запису — загальний код PDF writer-а, тож він має значення і для звичайних документів. v3.125.3 перезбудувала звичайні бібліотеки pdfium.dll, щоб нести той самий ремонт. Спільний сирець — не доказ спільної поведінки: доки бінарник не перезбудовано, стара DLL тримає старий bug
Друга пастка сиділа в loader-і. До v3.125.2 постановка EnableV8Engine у True змушувала binding брати усталене ім’я pdfium.v8.dll і ігнорувати повний шлях у LibraryName. Застосунок, що вказував на щойно розгорнутий runtime, міг продовжувати завантажувати старішу копію з іншої теки. Від v3.125.2 LibraryName, що містить каталог, обирає рівно той файл в обох режимах engine, а відсутній шлях провалюється замість відкату до іншої bundled бібліотеки
uses
System.SysUtils, PDFium;
procedure SelectXfaRuntime;
begin
// Каталог у LibraryName пришпилює рівно цей файл (v3.125.2 і новіші);
// якщо файл відсутній, завантаження підіймає помилку замість відкату
{$IFDEF WIN64}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
PDFium.EnableV8Engine := True;
PDFium.LoadLibrary; // провал на старті, а не при першому save
end;
Після відкриття документа TPdf.XFA каже, що файл містить XFA, а TPdf.XfaRuntimeAvailable — що завантажена DLL справді може його виконати. Якщо вам треба ще й розрізнити статичні та dynamic форми, TPdf.FormType повертає ftXfaFull чи ftXfaForeground; стаття про виявлення XFA форм і витяг XFA пакетів у Delphi покриває той зонд детально
Чому збережені XFA поля набирають зайвих line feed-ів?
Збережені XFA поля набирали line feed-ів, бо обидва native XFA writer-и — загальний XML element writer і form packet serializer — робили pretty-print виводу з newline після start-тегів. У більшості XML той whitespace — косметика. В XFA даних — ні: коли datasets packet парситься знову, текст між <Comments> і </Comments> — це значення field, newline включно. Порожнє поле тому відкривалося знову з одним LF, і кожен подальший цикл save-and-reopen міг додати ще один
Очевидний ремонт — обрізати значення при завантаженні — був би неправильним. Користувачі набирають початкові пробіли, кінцеві пробіли і навмисний багаторядковий текст у XFA field-и, і адресний блок чи код фіксованої ширини мусять вижити байт-у-байт. Fix у v3.125.2 тому прибирає лише whitespace, який сам serializer синтезував навколо тегів. Користувацькі значення, наявні текстові вузли та CDATA секції проходять недоторканими, тож " indented" лишається з відступом, а навмисно порожнє поле лишається порожнім
Чому emoji повертається іншим символом?
Emoji повертався неправильним, бо Windows wchar_t має ширину 16 бітів, і два декодовні шляхи зберігали повне Unicode scalar значення в одному wchar_t. Обидва це робили: UTF-8 stream decoder і parser числових character references на кшталт 🙂. U+1F642, злегка усміхнене обличчя, не влазить у 16 бітів, тож старші біти відпадали, і замість нього з’являвся U+F642 — code point у Private Use Area, який більшість шрифтів рендерить як квадрат чи нічого
Form serializer мав протилежну проблему. Він фільтрував символи по одному wchar_t за раз, бачив дві surrogate code units, недійсні поодинці, і відкидав обидві, тож emoji зникав із form packet повністю. У v3.125.2 decoder споживає кожне scalar значення повністю і випромінює належну surrogate пару. Коли лишається лише один вихідний слот, він тримає молодшу surrogate в очікуванні і не звітує end-of-stream, поки та одиниця ще в буфері. UTF-8 послідовність, розрізана між блоками читання, переноситься до наступного читання замість того, щоб бути викинутою. Form exporter тепер тримає валідні surrogate пари разом, і числові character references теж продукують правильні пари
Latin-1 тестові дані ніколи не показують нічого з цього, тож кожен XFA round-trip тест потребує щонайменше одного символу supplementary plane
Single-stream XFA і провали save, яких ніхто не бачив
Single-stream XFA документ втрачав свої правки, бо native save helper відхиляв той layout сховища, а його caller ігнорував провал. ISO 32000-1 §12.7.8 дозволяє запису /XFA інтерактивного form словника бути або масивом імен пакетів і потоків, або одним потоком, що тримає весь XDP документ. Packet-масиви — звичайний випадок, але single streams цілком легальні, і PDF save завершувався, ніби нічого не сталося, поки дані форми лишалися на старих значеннях
Від v3.125.2 V8 runtime опікується підтримуваною single-stream підмножиною. Він спершу експортує обидва живі пакети, datasets і form, у staging-область і валідує їх, і лише потім замінює відповідні пакети в оригінальному XDP. Інші пакети та кореневі namespace декларації зберігаються. Якщо staging провалюється, персистентний XFA stream ніколи не чіпається, і документ тримає свою modification мітку
XML коментарі та processing instructions потребували додаткової опіки, бо внутрішній XML DOM їх викидає. У v3.125.2 їхня наявність робила save явно проваленим замість тихої втрати вмісту. v3.126.0 зберігає їх: перед парсингом кожен коментар чи processing instruction міняється на маркер, збудований з префікса, що не зустрічається ніде в оригінальному тексті. Після заміни живих пакетів кожен маркер мусить з’явитися рівно один раз, перш ніж оригінальний токен буде відновлено і stream буде записано. Токени поза заміненими пакетами тому зберігають свій текст і порядок, включно з токенами в prolog-і, template та інших пакетах
Деякі входи досі відмовляються намірено, і кожна відмова — явний провал save:
- Коментарі чи processing instructions всередині живих пакетів
datasetsчиform, бо їхні оригінальні позиції неможливо мапити в щойно експортований вміст - DTD декларації та XMLDSig підписи, бо переписування XDP не може тримати XML підпис валідним
- Недійсне кодування UTF-8 чи UTF-16, неповні теги, недійсні character references, невідомі entities і malformed processing instructions — відмовляються замість тихого ремонту
Single-stream вивід — UTF-8 і зберігає XML content model, а не оригінальне байтове розташування чи декларацію кодування
Останній дефект сидів нижче XFA. Native file writer буферизує вивід блоками по 32 КБ і зливав фінальний частковий блок лише у своєму деструкторі — після того, як document writer уже звітував успіх. Disk-full чи I/O помилка на тому останньому блоці була невидима caller-у. Від v3.125.2 у V8 runtime і v3.125.3 у звичайному runtime той фінальний flush — частина результату save, а XFA modification мітка чиститься лише після справжнього успіху. На Delphi боці TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean пише в тимчасовий файл поруч із цільовим і пересуває його на місце лише коли save повертає True, тож провалений save лишає попередній файл цілим
Чому dynamic XFA форма відкривається знову з меншою кількістю сторінок?
Dynamic XFA форма відкривається знову з меншою кількістю сторінок, коли її root subform не декларує restoreState="auto", і це рішення authoring форми, а не дефект PDFium Component. У XFA 3.3 restoreState на root subform усталено manual. Під manual XFA processor відновлює лише обмежений стан із збереженого form packet і лишає решту скриптам автора. Збережені значення field-ів і лічильники instance-ів repeating subform усе ще повертаються, а геометричні властивості, поставлені під час роботи, — ні
Випадок, який це викрив, — тристорінкова форма, чиїй скрипт ріс subform до h="450pt". Збережений form packet тримав нову висоту, значення та лічильники instance-ів. На reopen, утім, layout перебудовувався з висот шаблону, і форма перетікала на дві сторінки. Runtime мав рацію: шаблон ніколи не просив автоматичного відновлення. Декларація на root subform виправляє reopen:
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form1" layout="tb" restoreState="auto">
<pageSet>
<pageArea name="Page1">
<contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
<medium stock="letter"/>
</pageArea>
</pageSet>
<subform name="Details" layout="tb" w="7.5in">
<!-- fields; скрипти можуть міняти h чи додавати instance-и під час роботи -->
</subform>
</subform>
</template>
Якщо шаблон не ваш, не латайте довкола нього у viewer: форма, що покладається на режим manual, очікує, що її власні скрипти перебудують стан. Жива репагінація під час набирання — окрема тема, покрита в як PDFium Component відстежує кількість сторінок dynamic XFA та переїхалі fields
Як верифікувати XFA save у Delphi?
Єдина надійна перевірка XFA save — знову відкрити збережений файл у свіжому екземплярі TPdf і прочитати збережені дані назад. TPdf.GetXfaDatasets повертає datasets packet так, як він збережений у документі, а не живу XFA data model, тож виклик його до збереження показує старі значення. Після повторного відкриття він показує рівно те, що було записано. Single-stream документ не має окремо іменованих пакетів: PDFium звітує весь XDP як один пакет з порожнім ім’ям, тож GetXfaPacketByName('datasets') і GetXfaDatasets не повертають нічого, і fallback читає повний stream через GetXfaFormPackets
uses
System.SysUtils, PDFium, FPdfXfa;
function ReadSavedXfaData(const FileName: string): string;
var
Pdf: TPdf;
Packets: TXfaPacketList;
Bytes: TBytes;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
Bytes := Pdf.GetXfaDatasets; // layout packet-масиву
if Length(Bytes) = 0 then
begin
Packets := Pdf.GetXfaFormPackets; // single stream: один безіменний пакет
if Length(Packets) = 1 then
begin
SetLength(Bytes, Length(Packets[0].Content));
if Length(Bytes) > 0 then
Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
end;
end;
Result := TEncoding.UTF8.GetString(Bytes); // збережений XDP вивід — UTF-8
finally
Pdf.Free;
end;
end;
Рутина save потім комітить pending-правку, перевіряє результат SaveAs і порівнює значення, прочитане з повторно відкритого файлу. TPdf.ClearFormFieldFocus вбиває form focus — саме в той момент PDFium комітить edit buffer focused field. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean заповнює focused field програмно, але спирається на focus, який wrapper відстежує через FocusFormField, що обходить widget annotations. Dynamic XFA сторінка зазвичай не має жодної, тож там текст зазвичай приходить крізь keyboard input у TPdfView, і функція повертає False, коли жодне відстежуване поле не має фокусу
function XmlText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
end;
procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
Expected: string);
var
Saved: string;
begin
// Опційне scripted заповнення; False означає, що жодне відстежуване поле не має фокусу
if (Pdf.FocusedFormFieldIndex >= 0) and
not Pdf.SetFocusedFormFieldText(Expected) then
raise EPdfError.Create('Could not write the focused field');
Pdf.ClearFormFieldFocus; // коміт edit buffer
if not Pdf.SaveAs(FileName) then // включно з фінальним flush (v3.125.2+)
raise EPdfError.CreateFmt('Saving %s failed', [FileName]);
Saved := ReadSavedXfaData(FileName);
if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
Saved) = 0 then
raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;
Трактуйте substring-тест як smoke test. Порожній елемент може серіалізуватися як <Tag/>, атрибути можуть з’являтися на data елементах, а escaping поза & і < — вибір serializer-а. Для production перевірок завантажте повторно відкритий XML справжнім XML parser-ом і порівняйте текстовий вузол прив’язаного data елемента. Проганяйте перевірку двічі підряд також, бо newline дефект показував свою повну форму лише на другому поколінні
Швидка довідка: чек-лист XFA save fidelity
- Розгортайте
pdfium.v8.dllвід v3.125.2 чи новішої для XFA форм, і v3.125.3 чи новішої для звичайноїpdfium.dll, тож fix фінального запису є в обох - Вкажіть
LibraryNameна повний шлях і поставтеEnableV8Engineу True; відсутній шлях провалюється замість завантаження іншої копії - Підтвердьте
TPdf.XFAіTPdf.XfaRuntimeAvailableпісля відкриття документа - Викличте
ClearFormFieldFocusпередSaveAs, щоб focused field був закомічений - Ніколи не ігноруйте Boolean результат
SaveAs; False лишає попередній файл на місці - Верифікуйте повторним відкриттям у новому
TPdfі читаннямGetXfaDatasets, з fallback наGetXfaFormPacketsдля single-stream XFA - Тестуйте з порожніми значеннями, початковими пробілами, багаторядковим текстом,
&і символом supplementary plane, крізь два покоління save - Очікуйте явні провали save за DTD, XMLDSig і коментарі всередині живих пакетів single-stream XFA
- Якщо dynamic форма втрачає run-time геометрію на reopen, перевірте root subform на
restoreState="auto", перш ніж підозрювати бібліотеку
Про структуру callback, якої XFA runtime очікує від host-застосунку, — у FPDF_FORMFILLINFO version 2 та XFA ABI у Delphi. V8 runtime, Delphi і C++Builder wrapper та viewer control — усе частина PDFium Component для Delphi і C++Builder, що включає обидва Windows runtime-и для Win32 і Win64