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

PDFium Component XFA save: нови редове, emoji и restoreState

PDFium Component записва редактираните XFA стойности на формата точно, през запис и повторно отваряне, когато върти Windows V8 runtime pdfium.v8.dll, доставен в v3.125.2 или по-нов. По-старите runtime-и долепяха line feeds към стойностите на полетата, режеха emoji до несвързан знак от BMP, тихо прескачаха single-stream XFA записите и можеха да погълнат провален финален запис. Един симптом при повторно отваряне изобщо не е дефект на библиотеката: dynamic форма, чиито root subform липсва restoreState="auto", си преизгражда layout-а от шаблона

Бъг репортите за всичко това си приличаха. Клиент попълва XFA форма за искане в Delphi viewer, записва, отваря пак, и нещо е леко настрани. Празна кутия за коментари вече държи празен ред, а след втори запис — два. Име, написано с emoji, се връща с glyph от private-use областта. Никой не получава грешка, а точно това прави тези бъгове скъпи: разминаването излиза наяве седмици по-късно в чужд экспорт

Какво се чупи, когато XFA форма бъде записана и отворена пак?

Четири отделни дефекта в native XFA save пътя причиняваха разминаване на стойностите, и всеки се криеше зад запис, изглеждащ успешен. Две идваха от сериализацията, едно от подредбата на single-stream съхранението и едно от самия PDF writer. Таблицата нанася всеки симптом срещу причината му и срещу release-а, в който PDFium Component го е поправил

Симптом при повторно отварянеПричинаПоправено в
Празно поле държи line feed; стойностите добиват по един нов ред на записИ двата XFA writer-а вмъкваха layout нови редове след start таговеv3.125.2, pdfium.v8.dll
U+1F642 се връща като U+F642, или emoji-то изчезва от form пакета16-битово отрязване в wchar_t при декодиране; surrogate филтриране в form сериализатораv3.125.2, pdfium.v8.dll
Редакциите в single-stream XFA документ просто ги нямаNative save отхвърля stream подредбата, но връщаната стойност беше игнориранаv3.125.2; коментари и processing инструкции се пазят от v3.126.0
Отрязан файл, макар записът да докладва успехПоследният буфериран запис проваля, след като writer-ът вече е върнал успехv3.125.2 V8 runtime; v3.125.3 обикновен pdfium.dll
Тристранична dynamic форма се отваря наново като две странициRoot subform не иска restoreState="auto"Авторство на формата, не дефект на библиотеката

По-ранни текстове заключваха, че редакциите на XFA полета изобщо не могат да се запазят с PDFium, което беше вярно за runtime-ите по онова време. По-новият V8 runtime записва XFA стойностите нативно, така че редакция, направена в живата форма, стига до записания datasets пакет без да си правите surgery върху пакети от ваша страна

Кой PDFium runtime записва XFA стойности?

XFA save точността зависи от native DLL-а, не от Delphi wrapper-а, така че първата проверка е кой runtime реално е заредил процесът ви. PDFium Component доставя по два Windows билда на архитектура: обикновения pdfium.dll, билднат без V8 и XFA, и pdfium.v8.dll, който носи JavaScript двигателя и XFA form runtime-а. Само pdfium.v8.dll може да пусне XFA форма, така че всяка XFA поправка, описана тук, живее там — почвайки от преизградените Win32 и Win64 V8 библиотеки в v3.125.2

Поправката за финалния запис е генеричен PDF writer код, така че има значение и за обикновени документи. v3.125.3 преизгради обикновените pdfium.dll библиотеки, за да носят същата поправка. Споделен source не е доказателство за споделено поведение: докато бинарият не се преизгради, старият DLL пази стария бъг

Втори капан седеше в loader-а. Преди v3.125.2 задаването на EnableV8Engine на True караше binding-а да вземе default името pdfium.v8.dll и да игнорира пълен път в LibraryName. Приложение, сочащо току-що деплойнат runtime, можеше да продължи да зарежда по-старо копие от друга папка. От v3.125.2 насам LibraryName, съдържащ директория, избира точно този файл и в двата режима на двигателя, а липсващ път проваля вместо да се прехвърли към друга вградена библиотека

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;  // провали се при стартиране, не при първия запис
end;

След отваряне на документ TPdf.XFA ви казва, че файлът съдържа XFA, а TPdf.XfaRuntimeAvailable — че заредената DLL може реално да го изпълни. Ако трябва и да различите static от dynamic форми, TPdf.FormType връща ftXfaFull или ftXfaForeground; статията за разпознаване на XFA форми и извличане на XFA пакети в Delphi покрива това проучване в детайли

Защо записаните XFA полета добиват излишни line feeds?

Записаните XFA полета добиваха line feeds, защото и двата native XFA writer-а — генеричният XML element writer и сериализаторът на form пакет — pretty-print-ваха изхода си с нов ред след start таговете. В повечето XML този whitespace е козметичен. В XFA данните не е: когато datasets пакетът се разпарси пак, текстът между <Comments> и </Comments> е стойността на полето — новият ред включително. Празно поле затова се отваряше наново с един LF, а всеки следващ цикъл запис-отваряне можеше да долепи още един

Диаграма на XFA save цикъла в PDFium Component, в която writer-ът добавя нов ред след start таговете, повторно отвореният парсер чете LF между таговете Comments като стойност на полето, а всеки следващ запис долепя още един line feed, докато v3.125.2 не маха само синтезирания от сериализатора whitespace
Един цикъл запис-отваряне засажда първия line feed, а всеки следващ рунд долепя още един — затова разминаването показа пълния си вид чак на второто поколение

Очевидната поправка — подрязване на стойностите при зареждане — би била грешна. Потребителите пишат водещи интервали, завършващи интервали и нарочно многоредов текст в XFA полета, а адресен блок или код с фиксирана ширина трябва да оцеляват байт по байт. Поправката в v3.125.2 затова маха само whitespace-а, който самият сериализатор е синтезирал около таговете. Потребителските стойности, съществуващите текстови възли и CDATA секциите минават непипнати, така че " indented" си остава с отстъп, а нарочно празно поле си остава празно

Защо emoji се връща като друг знак?

Emoji се връщаше объркано, защото Windows wchar_t е широк 16 бита, а два декодиращи пътя записваха пълна Unicode скаларна стойност в един-единствен wchar_t. UTF-8 stream декодерът и парсерът за числови символни референции като &#x1F642; и двамата го правеха. U+1F642, леко усмихнатото лице, не се събира в 16 бита, така че високите битове отпадаха и вместо него излизаше U+F642 — code point в Private Use Area, който повечето шрифтове рисуват като кутия или нищо

Form сериализаторът имаше обратния проблем. Той филтрираше знаците по един wchar_t наведнъж, виждаше две surrogate кодови единици, невалидни поотделно, и ги хвърляше и двете, така че emoji-то изчезваше изцяло от form пакета. В v3.125.2 декодерът изяжда всяка скаларна стойност докрай и извежда правилна surrogate двойка. Когато остане само един изходен слот, държи ниската surrogate на чакане и не докладва край на stream, докато тази единица е още в буфера. UTF-8 последователност, разцепена между read блокове, се пренася към следващото четене вместо да се изхвърля. Form exporter-ът вече държи валидните surrogate двойки заедно, а числовите символни референции също дават правилни двойки

Диаграма на обработката на surrogates в PDFium Component, в която U+1F642 пристига като UTF-16 двойка D83D DE42 и два дефектни пътя я опорчват: 16-битовите wchar_t декодери отрязват скалара до U+F642 в private use областта, а form сериализаторът филтрира самотни surrogates и изхвърля emoji-то изцяло
Windows wchar_t е широк 16 бита, така че скалар, който се нуждае от surrogate двойка, или губеше високата си половина, или изчезваше от пакета, докато и двата пътя се научат да държат двойките заедно

Latin-1 тестови данни никога не показват нищо от това, така че всеки XFA round-trip тест се нуждае от поне един знак от supplementary равнината

Single-stream XFA и записани провали, които никой не е виждал

Single-stream XFA документ губеше редакциите си, защото native save помощникът отхвърляше тази подредба на съхранението, а извикващият го игнорираше провала. ISO 32000-1 §12.7.8 позволява записът /XFA на речника на интерактивната форма да е или масив от имена на пакети и streams, или единичен stream, държащ целия XDP документ. Масивите от пакети са обичайният случай, но единичните streams са съвсем законни, а PDF записът се завършваше сякаш нищо не се е случило, докато данните на формата си оставаха на старите стойности

От v3.125.2 насам V8 runtime-ът обслужва поддържаното single-stream подмножество. Първо той експортира и двата живи пакета, datasets и form, в staging област и ги валидира, и чак тогава заменя съответните пакети в оригиналния XDP. Другите пакети и декларациите на root namespace се пазят. Ако staging провали, персистентният XFA stream изобщо не се пипа и документът пази своята отметка за промяна

XML коментарите и processing инструкциите поискаха допълнителни грижи, защото вътрешният XML DOM ги изхвърля. В v3.125.2 наличието им караше записа да провали направо, вместо съдържание да се губи тихо. v3.126.0 ги запазва: преди парсването всеки коментар или processing инструкция се разменя за маркер, построен от префикс, който не се среща никъде в оригиналния текст. След заменянето на живите пакети всеки маркер трябва да се появи точно веднъж, преди оригиналният токен да бъде възстановен и stream-ът да бъде записан. Токените извън заменените пакети затова пазят текста и реда си — включително токените в prolog-а, template-а и другите пакети

Някои входове се отказват и до днес нарочно, и всеки отказ е изричен провал на записа:

  • Коментари или processing инструкции вътре в живите пакети datasets или form, защото оригиналните им позиции не могат да се нанасят върху току-що експортирано съдържание
  • DTD декларации и XMLDSig подписи, защото презаписването на XDP не може да държи XML подпис валиден
  • Невалидно UTF-8 или UTF-16 кодиране, непълни тагове, невалидни символни референции, неизвестни entities и повредени processing инструкции, които се отхвърлят вместо да се поправят тихо
Схема на single-stream XFA save конвейера в PDFium Component, в която живите пакети datasets и form се експортират в staging, валидират се и после се заменят вътре в оригиналния XDP с запазени коментари чрез маркери, докато staging провали и входове като DTD или XMLDSig отказват записа изрично
Поставеният в staging export се валидира, преди нещо да бъде заменено, така че провален запис оставя персистентния XFA stream непипнат и документът пази отметката си за промяна

Single-stream изходът е UTF-8 и запазва XML content модела, а не оригиналната байтова подредба или декларацията за кодиране

Последният дефект седеше под XFA. Native file writer-ът буферира изхода в блокове от 32 KB и изплакваше последния частичен блок само в своя деструктор — след като document writer-ът вече е докладвал успех. Disk-full или I/O грешка на този последен блок беше невидима за извикващия. От v3.125.2 в V8 runtime-а и v3.125.3 в обикновения runtime този финален flush е част от резултата на записа, а отметката за XFA модификация се изчиства само след истински успех. От Delphi страната TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean пише във временен файл до целевия и го мества на място само когато записът върне True, така че провален запис оставя предишния файл непокътнат

Защо dynamic XFA форма се отваря наново с по-малко страници?

Dynamic XFA форма се отваря наново с по-малко страници, когато root subform-ът ѝ не декларира restoreState="auto", а това е решение при авторството на формата, не дефект на PDFium Component. В XFA 3.3 restoreState на root subform-а по подразбиране е manual. Под manual XFA процесорът възстановява само ограничено състояние от записания form пакет и оставя останалото на скриптовете на автора. Записаните стойности на полета и бройките на повторяеми subform инстанции пак се връщат, но геометричните свойства, зададени по време на изпълнение, не

Случаят, който показа това, беше тристранична форма, чийто скрипт разраства subform до h="450pt". Записаният form пакет държеше новата височина, стойностите и бройките инстанции. При повторното отваряне обаче layout-ът беше преизграден от височините на шаблона и формата се преля на две страници. Runtime-ът беше прав: шаблонът никога не е искал автоматично възстановяване. Декларирането му върху root subform-а оправя повторното отваряне:

<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">
      <!-- полета; скриптовете могат да сменят h или да добавят инстанции по време на работа -->
    </subform>
  </subform>
</template>

Ако не притежавате шаблона, не го заобикаляйте с кръпки в viewer-а: форма, разчитаща на режим manual, очаква собствените си скриптове да възстановят състоянието. Живата repagination, докато потребителят пише, е отделна тема, разгледана в как PDFium Component следи dynamic XFA броя страници и преместените полета

Как да верифицирате XFA запис в Delphi?

Единствената надеждна проверка на XFA запис е да отворите записания файл наново в свеж екземпляр TPdf и да прочетете записаните данни обратно. TPdf.GetXfaDatasets връща datasets пакета такъв, какъвто е записан в документа, а не живия XFA модел на данните, така че извикването му преди записа показва старите стойности. След повторното отваряне показва точно това, което е написано. 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;          // подредба с масив от пакети
    if Length(Bytes) = 0 then
    begin
      Packets := Pdf.GetXfaFormPackets;   // единичен 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;

Рутината за запис после предава чакащата редакция, проверява резултата от SaveAs и сравнява повторно отворената стойност. TPdf.ClearFormFieldFocus убива form фокуса — моментът, в който PDFium предава edit буфера на фокусираното поле. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean попълва фокусираното поле програмно, но разчита на фокус, който wrapper-ът следи чрез FocusFormField, обходяща widget annotations. Dynamic XFA страница обикновено няма такива, така че там текстът обикновено пристига чрез клавиатурен вход в TPdfView, а функцията връща False, когато нито едно проследено поле не е на фокус

function XmlText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
end;

procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
  Expected: string);
var
  Saved: string;
begin
  // Опционално запълване по скрипт; False значи, че нищо проследено поле не е на фокус
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // предава edit буфера
  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-ът отвъд & и < е избор на сериализатора. За production проверки заредете повторно отворения XML с истински XML парсер и сравнете текстовия възел на свързания data елемент. Пуснете проверката и два пъти подред, защото дефектът с новия ред показа пълния си вид чак на второто поколение

Бърза справка: чеклист за XFA save точност

  • Деплойнете pdfium.v8.dll от v3.125.2 или по-нов за XFA форми, и v3.125.3 или по-нов за обикновения pdfium.dll, така че поправката за финалния запис да е и на двете
  • Насочете LibraryName към пълен път и задайте EnableV8Engine на True; липсващ път проваля вместо да зареди друго копие
  • Потвърдете TPdf.XFA и TPdf.XfaRuntimeAvailable след отварянето на документа
  • Викайте ClearFormFieldFocus преди SaveAs, така че фокусираното поле да бъде предадено
  • Никога не игнорирайте Boolean резултата на SaveAs; False оставя предишния файл на място
  • Верифицирайте чрез повторно отваряне в нов TPdf и четене на GetXfaDatasets, с fallback към GetXfaFormPackets за single-stream XFA
  • Тествайте с празни стойности, водещи интервали, многоредов текст, & и знак от supplementary равнината, през две поколения запис
  • Очаквайте изрични провали на записа за DTD, XMLDSig и коментари вътре в живите пакети на single-stream XFA
  • Ако dynamic форма губи изпълнена геометрия при повторно отваряне, проверете root subform-а за restoreState="auto", преди да подозирате библиотеката

За callback структурата, която XFA runtime-ът очаква от host приложение, вижте FPDF_FORMFILLINFO version 2 и XFA ABI в Delphi. V8 runtime-ът, Delphi и C++Builder wrapper-ът и viewer контролата са всички част от PDFium Component за Delphi и C++Builder, който включва и двата Windows runtime-а за Win32 и Win64