PDFium Component zapisuje edytowane wartości formularzy XFA wiernie, przez zapis i ponowne otwarcie, gdy działa na runtime'ie Windows V8 pdfium.v8.dll dostarczanym od v3.125.2. Starsze runtime'y dokładały znaki nowej linii do wartości pól, ścinały emoji do niepowiązanego znaku BMP, po cichu pomijały zapisy XFA w jednym strumieniu i potrafiły połknąć nieudany zapis końcowy. Jeden objaw po ponownym otwarciu to w ogóle nie wada biblioteki: dynamiczny formularz, którego subform korzenia nie ma restoreState="auto", buduje swój layout od nowa z szablonu
Bugreporty w tej sprawie wyglądały wszędzie tak samo. Klient wypełnia formularz XFA wniosku w viewerze Delphi, zapisuje, otwiera ponownie, a coś jest minimalnie nie tak. Puste pole komentarzy trzyma teraz pustą linię, a po drugim zapisie dwie. Nazwa wpisana z emoji wraca z glifem z obszaru prywatnego. Nikt nie dostaje błędu, i to czyni te bugi kosztownymi: dryf wychodzi na jaw tygodnie później w cudzym eksporcie
Co idzie nie tak, gdy formularz XFA zostanie zapisany i otwarty ponownie?
Cztery osobne wady w natywnej ścieżce zapisu XFA powodowały dryf wartości i każda kryła się za zapisem wyglądającym na udany. Dwie pochodziły z serializacji, jedna z układu magazynu w jednym strumieniu, a jedna z samego pisarza PDF. Tabela mapuje każdy objaw na jego przyczynę i na wydanie, w którym PDFium Component ją naprawił
| Objaw po ponownym otwarciu | Przyczyna | Naprawione w |
|---|---|---|
| Puste pole trzyma znak nowej linii; wartości rosną o nową linię na zapis | Oba pisarze XFA wstawiały nowe linie layoutu po tagach otwierających | v3.125.2, pdfium.v8.dll |
| U+1F642 wraca jako U+F642 albo emoji znika z pakietu formularza | Ucinanie do 16-bitowego wchar_t w dekodowaniu; filtrowanie surrogate'ów w serializatorze formularza | v3.125.2, pdfium.v8.dll |
| Edycje w dokumencie XFA w jednym strumieniu są po prostu zgubione | Natywny zapis odrzucał układ strumienia, ale wartość zwrotna była ignorowana | v3.125.2; komentarze i instrukcje przetwarzania zachowywane od v3.126.0 |
| Obcięty plik, choć zapis raportował sukces | Końcowy buforowany zapis zawodził, potem pisarz już zwrócił sukces | runtime V8 v3.125.2; zwykły pdfium.dll v3.125.3 |
| Trójstronicowy formularz dynamiczny otwiera się ponownie jako dwustronicowy | Subform korzenia nie żąda restoreState="auto" | Projekt formularza, nie wada biblioteki |
Wcześniejsze opracowania wnioskowały, że edycji pól XFA nie da się w ogóle utrwalić w PDFium — co było prawdziwe dla runtime'ów tamtych czasów. Nowszy runtime V8 zapisuje wartości XFA natywnie, więc edycja zrobiona w żywym formularzu trafia do zapisanego pakietu datasets bez operacji na pakietach po twojej stronie
Który runtime PDFium zapisuje wartości XFA?
Wierność zapisu XFA zależy od natywnej biblioteki DLL, a nie od wrappera Delphi, więc pierwszym sprawdzeniem jest to, który runtime twój proces faktycznie wczytał. PDFium Component dostarcza dwa buildy Windows na architekturę: zwykły pdfium.dll, zbudowany bez V8 i XFA, oraz pdfium.v8.dll, który niesie silnik JavaScript i runtime formularzy XFA. Tylko pdfium.v8.dll potrafi uruchomić formularz XFA, więc każda z opisywanych tu poprawek XFA mieszka w nim, począwszy od przebudowanych bibliotek V8 Win32 i Win64 w v3.125.2
Poprawka zapisu końcowego to generyczny kod pisarza PDF, więc ma znaczenie także dla zwykłych dokumentów. v3.125.3 przebudowało zwykłe biblioteki pdfium.dll, żeby nieść tę samą naprawę. Wspólne źródło to nie dowód wspólnego zachowania: dopóki binarka nie zostanie przebudowana, stara biblioteka DLL trzyma stary bug
Druga pułapka siedziała w loaderze. Przed v3.125.2 ustawienie EnableV8Engine na True sprawiało, że wiązanie wybierało domyślną nazwę pdfium.v8.dll i ignorowało pełną ścieżkę w LibraryName. Aplikacja wskazująca na świeżo wdrożony runtime mogła wciąż wczytywać starszą kopię z innego folderu. Od v3.125.2 LibraryName zawierające katalog wybiera dokładnie ten plik w obu trybach silnika, a brakująca ścieżka zawodzi, zamiast schodzić do innej dołączonej biblioteki
uses
System.SysUtils, PDFium;
procedure SelectXfaRuntime;
begin
// Katalog w LibraryName przypina ten dokładny plik (v3.125.2 i nowsze);
// gdy pliku brakuje, wczytywanie podnosi wyjątek zamiast schodzić niżej
{$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; // zawieść na starcie, nie przy pierwszym zapisie
end;
Po otwarciu dokumentu TPdf.XFA mówi ci, że plik zawiera XFA, a TPdf.XfaRuntimeAvailable — że wczytana biblioteka DLL potrafi go faktycznie wykonać. Jeśli potrzebujesz też odróżnić formularze statyczne od dynamicznych, TPdf.FormType zwraca ftXfaFull albo ftXfaForeground; artykuł o wykrywaniu formularzy XFA i wyciąganiu pakietów XFA w Delphi opisuje to rozeznawanie szczegółowo
Dlaczego zapisane pola XFA zyskują dodatkowe znaki nowej linii?
Zapisane pola XFA zyskiwały znaki nowej linii, bo oba natywne pisarze XFA — generyczny pisarz elementów XML i serializator pakietu formularza — pretty-printowały wyjście z nową linią po tagach otwierających. W większości XML ta biel jest kosmetyczna. W danych XFA nie: gdy pakiet datasets jest parsowany ponownie, tekst między <Comments> a </Comments> jest wartością pola — razem z nową linią. Puste pole otwierało się więc z jednym LF, a każdy kolejny cykl zapis i ponowne otwarcie mógł dokładać następny
Oczywista naprawa, czyli przycinanie wartości przy wczytywaniu, byłaby zła. Użytkownicy wpisują do pól XFA spacje z przodu, spacje na końcu i celowy tekst wieloliniowy, a blok adresu albo kod o stałej szerokości musi przeżyć bajt po bajcie. Poprawka z v3.125.2 usuwa więc wyłącznie biel, którą sam serializator syntetyzował wokół tagów. Wartości użytkownika, istniejące węzły tekstowe i sekcje CDATA przechodzą nietknięte, więc " indented" zostaje wcięte, a celowo puste pole pozostaje puste
Dlaczego emoji wraca jako inny znak?
Emoji wracało zepsute, bo Windowsowe wchar_t ma 16 bitów, a dwie ścieżki dekodowania zapisywały pełną wartość skalarną Unicode w pojedynczym wchar_t. Robiły tak dekoder strumienia UTF-8 i parser numerycznych referencji znaków takich jak 🙂. U+1F642, lekko uśmiechnięta twarz, nie mieści się w 16 bitach, więc górne bity odpadały i zamiast niego pojawiało się U+F642: punkt kodowy z Private Use Area, który większość czcionek renderuje jako kwadrat albo nic
Serializator formularza miał odwrotny problem. Filtrował znaki po jednym wchar_t, widział dwie jednostki surrogate'owe nieprawidłowe w izolacji i wyrzucał obie, więc emoji znikało z pakietu formularza całkowicie. W v3.125.2 dekoder konsumuje każdą wartość skalarną w całości i emituje porządną parę surrogate'ów. Gdy zostaje tylko jedno miejsce wyjściowe, trzyma niski surrogate w zawieszeniu i nie raportuje końca strumienia, dopóki ta jednostka siedzi w buforze. Sekwencja UTF-8 rozcięta między blokami odczytu jest przenoszona do następnego odczytu zamiast być wyrzucana. Eksporter formularza trzyma teraz ważne pary surrogate'ów razem, a numeryczne referencje znaków też produkują poprawne pary
Dane testowe Latin-1 nigdy nie pokazują niczego z tego, więc każdy test rundy XFA potrzebuje przynajmniej jednego znaku z płaszczyzny uzupełniającej
XFA w jednym strumieniu i porażki zapisu, których nikt nie widział
Dokument XFA w jednym strumieniu gubił edycje, bo natywny pomocnik zapisu odrzucał ten układ magazynu, a jego wołający ignorował porażkę. ISO 32000-1 §12.7.8 pozwala, żeby wpis /XFA słownika formularza interaktywnego był albo tablicą nazw pakietów i strumieni, albo pojedynczym strumieniem niosącym cały dokument XDP. Tablice pakietów to przypadek pospolity, ale pojedyncze strumienie są całkowicie legalne, a zapis PDF kończył się, jakby nic się nie stało, podczas gdy dane formularza zostawały na starych wartościach
Od v3.125.2 runtime V8 obsługuje wspierany podzbiór pojedynczego strumienia. Najpierw eksportuje oba żywe pakiety, datasets i form, do strefy przygotowawczej i waliduje je, a dopiero potem podmienia pasujące pakiety w oryginalnym XDP. Pozostałe pakiety i deklaracje przestrzeni nazw korzenia są zachowywane. Gdy przygotowanie zawiedzie, trwały strumień XFA nie jest nigdy ruszany, a dokument trzyma swoją pieczątkę modyfikacji
Komentarze XML i instrukcje przetwarzania potrzebowały dodatkowej troski, bo wewnętrzny XML DOM je wyrzuca. W v3.125.2 ich obecność czyniła zapis jawnie nieudanym, zamiast po cichu gubić treść. v3.126.0 je zachowuje: przed parsowaniem każdy komentarz albo instrukcja przetwarzania jest podmieniany na znacznik zbudowany z prefiksu, który nie występuje nigdzie w oryginalnym tekście. Po podmianie żywych pakietów każdy znacznik musi pojawić się dokładnie raz, zanim przywrócony zostanie oryginalny token i strumień zostanie zapisany. Tokeny poza podmienianymi pakietami zachowują więc swój tekst i porządek, łącznie z tokenami w prologu, szablonie i pozostałych pakietach
Niektóre wejścia są nadal odrzucane celowo, a każde odrzucenie to jawna porażka zapisu:
- komentarze albo instrukcje przetwarzania wewnątrz żywych pakietów
datasetsalboform, bo ich oryginalne pozycje nie dadzą się zmapować na świeżo wyeksportowaną treść - deklaracje DTD i podpisy XMLDSig, bo przepisanie XDP nie potrafi utrzymać ważności podpisu XML
- nieprawidłowe kodowanie UTF-8 albo UTF-16, niedokończone tagi, błędne referencje znaków, nieznane encje i zniekształcone instrukcje przetwarzania — odrzucane zamiast naprawiane po cichu
Wyjście pojedynczego strumienia jest UTF-8 i zachowuje model treści XML, a nie oryginalny układ bajtów albo deklarację kodowania
Ostatnia wada siedziała poniżej XFA. Natywny pisarz plików buforuje wyjście w blokach po 32 KB i spłukiwał końcowy częściowy blok dopiero w destruktorze, po tym jak pisarz dokumentów już raportował sukces. Dysk pełny albo błąd I/O na tym ostatnim bloku był dla wołającego niewidzialny. Od v3.125.2 w runtime V8 i v3.125.3 w zwykłym runtime ten końcowy spłuk wchodzi w skład wyniku zapisu, a pieczątka modyfikacji XFA jest zdejmowana dopiero po prawdziwym sukcesie. Po stronie Delphi TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean zapisuje do pliku tymczasowego obok celu i przenosi go na miejsce tylko wtedy, gdy zapis zwróci True, więc nieudany zapis zostawia poprzedni plik w całości
Dlaczego dynamiczny formularz XFA otwiera się ponownie z mniejszą liczbą stron?
Dynamiczny formularz XFA otwiera się ponownie z mniejszą liczbą stron, gdy jego subform korzenia nie deklaruje restoreState="auto", a to decyzja projektowa formularza, nie wada PDFium Component. W XFA 3.3 restoreState na subformie korzenia domyślnie jest manual. Przy manual procesor XFA przywraca ze zapisanego pakietu formularza tylko ograniczony stan, a resztę zostawia skryptom autora. Zapisane wartości pól i liczniki instancji powtarzalnych subformów wciąż wracają, ale właściwości geometryczne ustawione w trakcie działania — już nie
Przypadek, który to wystawił, to trójstronicowy formularz, którego skrypt rozrastał subform do h="450pt". Zapisany pakiet formularza trzymał nową wysokość, wartości i liczniki instancji. Przy ponownym otwarciu layout był jednak budowany od wysokości z szablonu i formularz przelewał się na dwie strony. Runtime miał rację: szablon nigdy nie poprosił o automatyczne przywracanie. Zadeklarowanie tego na subformie korzenia naprawia ponowne otwarcie:
<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">
<!-- pola; skrypty mogą zmieniać h albo dodawać instancje w trakcie działania -->
</subform>
</subform>
</template>
Jeśli szablon nie jest twój, nie łataj go w viewerze: formularz polegający na trybie manual oczekuje, że jego własne skrypty odbudują stan. Żywa repaginacja w trakcie pisania przez użytkownika to osobny temat — opisuje go artykuł o tym, jak PDFium Component śledzi liczby stron dynamicznego XFA i przeprowadzone pola
Jak zweryfikować zapis XFA w Delphi?
Jedynym wiarygodnym sprawdzeniem zapisu XFA jest ponowne otwarcie zapisanego pliku w świeżej instancji TPdf i odczytanie przechowanych danych z powrotem. TPdf.GetXfaDatasets zwraca pakiet datasets taki, jak jest przechowany w dokumencie, a nie żywy model danych XFA, więc wołanie go przed zapisem pokazuje stare wartości. Po ponownym otwarciu pokazuje dokładnie to, co zapisano. Dokument w jednym strumieniu nie ma osobno nazwanych pakietów: PDFium raportuje cały XDP jako jeden pakiet o pustej nazwie, więc GetXfaPacketByName('datasets') i GetXfaDatasets nie zwracają niczego, a fallback czyta kompletny strumień przez 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; // układ tablicy pakietów
if Length(Bytes) = 0 then
begin
Packets := Pdf.GetXfaFormPackets; // jeden strumień: jeden nienazwany pakiet
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); // zapisane wyjście XDP jest UTF-8
finally
Pdf.Free;
end;
end;
Procedura zapisu commituje potem oczekującą edycję, sprawdza wynik SaveAs i porównuje ponownie otwartą wartość. TPdf.ClearFormFieldFocus zabija fokus formularza, a to właśnie moment, w którym PDFium commituje bufor edycji pola z fokusem. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean wypełnia pole z fokusem programowo, ale polega na fokusu, który wrapper śledzi przez FocusFormField, chodzące po adnotacjach widgetów. Dynamiczna strona XFA zwykle żadnych nie ma, więc tam tekst zwykle przychodzi przez wejście z klawiatury w TPdfView, a funkcja zwraca False, gdy żadne śledzone pole nie ma fokusu
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
// Opcjonalne wypełnienie skryptowe; False znaczy, że żadne śledzone pole nie ma fokusu
if (Pdf.FocusedFormFieldIndex >= 0) and
not Pdf.SetFocusedFormFieldText(Expected) then
raise EPdfError.Create('Could not write the focused field');
Pdf.ClearFormFieldFocus; // commit bufora edycji
if not Pdf.SaveAs(FileName) then // łącznie z końcowym spłukiwaniem (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;
Traktuj test podtekstu jako smoke test. Pusty element może być serializowany jako <Tag/>, atrybuty mogą się pojawić na elementach danych, a ucieczki poza & i < to wybór serializatora. Do sprawdzeń produkcyjnych wczytaj ponownie otwarty XML prawdziwym parserem XML i porównaj węzeł tekstowy związanego elementu danych. Uruchom sprawdzenie też dwa razy pod rząd, bo wada nowej linii pokazała pełny kształt dopiero na drugiej generacji
Ściąga: lista kontrolna wierności zapisu XFA
- Rozprowadzaj
pdfium.v8.dllz v3.125.2 albo nowszego dla formularzy XFA, a v3.125.3 albo nowszy dla zwykłegopdfium.dll, żeby poprawka zapisu końcowego była w obu - Skieruj
LibraryNamena pełną ścieżkę i ustawEnableV8Enginena True; brakująca ścieżka zawodzi, zamiast wczytywać inną kopię - Potwierdź
TPdf.XFAiTPdf.XfaRuntimeAvailablepo otwarciu dokumentu - Wołaj
ClearFormFieldFocusprzedSaveAs, żeby pole z fokusem zostało zcommitowane - Nigdy nie ignoruj wyniku Boolean
SaveAs; wynik False zostawia poprzedni plik na miejscu - Weryfikuj przez ponowne otwarcie w nowym
TPdfi czytanieGetXfaDatasets, ze schodzeniem doGetXfaFormPacketsdla XFA w jednym strumieniu - Testuj na pustych wartościach, spacjach z przodu, tekście wieloliniowym,
&i znaku z płaszczyzny uzupełniającej, przez dwie generacje zapisu - Oczekuj jawnych porażek zapisu dla DTD, XMLDSig i komentarzy wewnątrz żywych pakietów XFA w jednym strumieniu
- Jeśli dynamiczny formularz gubi geometrię z runtime'u przy ponownym otwarciu, sprawdź subform korzenia pod kątem
restoreState="auto", zanim podejrzysz bibliotekę
O strukturze callbacków, jakiej runtime XFA oczekuje od aplikacji gospodarza, pisze FPDF_FORMFILLINFO wersja 2 i ABI XFA w Delphi. Runtime V8, wrapper dla Delphi i C++Buildera i kontrolka viewera to wszystko części PDFium Component for Delphi and C++Builder, który zawiera oba runtime'y Windows dla Win32 i Win64