Artykuł techniczny

Indeks widgetu a indeks adnotacji w formularzach PDFium Delphi

W PDFium Component, opartym na PDFium komponencie VCL/LCL dla Delphi, C++Buildera i Lazarusa, indeks pola formularza nie jest indeksem adnotacji. Strona niesie adnotacje Link, Text i Ink obok swoich widgetów, więc wyliczanie pól musi filtrować po FPDFAnnot_GetSubtype i wystawiać zerowo indeksowany indeks logiczny, mapowany z powrotem na rzeczywistą pozycję adnotacji dopiero przy wywołaniu natywnym

Błąd, który to ujawnia, jest jednoznaczny, gdy raz go zobaczysz. Tester naciska Tab w wypełnionym formularzu faktury, a kursor znika, ponieważ fokus przeszedł na hiperłącze w stopce. Albo gorzej: nic się nie dzieje, twój kod zapisuje pole 3 jako sfokusowane, panel interfejsu się aktualizuje, a FORM_SetFocusedAnnot po cichu zwracała false przez cały czas. Oba objawy wynikają z tego samego błędu projektowego, a jeden z nich ma drugą, ukrytą przyczynę źródłową

Dwie przestrzenie indeksów, które daje ci PDFium

PDFium wystawia dwa systemy numeracji nad tą samą stroną, i pokrywają się one tylko na dokumentach, które akurat nie zawierają niczego poza widgetami formularza. Pierwszy to indeks adnotacji: pozycja w tablicy strony /Annots, którą liczy FPDFPage_GetAnnotCount i którą przyjmuje FPDFPage_GetAnnot (ISO 32000-1 §12.5.2). Drugi to logiczny indeks pola, który API na poziomie aplikacji powinno oferować, liczony od zera po interaktywnych polach, do których użytkownik faktycznie może dotrzeć. ISO 32000-1 §12.5.6.19 definiuje widgety adnotacji jako wizualną reprezentację interaktywnych pól formularza, a §12.7 definiuje sam formularz. Wszystko inne na stronie to inny podtyp o innej semantyce: adnotacja Link ma cel, adnotacja Ink ma listę pociągnięć, adnotacja Text to karteczka samoprzylepna. Żadna z nich nie należy do liczby pól i żadna nie może przyjąć fokusu formularza. A jednak w tablicy /Annots siedzą przeplecione z widgetami w dowolnej kolejności, w jakiej zapisała je aplikacja produkująca, co często wcale nie jest kolejnością, jaką sugerowałoby cokolwiek innego w dokumencie

Dlaczego Tab ląduje na hiperłączu zamiast na kolejnym polu?

Ponieważ liczba pól była w rzeczywistości liczbą adnotacji. Oryginalna implementacja zwracała FPDFPage_GetAnnotCount bezpośrednio z FormFieldCount, podczas gdy akcesor informacji o polu, pomocnik kolejności tabulacji i pomocnik fokusu traktowały tę samą liczbę całkowitą jako pozycję widgetu. Na czystej stronie AcroForm z sześcioma widgetami i niczym więcej sześć równa się sześciu i każdy test przechodzi. Dodaj hiperłącze w stopce i komentarz recenzenta na marginesie, a liczba zgłasza osiem pól, indeksy 6 i 7 rozwiązują się do obiektów niebędących formularzem, a Tab wchodzi prosto w nie

Poprawka na końcu wyliczania polega na liczeniu podtypów zamiast adnotacji. Otwórz każdą adnotację, zapytaj o jej podtyp, zachowaj widgety i zamknij uchwyt w bloku finally, ponieważ FPDFPage_GetAnnot zwraca uchwyt na własność, który musi wrócić przez FPDFPage_CloseAnnot

function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
  Count, I: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := 0;
  if Page = nil then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);   // every annotation, not just fields
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
        Inc(Result);
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

Zwróć uwagę na to, czego to celowo nie robi. Nie pyta niczego środowiska wypełniania formularza i nie potrzebuje uchwytu formularza, ponieważ podtyp żyje w słowniku adnotacji i jest odczytywalny wyłącznie ze strony. To ma znaczenie dla kolejności: liczba jest dostępna, zanim jeszcze zdecydowałeś, czy dokument w ogóle zasługuje na środowisko wypełniania formularza, co artykuł o JavaScript AcroForm i zdarzeniach hosta omawia jako decyzję bezpieczeństwa, a nie wygody

Mapowanie indeksu logicznego z powrotem na natywnej granicy

Reguła utrzymująca obie przestrzenie przed wzajemnym przeciekaniem jest prosta: indeks logiczny to jedyna liczba przekraczająca twoje publiczne API, i jest konwertowana na indeks adnotacji w ostatniej funkcji przed wywołaniem natywnym. Jeden pomocnik mapujący, używany zarówno przez informacje o polu, fokus, ustawiacze flag, jak i kolejność tabulacji, sprawia, że ta reguła jest egzekwowalna

function AnnotationIndexForField(Page: FPDF_PAGE;
  FieldIndex: Integer): Integer;
var
  Count, I, Current: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := -1;
  if (Page = nil) or (FieldIndex < 0) then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);
  Current := 0;
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
      begin
        if Current = FieldIndex then
          Exit(I);        // real /Annots position: native calls only
        Inc(Current);
      end;
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

Dwie właściwości tego pomocnika warto wprost wypowiedzieć. To przeszukiwanie liniowe, więc naiwna pętla po każdym polu kosztuje kwadratową liczbę otwarć adnotacji na stronie z setkami widgetów; jeśli wyliczasz całą stronę, przejdź przez adnotacje raz i zbierz uchwyty widgetów po drodze zamiast wywoływać mapper na każde pole. I zwraca -1 zamiast rzucać, co pozwala wywołującemu zdecydować, czy nieaktualny indeks jest błędem programistycznym wartym wyjątku, czy wyścigiem wartym zignorowania, na przykład po edycji, która usunęła adnotację, do której wciąż odwołuje się buforowana lista interfejsu

Dlaczego FORM_SetFocusedAnnot zawodzi na stronie bez interfejsu?

Ponieważ PDFium odmawia sfokusowania widgetu, którego widok strony nigdy nie został oznaczony jako ważny. FORM_SetFocusedAnnot rozwiązuje adnotację do widoku strony wewnątrz środowiska wypełniania formularza, a jeśli ten widok strony nie istnieje, zwraca false bez żadnej diagnostyki. Sama poprawka mapowania indeksu naprawia więc lądowanie Tab na hiperłączu, ale pozostawia nietknięty drugi objaw: twój logiczny rekord fokusu mówi „pole 3", natywnie sfokusowany widget wciąż jest niczym, a każdy akcesor zbudowany na natywnym fokusie — sfokusowany tekst, sfokusowana wartość, stan wyboru z listy — wciąż zwraca pustkę. Widok strony jest tworzony przez FORM_OnAfterLoadPage i niszczony przez FORM_OnBeforeClosePage. W przeglądarce zbudowanej wokół kontrolki wizualnej te wywołania zdarzają się jako część wyświetlania strony, dlatego ta awaria tak często wygląda jak błąd wyłącznie bez interfejsu: ten sam kod, który działa w demie z GUI, zawodzi w narzędziu wsadowym. Cykl życia należy do obiektu dokumentu, nie do przeglądarki, więc PDFium Component wydaje teraz oba wywołania, ilekroć strona jest wczytywana lub zwalniana przy obecnym uchwycie formularza. Sygnatura C przyjmuje najpierw stronę, a potem uchwyt formularza, co łatwo odwrócić, pisząc wiązanie ręcznie

procedure ReportFirstField(const FileName: string);
var
  Pdf: TPdf;
  Idx: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FormFill := True;      // form-fill environment, before Active
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Pdf.PageNumber := 1;       // page load also runs FORM_OnAfterLoadPage

    Idx := Pdf.FocusNextFormField;   // logical index, 0-based over widgets
    if Idx < 0 then
      Exit;                    // page holds no widget annotations

    Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
      string(Pdf.FocusedFormFieldValue));   // reads the native focused widget
  finally
    Pdf.Free;                  // page unload runs FORM_OnBeforeClosePage
  end;
end;

Sprawdzenie dowodzące poprawki jest tym, które porównuje obie strony. Wywołaj FocusFormField z indeksem logicznym, a potem odczytaj wartość przez akcesor, który przechodzi przez natywnie sfokusowany widget, a nie przez twój własny rekord, jak FocusedFormFieldValue lub FocusedFormOptionSelected. Jeśli indeks logiczny wraca poprawnie, ale natywny akcesor zwraca pustkę, brakuje widoku strony, nie mapowania

Czego logiczny indeks pola nie obiecuje

Zerowo indeksowany indeks pola to wygoda, nie tożsamość semantyczna, i wynikają z tego cztery ograniczenia. Jest na poziomie strony, nie dokumentu, więc indeks 0 na stronie 2 to inny widget niż indeks 0 na stronie 1, a porównywanie ich jest bez znaczenia. Jest pozycyjny, więc wstawienie lub usunięcie adnotacji unieważnia każdy zbuforowany indeks powyżej zmiany; traktuj przechowywany indeks jako ważny tylko tak długo, jak strona pozostaje wczytana i nieedytowana

Trzecie ograniczenie to to, które zaskakuje przy przeglądaniu listy pól. Indeks wylicza widgety, nie pola. Grupa przycisków radiowych to jedno pole z kilkoma widgetami-dziećmi, więc trzyprzyciskowa grupa wnosi trzy kolejne indeksy, które wszystkie zgłaszają tę samą Name. Rekord TPdfFormFieldInfo niesie GroupCount i GroupIndex dokładnie na ten przypadek, a interfejs listy, który je ignoruje, pokazuje to samo pole trzykrotnie. Czwarte ograniczenie dotyczy kolejności przechodzenia: kolejność tabulacji wystawiana tutaj to kolejność wyliczania widgetów, która podąża za tablicą /Annots, nie za wpisem strony /Tabs (ISO 32000-1 §7.7.3.3) ani drzewem pól AcroForm. Dla większości producentów te się zgadzają; dla formularza ułożonego w dwie kolumny przez generator, który zapisał najpierw prawą kolumnę, nie zgadzają się, a ścieżka klawiaturowa opisana w artykule o nawigacji po polach formularza będzie sprawiać wrażenie błędnej, mimo że każdy indeks jest poprawny. Gdy plik klienta zachowuje się dziwnie, zrzuć obie przestrzenie indeksów obok siebie, zanim zaczniesz teoretyzować: widok adnotacji i widok pola tej samej strony, wydrukowane razem, zwykle czynią przyczynę oczywistą na jeden rzut oka

procedure DumpIndexSpaces(Pdf: TPdf);
var
  I: Integer;
  Info: TPdfFormFieldInfo;
begin
  for I := 0 to Pdf.AnnotationCount - 1 do
    Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));

  for I := 0 to Pdf.FormFieldCount - 1 do
  begin
    Info := Pdf.FormFieldInfo[I];
    Writeln('field ', I, ': ', string(Info.Name),
      ' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
  end;
end;

Liczba adnotacji znacznie przewyższająca liczbę pól oznacza, że strona miesza podtypy, co jest normalne w recenzowanych dokumentach i dokładnie sytuacją, dla której istnieje to mapowanie; artykuł o przepływie pracy przeglądu adnotacji patrzy na tę samą stronę od strony oznaczeń. Równe liczby na każdym pliku testowym, z drugiej strony, oznaczają, że twoje fixture'y w ogóle nie potrafią wykryć tej klasy błędu, a uczciwą odpowiedzią jest dodanie fixture'u formularza niosącego łącze i karteczkę samoprzylepną

Opisane tutaj API wyliczania pól, fokusu i adnotacji są dostarczane z PDFium Component dla Delphi, C++Buildera i Lazarusa, którego strona produktu niesie pełne odniesienie pól formularza, w tym rekord informacji o polu i akcesory fokusu