Artykuł techniczny

Dziedziczone wartości pól AcroForm i resetowanie w Delphi

HotPDF Delphi Component traktuje /FT, /Ff, /V i /DV na wczytanym polu AcroForm jako atrybuty dziedziczne, rozwiązywane przez spacer po łańcuchu /Parent. Od v2.754.3 i v2.754.4 nazwane dziecko, którego typ pochodzi od rodzica, pozostaje indywidualnie adresowalne, RemoveFormField zostawia jego rodzeństwo w spokoju, a ResetLoadedFormField kopiuje odziedziczony domyślny z jego oryginalnym typem obiektu PDF. Wcześniej zaskakująco dużo zwyczajnych formularzy było błędnie czytanych

Formularz, który to wszystko odsłania, nie jest egzotyczny. Narzędzie autorskie buduje węzeł grupy group, który niesie raz /FT /Ch, flagi pola i listę opcji, a pod nim wiesza dwoje nazwanych dzieci a i b, każde złączony słownik pole-plus-widget z niczym poza /T, /Parent, /Rect i własnym /V. To całkowicie legalny sposób współdzielenia atrybutów i dokładnie ten przypadek, który sekcja Limits artykułu o ustawianiu wartości pól formularza we wczytanym PDF w Delphi oznaczyła jako nieobsłużony: uzgadnianie przycisków patrzyło tylko na lokalne /FT. Ten artykuł podchodzi tam, gdzie tamten się zatrzymał, obejmując klasyfikację drzewa pól, czytanie wartości odziedziczonych i to, co reset pojedynczego pola ma prawo zapisać

Które wpisy AcroForm pole może odziedziczyć po rodzicu?

ISO 32000-1 §12.7.3.1, Tabela 220, oznacza /FT, /Ff, /V i /DV jako dziedziczne, a Tabela 229 w §12.7.4.3 robi to samo z /MaxLen pola tekstowego, więc każdy czytnik patrzący tylko na lokalny słownik zgłosi zły typ, złe flagi i pustą wartość dla całkowicie poprawnego dziecka. HotPDF przepuszcza wszystkie te odczyty przez jeden wewnętrzny resolver, HPDFLoadedInheritedFieldObject, który sprawdza słownik pod kątem klucza, rozwiązuje referencję pośrednią, jeśli ją znajdzie, a w przeciwnym razie idzie za /Parent najwyżej 128 poziomów, bo zniekształcone pliki potrafią budować cykle /Parent nie mające nic wspólnego z /Kids. Na nim siedzą publiczne gettery: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue oraz helpery opcji GetLoadedFormFieldOptionCount i GetLoadedFormFieldOptions, które łapią też tablicę /Opt zapisaną na rodzicu. Jedna reguła w resolverze łatwo ucha: spacer zatrzymuje się na pierwszym słowniku zawierającym klucz, nawet jeśli wartością jest tam pusty łańcuch. Lokalne /V () to celowe przysłonięcie rodzica, a nie luka do wypełnienia z wyższych pięter drzewa

Diagram odziedziczonych atrybutów AcroForm w HotPDF: węzeł grupy niesie raz /FT, /Ff i /Opt, podczas gdy nazwane dzieci group.a i group.b trzymają tylko /T, /Parent, /Rect i lokalne /V, pokazując HPDFLoadedInheritedFieldObject idący po /Parent do 128 poziomów, gdzie wygrywa pierwszy słownik trzymający klucz, a pusta lokalna wartość przysłania rodzica
HotPDF rozwiązuje /FT, /Ff, /V, /DV i /Opt przez jeden resolver chodzący po rodzicach, więc nazwane dziecko pozostaje adresowalne, a lokalna pusta wartość celowo przysłania wszystko, co niesie grupa nad nim
var
  Pdf: THotPDF;
  Field: THPDFLoadedFormField;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    // 'group' niesie /FT /Ch, /Ff 131078 i /Opt; dziecko
    // 'group.b' niesie tylko /T, /Parent, /Rect i własne /V
    Field := Pdf.GetFormField('group.b');
    try
      if Pdf.GetFormFieldType(Field.Index) = lfftChoice then
      begin
        // 131078 = Combo (bit 18) + NoExport (bit 3) + Required (bit 2)
        Writeln(Pdf.GetLoadedFormFieldFlags(Field.Index));
        Writeln(Pdf.IsFormFieldRequired(Field.Index));    // TRUE
        Writeln(Pdf.GetLoadedFormFieldOptionCount(Field.Index));
        Writeln(Pdf.GetFormFieldValue(Field.Index));       // lokalne /V
      end;
    finally
      Field.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Dlaczego lokalne /FT to zły test na pole terminalne?

Bo rodzic może podawać typ i nadal posiadać nazwane pola dzieci, więc obecność /FT nie mówi nic o tym, gdzie kończy się drzewo pól. Stare przejście ogłaszało węzeł terminalnym, gdy tylko miał własne /FT albo brak /Kids. W formularzu wyżej group ma i /FT /Ch, i /Kids, więc był rejestrowany jako jedno pole o nazwie group z dwoma widgetami, a pełne nazwy kwalifikowane group.a i group.b po prostu znikały. GetFormFieldCount zwracał 1, wyszukanie po nazwie dziecka padało, a SetFormFieldValue mógł pisać tylko do współdzielonego rodzica. Zastępczy test, HPDFLoadedFieldHasChildFields, patrzy na dzieci zamiast na rodzica: element /Kids jest polem-dzieckiem, jeśli ma własne /T, ma własne /Kids albo w ogóle nie jest słownikiem /Subtype /Widget. Tylko gdy żadne dziecko się nie kwalifikuje, węzeł jest terminalny, a jego dzieci traktowane są jako jego adnotacje widget

Oba przypadki brzegowe, które ukształtowały tę regułę, wychodzą ze słowników złączonych, na jakie §12.7.3.1 pozwala, gdy pole ma pojedynczy widget. Nazwany złączony słownik niesie /Subtype /Widget i nadal jest polem-dzieckiem, więc sam subtyp nie może go wysłać do anonimowej listy widgetów rodzica; wygrywa /T. Odwrotność też się zdarza: część producentów powtarza /FT rodzica na każdym anonimowym widgecie, więc /FT nie może służyć jako dowód, że widget rozpoczyna nowe pole. Klasyfikację współdzielą cache relacji, FormFieldExists i RemoveFormField, a każde z tych przejść zapisuje teraz odwiedzone słowniki i zatrzymuje się po 128 poziomach. Plik regresyjny, którego grupa wypisuje siebie dwa razy, /Kids [5 0 R 5 0 R 6 0 R 7 0 R], nadal raportuje dokładnie dwa pola zamiast rekursować w nieskończoność albo liczyć ten sam węzeł podwójnie

Jak RemoveFormField unika usuwania pól rodzeństwa?

RemoveFormField usuwa teraz tylko nazwane przez ciebie dziecko, bo odkrywanie i usuwanie wreszcie zgodziły się co do tego, czym jest pole terminalne. Ta zgoda znaczy więcej, niż wygląda. Wersja po nazwie rozwiązuje indeks przez cache relacji, a potem liczy pola terminalne w drugim przejściu po /AcroForm /Fields. Gdy tylko cache naprawiono, by widział group.a i group.b, nienaprawione przejście usuwające nadal traktowałoby group jako pojedyncze pole terminalne, a indeks 0 usunąłby rodzica razem z całym rodzeństwem i wszystkimi ich widgetami. Przejście usuwające używa teraz tego samego testu HPDFLoadedFieldHasChildFields i tego samego zbioru odwiedzonych, zbiera adnotacje widget usuniętego dziecka i tylko jego, zdejmuje je z /Annots każdej strony, a rodzica usuwa tylko wtedy, gdy jego tablica /Kids zostaje pusta. Regresja sprawdza wszystkie trzy miejsca, gdzie błąd by wyszedł: /Kids rodzica, /Annots strony oraz wartość i wygląd przeżywszego rodzeństwa, zarówno po pełnym przepisaniu, jak i po aktualizacji przyrostowej

Diagram przeżycia rodzeństwa w RemoveFormField HotPDF: przejście usuwające używa ponownie HPDFLoadedFieldHasChildFields i zbioru odwiedzonych z odkrywania, zdejmuje z AcroForm /Fields i /Annots strony tylko nazwane dziecko group.a i trzyma współdzielonego rodzica, dopóki jego tablica /Kids nadal mieści przeżywsze group.b
Odkrywanie i usuwanie wreszcie zgodziły się co do tego, czym jest pole terminalne, więc usunięcie jednego nazwanego dziecka zostawia wartość i wygląd jego rodzeństwa nietknięte po pełnym przepisaniu albo aktualizacji przyrostowej
// Usuń jedno nazwane dziecko; jego rodzeństwo i współdzielony rodzic przeżyją
Pdf.RemoveFormField('group.a');

Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Typ, flagi i opcje są nadal rozwiązywane przez rodzica
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');

Co zapisuje ResetLoadedFormField, gdy domyślny jest dziedziczony?

ResetLoadedFormField zapisuje lokalne /V będące świeżą kopią odziedziczonego /DV z tym samym typem obiektu PDF i waliduje cały domyślny, zanim dotknie pola. Typ obiektu ma znaczenie, bo skalarne gettery spłaszczają wszystko do tekstu. Domyślny checkboxa to nazwa typu /Yes, domyślny listy wielokrotnego wyboru to tablica łańcuchów, a domyślny tekstowy może być szesnastkowym łańcuchem UTF-16; skopiowanie któregokolwiek przez GetLoadedFormFieldDefaultValue zamieniłoby nazwę w łańcuch, tablicę w pusty łańcuch, a łańcuch szesnastkowy w jego literalne cyfry. Reset rozgałęzia się więc po odziedziczonym typie: pola tekstowe i wyboru dostają nowy obiekt łańcucha zachowujący flagę IsHexadecimal, pola wyboru z domyślnym tablicowym dostają nową tablicę nowych łańcuchów, a przyciski inne niż pushbutton dostają nowy obiekt nazwy. Kopiowanie, zamiast wskazywać na obiekty rodzica, jest celowe: /V współdzielące tablicę /DV rodzica albo jej numer obiektu zmieniłoby domyślny, gdy ktokolwiek następnym razem edytowałby wartość. Domyślny złego typu albo tablica wyboru zawierająca cokolwiek poza łańcuchami rzuca wyjątek i zostawia /V oraz /I dokładnie takie, jakie były. Pushbuttony, które nie mają wartości (Tabela 226, bit 17), i pola podpisu schodzą na starszą ścieżkę tylko-łańcuchową

Diagram typowanego resetu w HotPDF: ResetLoadedFormField rozgałęzia się po typie obiektu odziedziczonego /DV, zapisując świeży obiekt nazwy dla checkboxa, nową tablicę nowych łańcuchów dla wyboru wielokrotnego, łańcuch zachowujący IsHexadecimal dla tekstu szesnastkowego, pusty łańcuch albo /Off, gdy nie ma /DV, i rzucając bez dotykania /V ani /I przy niezgodności typu
Kopiowanie zamiast wskazywania na obiekty rodzica chroni przed po cichu zmianą domyślnego przy późniejszej edycji wartości, a pushbuttony i pola podpisu schodzą na starszą ścieżkę tylko-łańcuchową

Gdy nie ma /DV nigdzie w górę łańcucha, metoda dotrzymuje swojej umowy czyszczenia, zapisując lokalny pusty łańcuch, albo /Off dla checkboxa albo pola radio. Skasowanie lokalnego /V wyglądałoby schludniej i byłoby błędem: rodzic może trzymać aktualną wartość, a zdjęcie przysłonięcia dziecka po cichu przywróciłoby tę wartość. Dlatego też reset pojedynczego pola to nie akcja ResetForm z §12.7.5.3, którą przeglądarka puszcza na zbiorze pól, gdy użytkownik kliknie przycisk, jak opisano w artykule o budowaniu pól i akcji AcroForm w HotPDF. ResetLoadedFormField to operacja edycyjna na jednym wczytanym polu, z własną regułą na przypadek braku domyślnego, i zapisuje pole przez NoteLoadedFormFieldDirty, żeby przyrostowe przeliczanie widziało zmianę

var
  Field: THPDFLoadedFormField;
begin
  Field := Pdf.GetFormField('group.a');
  try
    // Rodzic trzyma /DV [(b) (r)] na liście MultiSelect: group.a dostaje
    // własne /V [(b) (r)] i świeże /I [0 2]; rodzic nietknięty
    Pdf.ResetLoadedFormField(Field.Index);
    // Skalarne gettery nie umieją przedstawić domyślnego tablicowego
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // pusty
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Zgoda między /V, /I i /AS

Reset jest poprawny tylko wtedy, gdy indeks wyboru i stan wyglądu idą za wartością, więc ResetLoadedFormField kończy się tymi samymi dwoma uzgadniaczami co SetFormFieldValue. HPDFReconcileChoiceSelection przyjmuje teraz wartość tablicową: usuwa lokalne /I bez mutowania go, dopasowuje każdą wartość do eksportowej połowy każdego wpisu /Opt i zapisuje jedno nowe sortowane /I, więc reset do [(b) (r)] przy opcjach b, g, r daje /I [0 2]. ReconcileLoadedButtonAppearanceStates pyta teraz o typ odziedziczony, więc checkbox dziecko, którego /FT /Btn mieszka na rodzicu, wreszcie dostaje ustawione swoje /AS. Po stronie zapisu SetFormFieldValue i SetLoadedFormFieldDefaultValue przechowują obiekt nazwy dla odziedziczonego przycisku innego niż pushbutton, nawet gdy dziecko nie ma lokalnego wpisu, z którego można skopiować typ. A gdy EnsureLoadedFieldAppearanceStream przebudowuje wyglądy przycisków, zapisuje /AS /Off, chyba że wartość pasuje do stanu włączenia, i daje każdemu strumieniowi stanu porządne /Type /XObject, /Subtype /Form i /BBox; przed v2.754.4 odtworzenie wyglądu po resecie mogło zaznaczyć checkbox od nowa, zanim plik został zapisany

Granice warte poznania, zanim na tym zbudujesz

Skalarne gettery pozostają skalarne. GetFormFieldValue i GetLoadedFormFieldDefaultValue zwracają pusty łańcuch dla wartości tablicowej, sprowadzają liczby i booleany do 42 albo true i raportują łańcuch zakodowany szesnastkowo w jego zapisie szesnastkowym. Cykl /Parent kończy spacer bez wyjątku, więc pole, którego typ przepadł w cyklu, raportuje lfftUnknown i flagi 0, zamiast padać. SetFormFieldValue i ResetLoadedFormField zawsze zapisują dziecko, które zaadresujesz, i nigdy nie promują wartości do współdzielonego rodzica, co jest słuszne dla niezależnych dzieci, ale znaczy, że grupy radio należy zaadresować przez pole posiadające wybór. I każde wywołanie komituje jedno pole na własną rękę; nic tu nie czyni z partii resetów transakcji

Rozwiązywanie atrybutów dziedzicznych, ujednolicona klasyfikacja drzewa pól i typowany reset opisane tutaj są częścią API formularzy wczytanych w HotPDF Delphi Component dla Delphi i C++Buildera, obok tworzenia pól omówionego w artykule o dodawaniu pól AcroForm do wczytanego PDF w Delphi