Artykuł techniczny

Uruchamianie kodu AcroForm JavaScript w Delphi za pomocą PDFium Component

Formularz, którego sumy przeliczają się w programie Acrobat, lecz pozostają zamrożone w Twojej własnej przeglądarce w Delphi, prawie nigdy nie jest wynikiem błędu renderowania. Biblioteka PDFium blokuje wykonywanie kodu JavaScript w dokumentach, chyba że aplikacja hosta dołączy platformę JS. Komponent PDFium Component dołącza tę platformę automatycznie począwszy od wersji v2.13.2, dzięki czemu kod AcroForm JavaScript, skrypty poziomu dokumentu oraz kalkulacje pól są uruchamiane za każdym razem, gdy załadowana jest biblioteka DLL z włączoną obsługą silnika V8. Aplikacja hosta zachowuje kontrolę poprzez zestaw zdarzeń, które mogą wyświetlać, przekierowywać lub blokować wszelkie operacje skryptów

To jedno zdanie wyjaśnia problem, z którym często zgłaszają się programiści: „mój formularz zamówienia przelicza sumy pozycji w programie Acrobat, lecz nie w mojej aplikacji”, „funkcja app.alert nigdy się nie wywołuje” czy „pole daty nie formatuje się automatycznie”. Wszystkie te przypadki sprowadzają się do tej samej zasady działania, a jej zrozumienie jest kluczowe, ponieważ ta sama zasada stanowi barierę bezpieczeństwa chroniącą przed złośliwymi dokumentami

Dlaczego kalkulacje formularzy PDF nie działają w bibliotece PDFium?

PDFium traktuje kod JavaScript w dokumentach jako funkcję opcjonalną (opt-in): jeśli składnik m_pJsPlatform środowiska wypełniania formularzy ma wartość NULL, każdy skrypt w dokumencie jest cicho pomijany. Brak błędu, brak wpisu w logach, brak częściowego wykonania. Pola obliczeniowe zachowują ostatnią zapisaną wartość, funkcja app.alert nie działa, skrypty formatowania nie uruchamiają się. Program Acrobat, który zawsze posiada wbudowany silnik JavaScript, bez problemu otwiera ten sam plik, dlatego ta różnica wygląda na błąd w Twoim kodzie, podczas gdy w rzeczywistości jest to celowe domyślne zachowanie biblioteki

Komponent PDFium Component miał w tym obszarze dodatkowe zaszłości historyczne. Do wersji v2.13.1 powiązanie dołączało platformę m_pJsPlatform wyłącznie w ścieżce inicjalizacji formularzy XFA, stąd zwykłe dokumenty AcroForm (stanowiące zdecydowaną większość skryptowanych plików PDF) nigdy nie otrzymywały platformy JavaScript, nawet w obecności biblioteki DLL z silnikiem V8. W wersji v2.13.2 odłączono to powiązanie od decyzji o XFA: metoda InitializeFormFill buduje teraz tabelę IPDF_JsPlatform za każdym razem, gdy funkcja V8FeaturesAvailable zwraca wartość True, niezależnie od tego, czy dokument korzysta z technologii XFA. Praktyczny skutek to poprawny przebieg skryptów poziomu dokumentu, łańcuchów kalkulacji pól oraz wywołań app.alert również w tradycyjnych dokumentach AcroForm

Której biblioteki DLL V8 wymaga AcroForm JavaScript w Delphi?

Obsługa AcroForm JavaScript wymaga pliku binarnego PDFium skompilowanego z silnikiem V8, który komponent PDFium Component ładuje jako pdfium.v8.dll, jeśli globalna flaga EnableV8Engine ma wartość True przed pierwszym załadowaniem biblioteki. Warto tu wspomnieć o jednej pułapce: komponent automatycznie wykrywa dokumenty XFA poprzez wstępne skanowanie w poszukiwaniu znaczników /XFA i sam włącza EnableV8Engine, lecz zwykły dokument AcroForm ze skryptami JavaScript nie posiada znacznika XFA, więc automatyczne wykrycie nie nastąpi. Jeśli Twoja przeglądarka ma obsługiwać skrypty formularzy, ustaw tę flagę ręcznie, jednorazowo, przed załadowaniem pierwszego dokumentu; po załadowaniu zwykłej biblioteki pdfium.dll proces nie może już przełączyć silników

uses PDFium;

procedure TViewerForm.FormCreate(Sender: TObject);
begin
  // Musi zostać uruchomione przed pierwszym załadowaniem biblioteki PDFium;
  // po załadowaniu zwykłej biblioteki pdfium.dll nie można jej podmienić
  EnableV8Engine := True;
end;

procedure TViewerForm.OpenDocument(const FileName: string);
begin
  FPdf.LoadDocument(FileName);
  if not V8FeaturesAvailable then
    StatusBar.SimpleText :=
      'JavaScript wyłączony: biblioteka pdfium.v8.dll nie została wdrożona';
end;

Funkcja V8FeaturesAvailable to rzeczywisty test czasu uruchomienia: informuje, czy załadowana biblioteka faktycznie udostępnia eksporty zależne od silnika V8. Test pytający o te eksporty działa niezależnie od tego, która biblioteka DLL została wdrożona w systemie. Ten sam silnik i ta sama flaga obsługują również dynamiczne renderowanie formularzy XFA; szczegóły integracji wykrywania XFA z automatycznym doborem silnika V8 opisano w artykule o wykrywaniu formularzy XFA i wyodrębnianiu pakietów XFA za pomocą PDFium

Zdarzenia hosta dla okien dialogowych, drukowania, poczty i wysyłania formularzy

Każda widoczna operacja skryptu jest przekazywana z powrotem do Twojego kodu za pomocą zdarzeń obiektu TPdf, a każde z nich domyślnie pozostaje bezpieczną, pustą procedurą, jeśli nie zostanie przypisane. Zdarzenie OnJavaScriptAlert odbiera komunikat, tytuł, typ przycisków oraz ikonę z wywołania app.alert i pozwala przekazać kod naciśniętego przycisku; zdarzenie OnJavaScriptResponse obsługuje monity wejściowe app.response (w tym flagę hasła); natomiast zdarzenia OnJavaScriptBeep, OnJavaScriptPrint, OnJavaScriptMail oraz OnJavaScriptSubmitForm obsługują żądania sygnału dźwiękowego, drukowania, wysyłania poczty i formularzy wraz z ich kompletnym zestawem parametrów. Przeglądarka, która nie obsługuje tych zdarzeń, nadal prawidłowo renderuje i oblicza dane; dokument po prostu nie będzie mógł otwierać okien dialogowych, drukować ani przesyłać danych

procedure TViewerForm.PdfJavaScriptAlert(Sender: TObject;
  const Msg, Title: WString; ButtonType, Icon: Integer;
  var Result_: Integer; var Handled: Boolean);
begin
  // Przekieruj wywołanie app.alert do VCL, aby okno było powiązane z naszą aplikacją
  Result_ := MessageBox(Handle, PWideChar(Msg), PWideChar(Title),
    MB_OK or MB_ICONINFORMATION);
  Handled := True;
end;

procedure TViewerForm.PdfJavaScriptSubmitForm(Sender: TObject;
  const Url: WString; const Data: TBytes);
begin
  // Dane nie zostaną przesłane, dopóki ta procedura nie zdecyduje o ich wysłaniu
  LogAudit(Format('Document requested form submit to %s (%d bytes)',
    [Url, Length(Data)]));
end;

To domyślne zachowanie ma duże znaczenie dla bezpieczeństwa. Zdarzenia OnJavaScriptMail oraz OnJavaScriptSubmitForm mają charakter czysto informacyjny: komponent PDFium Component nie nawiązuje samodzielnie połączeń sieciowych ani nie korzysta z interfejsu MAPI, więc złośliwy dokument wywołujący metody this.submitForm lub this.mailDoc w nieprzygotowanym programie hosta nic nie zdziała. Aplikacja hosta musi jawnie włączyć tę obsługę poprzez przypisanie procedury i samodzielną implementację przesyłania danych. Oznacza to, że decyzja o wysłaniu danych poza maszynę należy wyłącznie do Ciebie i jest podejmowana w Twoim kodzie, z pełnym dostępem do adresu URL i przesyłanych danych

Jak powstrzymać złośliwy plik PDF przed otwieraniem adresów URL?

Akcje adresów URI to jedyne miejsce, w którym historycznie domyślne zachowanie miało charakter aktywny, a nie pasywny, stąd wprowadzono tu dedykowane zabezpieczenie. Przed wersją v2.13.1 funkcja zwrotna FFI_DoURIActionWithKeyboardModifier bezwarunkowo otwierała za pomocą procedury systemowej dowolny adres URI podany przez pole XFA, co umożliwiało złośliwym dokumentom uruchamianie adresów URL po kliknięciu. Zdarzenie OnXfaUriAction eliminuje to zagrożenie: komponent sprawdza je przed wykonaniem akcji, a procedura ustawiająca parametr Handled na True całkowicie blokuje domyślne wywołanie ShellExecuteW. Brak przypisania tego zdarzenia zachowuje dotychczasowe działanie ze względów kompatybilności, stąd aplikacje dbające o bezpieczeństwo powinny zawsze je obsługiwać

procedure TViewerForm.PdfXfaUriAction(Sender: TObject;
  const Uri: WString; Modifiers: Integer; var Handled: Boolean);
begin
  // Zablokuj wszystko, co nie korzysta z bezpiecznego połączenia https; domyślnie
  // procedura ShellExecuteW otworzyłaby podany adres bez zmian
  if not SameText(Copy(Uri, 1, 8), 'https://') then
  begin
    LogAudit('Blocked URI action: ' + Uri);
    Handled := True;
  end;
end;

Biała lista protokołów to absolutne minimum; bardziej rygorystyczne programy żądają potwierdzenia od użytkownika lub otwierają zawartość w piaskownicy (sandbox) przeglądarki. Ta sama zasada weryfikacji dotyczy zestawu zdarzeń z wersji v2.13.1: OnXfaEmail, OnXfaHttpRequest oraz OnXfaOpenFile udostępniają parametry tekstowe żądanych akcji, podczas gdy sam komponent nie wykonuje żadnych połączeń sieciowych ani nie otwiera plików. Sposób wdrożenia tych zabezpieczeń w ramach strategii obsługi nieznanych dokumentów (w tym izolacji renderowania) opisano w artykule o budowaniu bezpiecznego podglądu plików PDF w Delphi

Zapisywanie danych do aktywnego pola za pomocą SetFocusedFormFieldText

Metoda TPdf.SetFocusedFormFieldText, wprowadzona w wersji v2.13.2, to uzupełnienie funkcji odczytu FocusedFormFieldText: zaznacza całą zawartość aktywnego pola za pomocą FORM_SelectAllText i zastępuje ją nowym tekstem przez FORM_ReplaceSelection, dokładnie tak, jakby użytkownik wpisał te dane ręcznie. Ponieważ tekst wprowadzany jest ścieżką edycji interaktywnej, skrypty klawiszy, formatowania i kalkulacji powiązane z polem uruchamiają się tak samo jak przy wprowadzaniu ręcznym. Sprawia to, że funkcja ta idealnie nadaje się do automatycznego wypełniania pól w przeglądarkach z aktywną obsługą JavaScript. Tematykę przechodzenia między polami i zarządzania fokusem opisano w artykule o nawigacji po polach formularza za pomocą PDFium Component

if FPdf.FocusedFormFieldIndex >= 0 then
begin
  if not FPdf.SetFocusedFormFieldText('42.50') then
    ShowMessage('No focused field accepts text on this page');
  // AcroForm: wartość jest zapisywana w /V w momencie opuszczenia pola.
  // XFA: dane pozostają wyłącznie w buforze edycji w pamięci
  // i nie zostaną zachowane przy zapisie
end;

Asymetria zapisu opisana w komentarzu to realna cecha systemu, a nie ostrzeżenie na pokaz. W przypadku pól tekstowych i list rozwijanych (combo) AcroForm, zmodyfikowana wartość trafia do klucza /V pola w momencie utraty fokusu i jest zachowywana przy zapisie pliku. Dla pól tekstowych XFA zapis trafia wyłącznie do bufora edycji w pamięci, ponieważ biblioteka PDFium nie udostępnia publicznego API do przenoszenia wartości widgetów z powrotem do struktury XML pakietów XFA; zapisanie dokumentu nie utrwali tych zmian. Jeśli wymagana jest trwała edycja danych XFA, należy modyfikować bezpośrednio kod XML pakietu datasets, a nie sam widget

Ograniczenia, które warto znać przed wdrożeniem

Pozostają dwa realne ograniczenia. Po pierwsze, implementowane przez PDFium API JavaScript to funkcjonalny podzbiór modelu obiektów programu Acrobat, skupiony na obsłudze formularzy: dostęp do pól, zdarzenia kalkulacji i formatowania, funkcje app.alert i app.response oraz żądania drukowania, poczty i formularzy. Dokumenty korzystające ze specyficznych obiektów dostępnych wyłącznie w programie Acrobat mogą nie działać poprawnie (zazwyczaj bez zgłaszania błędów), dlatego należy przetestować rzeczywiste formularze używane przez klientów zamiast zakładać pełną zgodność. Po drugie, włączenie obsługi V8 wymaga wdrożenia biblioteki pdfium.v8.dll, która jest znacznie większa od wersji standardowej; programy, które nie potrzebują skryptów ani technologii XFA, mogą pozostać przy mniejszej bibliotece DLL bez dołączania platformy m_pJsPlatform, co samo w sobie stanowi dobry krok w kierunku bezpieczeństwa

Podsumowując, zalecany schemat działania jest prosty: ustaw flagę EnableV8Engine przy uruchomieniu aplikacji, przypisz zdarzenia alertów i odpowiedzi, aby okna dialogowe wyglądały naturalnie, przypisz zdarzenie OnXfaUriAction i rejestruj w logach zdarzenia poczty oraz formularzy, pozostawiając pozostałe ustawienia domyślne bez zmian. Pełny opis zdarzeń oraz API formularzy znajduje się na stronie produktu PDFium Component