Artykuł techniczny

Dlaczego wartości pól formularzy PDF wracają w Delphi

Wartość pola formularza PDF „wraca", gdy pole jest zapisane jako słownik direct: PDFium awansuje kopię z tablicy /Annots strony do obiektu indirect, podczas gdy /Fields trzyma drugą bezpośrednią kopię. PDFium Component dla Delphi obsługuje typowane zapisy pól przez zunifikowanie obu referencji w jednego właściciela indirect przed zapisem

Objaw zwykle brzmi jak nawiedzenie. Zapis zwraca True, a natychmiast po nim odczyt podaje starą wartość. Zapisz, otwórz ponownie — stara wartość nadal tam siedzi. Nic nie rzuciło i PDF wygląda całkowicie zwyczajnie w każdej przeglądarce, jaką posiadasz

Objaw: udany zapis, który się nie klei

Wzorzec porażki jest wąski, ale bardzo powtarzalny: pokazuje się wyłącznie na formularzach, których słowniki pól są zapisane inline (jako obiekty direct), a nie jako numerowane obiekty indirect. Większość narzędzi autorskich emituje pola indirect, więc problem chowa się miesiącami i potem wychodzi na plikach od jednego konkretnego generatora, często ręcznie skleconego eksportera piszącego całe /AcroForm inline

Dwie obserwacje razem wskazują przyczynę: zapis raportuje sukces, a natywny odczyt pola przez warstwę formularzy zwraca poprzednią wartość. Gdy widzisz oba, przestań podejrzewać kodowanie stringów i zacznij patrzeć na własność obiektów

Dlaczego wartość pola formularza PDF wraca po udanym zapisie?

Wartość pola formularza PDF wraca, bo dwa osobne słowniki oba podają się za to samo pole, a zapis zaktualizował kopię, której drzewo pól nie konsultuje. Wartość pola mieszka we wpisie /V, a ISO 32000-1 §12.7.3.1 definiuje, gdzie ten wpis jest wiążący: na słowniku pola osiąganym z /AcroForm /Fields, schodzącym przez /Kids do pola terminalnego, z wpisy dziedzicznymi rozwiązanymi z /Parent

Ta sama klauzula pozwala na wygodę, która rosi wszystkie kłopoty. Gdy pole ma dokładnie jedną adnotację widgeta, słownik pola i słownik adnotacji widgeta mogą być scalone w jeden słownik. W dobrze uformowanym pliku ten scalony słownik to jeden obiekt indirect, referowany z dwóch miejsc: z /Fields (albo /Kids rodzica) i z tablicy /Annots strony. Dwie referencje, jeden obiekt, jedno /V

Dwie kopie jednego słownika

ISO 32000-1 §7.3.10 mówi wprost, że tylko obiekt indirect ma tożsamość, którą można współdzielić. Słownik direct nie ma numeru obiektu, więc gdy generator zapisuje scalony słownik pola/widgeta inline w obu miejscach — w /Fields i w /Annots — plik nie zawiera jednego obiektu referowanego dwa razy. Zawiera dwa wyglądające identycznie słowniki, które przypadkiem mają te same klucze i wartości

PDFium to toleruje i tym samym utrwala rozdwojenie. Gdy potrzebuje uchwytu do adnotacji, PDFium potrafi awansować bezpośredni słownik widgeta w tablicy /Annots strony do obiektu indirect, ale równoważna bezpośrednia kopia zostaje tam, gdzie była, wewnątrz /AcroForm /Fields (albo wewnątrz /Kids rodzica). Od tej chwili dokument trzyma dwóch właścicieli dla jednego logicznego pola. Writer edytujący /V na adnotacji strony dostaje prawdziwy sukces na poziomie słownika. Drzewo pól — to, co warstwa formularzy i każdy późniejszy czytnik traktują jako źródło prawdy — zmiany nigdy nie widzi

Dlatego nieaktualna wartość przeżywa zapis i ponowne otwarcie: nowa wartość została wiernie zserializowana do obiektu, którego hierarchia pól nie posiada

PDFium Component w Delphi: dwa słowniki direct podają się za jedno pole, zapis trafia w awansowaną adnotację, a drzewo pól nadal czyta starą kopię z /Fields
zapis zserializował się wiernie do obiektu, którego hierarchia pól nie posiada, więc nieaktualna wartość przeżywa zapis i ponowne otwarcie

Jak PDFium Component przywraca jednego właściciela pola

PDFium Component normalizuje bezpośrednią własność pól przed każdym typowanym zapisem: awansuje bezpośrednie słowniki /AcroForm i pól do obiektów indirect, wiąże każdy bezpośredni widget z jego pasującą adnotacją strony i przepisuje /Fields, /Kids, /Parent i /Annots tak, żeby każda referencja wskazywała ten sam obiekt indirect. Dopiero potem zmienia /V, więc wartość pola, wybrany widget i zapisany dokument dzielą jednego właściciela

Trudną częścią jest tożsamość. Dwa słowniki direct o identycznej treści nie dowodzą, że to ten sam widget; formularz może legalnie zawierać dwa podobne widgety. Normalizator odmawia więc zgadywania i wymaga jawnej, unikalnej nazwy adnotacji /NM (ISO 32000-1 §12.5.2) na każdym bezpośrednim widgetcie. Reguły stosowane przez ścieżkę zapisu są surowe:

  • Bezpośredni widget potrzebuje niepustego /NM o długości najwyżej 1024 jednostek UTF-16, i dokładnie jedna adnotacja strony może nosić tę samą nazwę
  • Bezpośrednia kopia i adnotacja strony muszą serializować się identycznie poza /Parent i regenerowanym przez PDFium /AP; każda inna różnica odrzuca zapis
  • Oba łącza rodzica są sprawdzane osobno, więc poprawne /Parent na adnotacji strony nie może zamaskować konfliktowego na oryginalnej kopii direct
  • Brakujące /Parent jest dokładane tylko wtedy, gdy unikalna własność w drzewie pól czyni odpowiedź jednoznaczną, a równoważne bezpośrednie kopie rodzica są przepinane do awansowanego właściciela
  • Zduplikowane w pełni kwalifikowane nazwy pól, cykliczne albo wielokrotnie posiadane drzewa pól i pola podpisów odrzucają, zanim cokolwiek zostanie opublikowane
  • Niepowiązane adnotacje, jak linki na tej samej stronie, i niepowiązane wpisy /AcroForm zostają nietknięte

Normalizator działa też pod twardymi budżetami: 4096 odwiedzin drzewa pól, stron albo członów tablic, głębokość zagnieżdżenia 64, 256 nowych obiektów indirect, 1 MiB na awansowany słownik, 4 MiB awansowanych bajtów łącznie, 128 MiB inspekcjonowanych bajtów słowników oraz 4096 jednostek UTF-16 na pełną nazwę pola i 262144 po całym formularzu. Wrogi albo zepsuty plik trafia w budżet i pada z komunikatem błędu, zamiast zamieniać wypełnianie formularza w denial of service

Cała operacja jest transakcyjna. TPdf.SetFormFieldValues uchwytuje snapshot, buduje aktualizację inkrementalną zawierającą znormalizowane obiekty i nowe wartości, ładuje kandydata i podmienia żywy dokument dopiero, gdy kandydat się zweryfikuje. Jeśli normalizacja odrzuci plik albo późniejsze żądanie we wsadzie padnie, oryginalny dokument, jego wartości i logiczny kursor pól zostają dokładnie takie, jakie były

Jak PDFium Component dla Delphi zunifikowuje bezpośrednią własność pól AcroForm: snapshot, kontrola tożsamości NM, unifikacja referencji, zapis wartości i zweryfikowana publikacja z jawnymi ścieżkami odrzucenia
bezpośrednie słowniki pól są awansowane i przepinane do jednego właściciela indirect, zanim zmieni się /V, a dwuznaczna własność odrzuca czysto

Zapisywanie i weryfikowanie wartości pola direct w Delphi

Bezpieczny wzorzec to zapis, potem odczyt zwrotny przez natywną warstwę formularzy i traktowanie rozbieżności jako porażki, nawet gdy zapis zwrócił True. TPdf.SetFormFieldValue adresuje pole indeksem na bieżącej stronie, bierze typowane TPdfFormValue i wypełnia TPdfFormValueWriteReport; TPdf.FormField[Index] czyta wartość zwrotnie przez warstwę formularzy PDFium, która rozwiązuje drzewo pól, zamiast ufać widgetowi

Wzorzec zapisz i zweryfikuj dla pól formularzy PDF w Delphi z PDFium Component: typowane SetFormFieldValue, odczyt zwrotny przez FormField, potem testy zapisu i ponownego otwarcia
samo True dowodzi tylko, że słownik się zmienił — czytaj zwrotnie przez drzewo pól i testuj zapis z ponownym otwarciem
uses
  System.SysUtils, PDFium;

procedure WriteTextField(Pdf: TPdf; PageNo, FieldIndex: Integer;
  const NewText: WString);
var
  Value: TPdfFormValue;
  Report: TPdfFormValueWriteReport;
begin
  Pdf.PageNumber := PageNo;          // SetFormFieldValue celuje w bieżącą stronę
  Value := Default(TPdfFormValue);
  Value.Kind := fvkText;
  Value.Text := NewText;

  if not Pdf.SetFormFieldValue(FieldIndex, Value, Report) then
    raise Exception.CreateFmt('Field %d on page %d rejected: %s',
      [FieldIndex, PageNo, Report.ErrorMessage]);

  // Weryfikuj drzewem pól, nie własną zmienną
  if Pdf.FormField[FieldIndex] <> NewText then
    raise Exception.CreateFmt('Field "%s" did not keep the new value',
      [Pdf.FormFieldInfo[FieldIndex].Name]);
end;

Na pliku z nierozwiązywalną własnością, na przykład bezpośrednim widgetcie bez /NM, SetFormFieldValue zwraca False, Report.UpdatedWidgets jest zerowe, a Report.ErrorMessage nazywa problem, jak "Direct widgets require an explicit unique NM identity". To zachowanie, którego chcesz: jasne odrzucenie zamiast cichego pół zapisu. Jeszcze jeden warunek wstępny wart znajomości. Typowany zapis dotyczy wyłącznie adnotacji i nie zdobywa natywnego fokusu, więc gdy natywny edytor pola nadal trzyma fokus, cała transakcja jest odrzucana przed uchwyceniem snapshotu; wołaj Pdf.ClearFormFieldFocus świadomie na początku, zamiast ponawiać w pętli

Dla kilku pól, zwłaszcza między stronami, użyj formy wsadowej. TPdf.SetFormFieldValues przyjmuje od 1 do 256 żądań, aplikuje wszystkie na jednym kandydacie i publikuje wszystko albo nic. TPdfFormValuesWriteReport.FailedRequest mówi ci, które żądanie połamało wsad

procedure FillInvoiceHeader(Pdf: TPdf);
var
  Requests: TPdfFormValueRequests;
  Batch: TPdfFormValuesWriteReport;
begin
  SetLength(Requests, 2);
  Requests[0] := Default(TPdfFormValueRequest);
  Requests[0].Page := 1;
  Requests[0].FieldIndex := 0;
  Requests[0].Value.Kind := fvkText;
  Requests[0].Value.Text := 'ACME Tooling Inc.';

  Requests[1] := Default(TPdfFormValueRequest);
  Requests[1].Page := 2;
  Requests[1].FieldIndex := 0;
  Requests[1].Value.Kind := fvkText;
  Requests[1].Value.Text := '2026-10-27';

  if not Pdf.SetFormFieldValues(Requests, Batch) then
    raise Exception.CreateFmt('Request %d failed: %s',
      [Batch.FailedRequest, Batch.ErrorMessage]);
  // Batch.UpdatedFields = 2; Batch.UpdatedWidgets liczy każdy zsynchronizowany widget
end;

Dwa żądania adresujące to samo pole terminalne są odrzucane jako duplikat właściciela, zamiast pozwolić ostatniemu zapisowi wygrać, a dokument źródłowy jest zachowany. Wartości checkbox i radio podlegają tym samym regułom; podział /V kontra /AS dla przycisków opisuje wartość pola checkbox kontra stan widgeta w PDFium

Dowodzenie, że wartość przeżywa zapis i ponowne otwarcie

Odczyt zwrotny w tej samej sesji dowodzi, że żywy dokument jest spójny. Zapis z ponownym otwarciem dowodzi, że plik jest, a kontrola jest na tyle tania, że warto trzymać ją we własnych testach integracyjnych

procedure SaveAndReopenCheck(Pdf: TPdf; const Target: string;
  PageNo, FieldIndex: Integer; const Expected: WString);
var
  Reopened: TPdf;
begin
  if not Pdf.SaveAs(Target, saNoIncremental, pvUnknown) then
    raise Exception.Create('Save failed');

  Reopened := TPdf.Create(nil);
  try
    Reopened.FileName := Target;
    Reopened.Active := True;
    Reopened.PageNumber := PageNo;
    if Reopened.FormField[FieldIndex] <> Expected then
      raise Exception.Create('Saved file lost the field value');
  finally
    Reopened.Free;
  end;
end;

Jeszcze dwie pułapki bajtowe: Trim i trailer /ID

Normalizowanie bezpośrednich pól znaczy ponowne czytanie surowych bajtów słowników, co przypomina o dwóch mniejszych pułapkach istotnych dla każdego kodu Delphi dotykającego składni PDF

Pierwsza to SysUtils.Trim na surowych bajtach PDF trzymanych w AnsiString. Literal string jak opcja choice może legalnie zawierać tekst UTF-16BE ze znacznikiem kolejności bajtów FE FF i osadzonymi bajtami NUL. W Unicode Delphi SysUtils.Trim bierze string, więc wywołanie po cichu konwertuje AnsiString na UTF-16 aktywną stroną kodową ANSI i konwertuje wynik z powrotem. Zależnie od tej strony kodowej runda potrafi przepisać wysokie bajty, uszkodzić BOM i nawet ruszyć delimiter idący za nim. Bezpieczne podejście trzyma lexer na bajtach: pomijaj białe znaki PDF pozycyjnie (ISO 32000-1 §7.2.2 wylicza NUL, tab, LF, FF, CR i spację) i tnij przez Copy, dekodując dopiero, gdy wiadomo, że wartość to PDF text string

// Ryzykowne na surowych bajtach PDF: AnsiString -> string -> AnsiString (W1057/W1058)
Token := Trim(Token);

// Bezpieczne dla bajtów: pomijaj białe znaki PDF pozycyjnie, potem tnij
P := 1;
while (P <= Length(Token)) and (Token[P] in [#0, #9, #10, #12, #13, ' ']) do
  Inc(P);
Token := Copy(Token, P, MaxInt);

Druga pułapka siedzi w samej aktualizacji inkrementalnej. ISO 32000-1 §7.5.5 (Table 15) i §14.4 wymagają, by trailer /ID był tablicą dwóch stringów bajtowych. Wewnętrzny helper składający identyfikator rzadkiej aktualizacji zwraca tylko zawartości dwóch stringów, bez klucza /ID i nawiasów tablicy, a trailer musi owinąć to jawnie jako /ID [ ... ]. Pobłażliwy loader PDFium i późniejszy pełny zapis ponownie oba chowają brakujące opakowanie, więc niezawodną kontrolą jest sparsowanie kandydata i zapisanych plików niezależnym surowym parserem i szukanie dwóch stringów po 16 bajtów. Jeśli sam piszesz aktualizacje inkrementalne, ta sama pułapka obowiązuje; artykuł o aktualizacjach inkrementalnych zaszyfrowanych PDF-ów schodzi głębiej w łańcuch trailera

Co zrobić we własnym kodzie formularzy Delphi?

  • Zawsze czytaj wartość zwrotnie przez FormField[Index] po zapisie, a w testach dodatkowo zapisuj, otwieraj ponownie i czytaj jeszcze raz
  • Traktuj False plus ErrorMessage jako informację: odrzucenie nazywające NM, konflikt rodzica albo duplikat nazwy znaczy, że własność pliku jest faktycznie dwuznaczna
  • Jeśli generujesz PDF-y, emituj pola i widgety jako obiekty indirect i dawaj każdemu widgetowi unikalne /NM; to usuwa dwuznaczność u źródła
  • Używaj SetFormFieldValues do wypełnień wielopolowych i międzystronicowych, żeby późna porażka nie mogła zostawić połowy formularza zaktualizowanej
  • Nigdy nie puszczaj Trim ani innych helperów RTL dla string po surowych bajtach PDF trzymanych w AnsiString

Miej też jasność co do granic. Dokumenty podpisane, formularze XFA, zaszyfrowane snapshoty i pliki, których własność widgetów nie da się rozwiązać jednoznacznie, zostają poza zadeklarowanym profilem i są odrzucane zamiast być przepychane na siłę. Jeśli twój workflow przenosi pola między dokumentami, pytania o własność robią się jeszcze trudniejsze; przeszczepianie pól AcroForm między dokumentami PDF zajmuje się tym przypadkiem

PDFium Component wnosi to typowane, transakcyjne pisanie formularzy do aplikacji Delphi, C++Builder i Lazarus obok renderowania, ekstrakcji tekstu i edycji dokumentów. Historia wersji, licencjonowanie i pobrania są na stronie produktu PDFium Component dla Delphi