Technical Article

Interaktywne formularze PDF w Delphi: Akcje i JavaScript

Samo pole formularza PDF to tylko pole przechowujące wartość. To, co sprawia, że formularz zachowuje się jak mała aplikacja, to dołączona do niego akcja: kliknięcie ukrywające sekcję, pobranie zapisanych wartości z pliku, przejście do ostatniej strony lub uruchomienie skryptu sumującego kolumnę. Nic z tego nie znajduje się w samym polu. Żyje to w słowniku akcji, a standard ISO 32000-1 organizuje całą tę rodzinę w §12.6. W tym artykule omówiono akcje, po które program w Delphi sięga najczęściej, oraz pokazano, jak PDFlibPas łączy każdą z nich z polem lub linkiem.

Model pojęciowy, o którym warto pamiętać, zakłada, że pole i akcja to osobne obiekty połączone referencją. Adnotacja widżetu lub adnotacja linku przenosi akcję w swoim wpisie /A. Akcja identyfikuje pole, na którym operuje, za pomocą nazwy (tytułu), a nie indeksu, więc tytuł nadany polu jest uchwytem, którego każda kolejna akcja używa do jego odnalezienia. Gdy ten podział stanie się jasny, API przestaje wyglądać jak losowy zestaw wywołań, a zaczyna jawić się jako jeden wzorzec zastosowany do czterech rodzajów operacji.

Nazwane akcje: nawigacja bez numeru strony

Najprostsze akcje nie przenoszą żadnych parametrów. Standard ISO 32000-1 §12.6.4.11, tabela 194, definiuje nazwane akcje (named actions): przeglądarka interpretuje symboliczną nazwę w czasie wykonywania, zamiast podążać za zapisanym celem. Powszechnie obsługiwane są cztery nazwy i są to dokładnie te, których czytelnik oczekuje od paska narzędzi: NextPage, PrevPage, FirstPage i LastPage. Ponieważ cel jest względny w stosunku do strony, którą przeglądarka aktualnie wyświetla, przycisk Dalej zbudowany w ten sposób działa na każdej stronie bez konieczności obliczania celu przez programistę.

W PDFlibPas nazwana akcja jest dołączana do prostokąta aktywny obszar (hotspot) na bieżącej stronie. Czwarty i piąty argument typu integer wybierają operację oraz wygląd.

// NamedActionType: 0 = NextPage, 1 = PrevPage, 2 = FirstPage, 3 = LastPage
// Options bit 0 (value 1) draws a border around the hotspot
Pdf.AddLinkToNamedAction(500, 560, 60, 18, 0, 1);   // Next
Pdf.AddLinkToNamedAction(40, 560, 60, 18, 1, 1);    // Previous
Pdf.AddLinkToNamedAction(110, 560, 60, 18, 3, 1);   // jump to last page

Nie ma tu celu, który trzeba by synchronizować, i o to właśnie chodzi. Nazwana akcja przetrwa wstawianie i usuwanie stron, ponieważ w ogóle nie wskazuje konkretnej strony. Kontrastuje to z jawnym linkiem typu go-to, który przechowuje indeks strony docelowej, wymagający ponownej numeracji w momencie, gdy dokument się rozrasta.

Akcja Hide i pułapka związana z tablicą

Akcja Hide, opisana w ISO 32000-1 §12.6.4.10, tabela 196, przełącza widoczność jednego lub więcej pól. Jest to najczystszy sposób na zbudowanie zachowania pokazywania i ukrywania bez użycia skryptów i doskonale nadaje się dla linków typu Pokaż szczegóły lub dwóch wykluczających się wzajemnie paneli, gdzie ujawnienie jednego ukrywa drugi. Akcja ta niesie cel w swoim wpisie /T oraz wartość logiczną /H, która decyduje o kierunku: ukryj, gdy ma wartość true, pokaż, gdy false.

Subtelność tkwi w sposobie kodowania tego celu i jest to ten rodzaj szczegółu, który sprawia, że formularz działa na komputerze dewelopera, a u klienta już nie. Gdy akcja wskazuje pojedyncze pole, wpis /T jest zapisywany jako pojedynczy ciąg tekstowy. Gdy wskazuje kilka pól, /T jest zapisywany jako tablica ciągów tekstowych. Starsze przeglądarki nie traktują tablicy jednoelementowej tak samo jak zwykłego ciągu znaków, więc kodowanie musi zależeć od liczby pól: pojedyncza nazwa musi być wygenerowana jako ciąg znaków, a nie tablica o długości jeden, aby najszersza gama czytników mogła ją obsłużyć. PDFlibPas podejmuje tę decyzję za Ciebie. Przekazujesz nazwy pól rozdzielone przecinkami, średnikami lub znakami nowej linii, a program zapisujący generuje pojedynczy ciąg znaków dla jednej nazwy oraz tablicę dla dwóch lub więcej.

// HideFlag non-zero hides the listed fields (/H true); zero shows them.
// One name -> /T is a text string. Two or more -> /T is an array of strings.
Pdf.AddLinkToHideField(40, 700, 90, 18, 'ShippingAddress', 1, 1);
Pdf.AddLinkToHideField(140, 700, 90, 18,
  'ShippingName,ShippingAddress,ShippingZip', 1, 1);

Ponieważ akcja nie odwołuje się do żadnego zewnętrznego zasobu, pozostaje zgodna z PDF/A. Przekazywane nazwy to w pełni kwalifikowane nazwy pól, dlatego pole podrzędne w grupie musi być adresowane za pomocą pełnej ścieżki z kropkami, a nie tylko samej nazwy liścia.

ImportData: wstępne wypełnianie z FDF

Tam, gdzie akcja Hide reorganizuje to, co już znajduje się na stronie, akcja importowania danych pobiera wartości z zewnątrz. Standard ISO 32000-1 §12.6.4.8, tabela 198, definiuje ją jako akcję, która wypełnia formularz AcroForm z pliku Forms Data Format (FDF) na dysku. Jest to akcja stojąca za kontrolkami typu Przeładuj przykładowe dane lub Przywróć domyślne, gdzie plik FDF jest dostarczany obok dokumentu PDF i zawiera kanoniczne wartości pól. Wywołanie to odzwierciedla inne akcje, przyjmując prostokąt aktywnego obszaru, ścieżkę do pliku FDF i maskę bitową wyglądu: Pdf.AddLinkToImportData(40, 660, 120, 18, 'defaults.fdf', 1). Plik nie musi istnieć w momencie tworzenia pliku PDF, ale musi być obecny, gdy użytkownik kliknie link, a wszelkie ukośniki odwrotne w ścieżce są automatycznie zamieniane na ukośniki zgodne ze standardem PDF.

Jedno ograniczenie warto sformułować jasno, ponieważ bywa zaskoczeniem. Akcja importu danych wskazuje na plik zewnętrzny, dlatego nie jest dozwolona w formacie PDF/A. Gdy dokument działa w trybie PDF/A, wywołanie zwraca zero i nic nie dodaje, zamiast generować plik, który nie przejdzie walidacji. Jeśli Twój proces przetwarzania ma na celu wygenerowanie wyjściowego archiwum, wstępne wypełnianie must nastąpić w momencie generowania dokumentu poprzez bezpośrednie zapisanie wartości pól, a nie odłożenie tego do momentu kliknięcia.

JavaScript: pakiety globalne i skrypty powiązane z akcjami

W przypadku logiki wykraczającej poza pokazywanie, ukrywanie i importowanie, rodzina akcji sięga po JavaScript na poziomie dokumentu. Skrypt może znajdować się w dwóch różnych miejscach, a różnica między nimi ma kluczowe znaczenie. Pakiet JavaScript na poziomie dokumentu jest zapisywany raz dla całego pliku i uruchamia się przy otwarciu dokumentu, co czyni go odpowiednim miejscem dla definicji funkcji i współdzielonego stanu. Skrypt powiązany z akcją (per-action) jest dołączony do jednego linku lub pola i uruchamia się tylko wtedy, gdy ten obiekt zostanie aktywowany, co czyni go odpowiednim miejscem na tę jedną linijkę kodu, która wywołuje funkcję zdefiniowaną już w pakiecie globalnym.

PDFlibPas udostępnia obie te opcje. AddGlobalJavaScript zapisuje nazwany pakiet na poziomie dokumentu; ponowne użycie nazwy zastępuje to, co było pod nią zapisane. Z kolei AddLinkToJavaScript dołącza skrypt do aktywnego obszaru, dzięki czemu kliknięcie go powoduje uruchomienie skryptu.

// Document-level package: define a reusable function once.
Pdf.AddGlobalJavaScript('Totals',
  'function recalcTotal() {' +
  '  var net = this.getField("Net").value;' +
  '  var tax = this.getField("Tax").value;' +
  '  this.getField("Gross").value = Number(net) + Number(tax);' +
  '}');

// Per-action script on a link: just call the shared function.
Pdf.AddLinkToJavaScript(40, 620, 100, 18, 'recalcTotal();', 1);

Trzymanie funkcji w pakiecie globalnym, a wywołania w linku, nie wynika jedynie z preferencji stylistycznych. Pozwala to uniknąć duplikowania tej samej zawartości w każdej kontrolce, która jej potrzebuje. Oznacza to również, że przeglądarka z wyłączoną obsługą skryptów po kliknięciu po prostu nic nie zrobi, zamiast zawiesić się na błędnie sformułowanym obiekcie wierszowym. Ponadto pozwala to zachować niewielki rozmiar wpisów powiązanych z poszczególnymi akcjami, co ułatwia czytanie struktury pliku podczas jego późniejszej inspekcji.

Pola, pola podrzędne i zamrażanie wyniku

Akcje potrzebują pól, na których mogą operować, więc warto zobaczyć, jak powstaje pole. Funkcja NewFormField tworzy pole na bieżącej stronie i zwraca jego indeks; typ całkowity określa rodzaj pola, gdzie 1 to Text, 2 to Pushbutton, 3 to Checkbox, 4 to Radiobutton, 5 to Choice, 6 to Signature, a 7 to Parent, które posiada dzieci, ale samo nic nie rysuje. Przekazywany tytuł nie może zawierać kropki, ponieważ kropka służy jako separator w w pełni kwalifikowanych nazwach, których akcje używają do adresowania pól podrzędnych.

Grupy radiowe i formularze hierarchiczne są budowane poprzez przypisywanie pól podrzędnych do pola nadrzędnego. NewChildFormField dodaje element podrzędny pod nazwanym elementem nadrzędnym, a w przypadku pól opcji (radio) i wyboru (choice) AddFormFieldSub dodaje poszczególne opcje i zwraca tymczasowy indeks służący do pozycjonowania każdej z nich. Gdy faza interaktywna dobiegnie końca i chcesz zamrozić pole, aby jego aktualny wygląd stał się stałą zawartością strony, FlattenFormField rysuje pole na stronie i usuwa je z formularza. Po operacji spłaszczania (flatten) indeksy kolejnych pól przesuwają się w dół o jeden, o czym należy pamiętać, jeśli spłaszczasz wiele pól w pętli.

var
  Pdf: TPDFlib;
  FldShip: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.SetOrigin(1);          // top-left origin
    Pdf.SetPageSize('A4');
    Pdf.NewPage;

    // A text field the Hide action will target by its title.
    FldShip := Pdf.NewFormField('ShippingAddress', 1);
    Pdf.SetFormFieldBounds(FldShip, 40, 120, 240, 20);
    Pdf.SetFormFieldValue(FldShip, '');

    // Wire a Hide link and a navigation link to this page.
    Pdf.DrawText(40, 110, 'Toggle shipping block:');
    Pdf.AddLinkToHideField(220, 100, 70, 16, 'ShippingAddress', 1, 1);
    Pdf.AddLinkToNamedAction(500, 800, 60, 18, 3, 1);  // Last page

    // A document-level script available to every event in the file.
    Pdf.AddGlobalJavaScript('OnOpen',
      'app.alert("Form ready", 3);');

    // Freeze the field if the output should no longer be editable.
    // Pdf.FlattenFormField(FldShip);

    if Pdf.SaveToFile('form_actions.pdf') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

Wywołanie flatten jest celowo zakomentowane. Pominięcie go sprawia, że dokument jest wysyłany jako aktywny formularz, którego akcje są uruchamiane w czytniku. Włączenie go powoduje wyrenderowanie pola do postaci statycznych znaków, co jest pożądane, gdy formularz został wypełniony, a wynik ma być przesyłany jako niezmienna instancja. To samo pole, ten sam kod, ale dwa skrajnie różne dokumenty w zależności od tego, czy go zamrozisz.

Wybór właściwej operacji

Cztery opisywane akcje dzielą się wyraźnie ze względu na to, na co wpływają. Nazwana akcja przesuwa obszar roboczy przeglądarki i nie wymaga żadnego pola. Akcja Hide zmienia widoczność i wymaga nazw pól, przy czym kodowanie ciąg-kontra-tablica jest obsługiwane automatycznie. Akcja importu danych sięga do pliku na dysku, stąd jest niedozwolona w PDF/A. Akcja JavaScript uruchamia dowolną logikę i najlepiej podzielić ją na globalny pakiet funkcji oraz małe wywołania powiązane z akcjami. Sięgaj po najprostsze rozwiązanie realizujące dane zadanie: akcja Hide jest bardziej przenośna niż skrypt ustawiający flagę ukrycia, a nazwana akcja jest trwalsza niż zapisany cel strony, ponieważ nie wymaga utrzymywania konkretnego numeru.

W tym miejscu obrazu dopełniają dwa powiązane tematy. Jeśli formularz jest częścią dokumentu o wysokiej dostępności, drzewo struktury, po którym poruszają się czytniki ekranu, zostało omówione w naszym artykule na temat struktury i dostępności znaczników PDF (tagged PDF). Gdy wypełniony formularz musi zostać zablokowany i podpisany, procedurę tę opisuje przewodnik po warsztacie zgodności i podpisywania. Wszystkie te trzy mechanizmy bazują na tym samym silniku, który jest dostarczany jako biblioteka PDF dla Delphi wraz z interfejsami API do tworzenia, obsługi formularzy oraz podpisów omówionymi w innych miejscach na tym blogu.