Artykuł techniczny

HotPDF headless: widgety bez wyglądu i dziedziczenie pól

Formularz PDF z wartościami pól, ale bez strumieni wyglądu, może wyjść z zadania renderowania na Linuksie jako puste pola, zła czcionka albo wyrównanie, lub z EHPDFHeadlessRenderError o treści "Widget field requires a name field type". Renderer headless HotPDF rysuje tymczasowe wyglądy dla widgetów tekstowych, przyciskowych i wyboru, dziedzicząc /FT, /V, /DA i /Q przez drzewo pól

Gdzie widget trzyma ustawienia swojego pola

Większość kłopotów zaczyna się od rozdziału między polem a jego widgetami. Słownik pola trzyma logiczną część kontrolki formularza: jej typ, wartość, flagi i domyślny wygląd. Adnotacja widget trzyma to, czego potrzebuje strona: /Rect, obramowanie, wskaźnik na swoją stronę. Pole z jednym widgetem bywa zwykle scalone w jeden słownik. Gdy ta sama wartość ma pokazać się na kilku stronach, widgety stają się dziećmi pola i często niosą same geometrię i odwołanie /Parent. ISO 32000-1 §12.7.3.1 czyni /FT, /Ff, /V i /DV dziedzicznymi wzdłuż tego łańcucha (tabela 220), a §12.7.3.3 dorzuca do zbioru dziedzicznych z tabeli 222 tylko dwa atrybuty tekstu zmiennego: /DA, który jest wymagany, ale może przyjść od przodka, i /Q. Słownik formularza interaktywnego (tabela 218) dostarcza domyślnych wartości /DA i /Q dla całego dokumentu, plus /DR — domyślny słownik zasobów, w którym szuka się nazw czcionek z /DA. /DR nie ma w tabeli 222; należy do formularza jako całości. Renderer czytający tylko słownik widgetu nie znajdzie w pliku poniżej ani typu, ani wartości, ani /DA i spadnie na własną domyślną czcionkę

1 0 obj  % katalog
<< /Type /Catalog /Pages 2 0 R
   /AcroForm << /Fields [10 0 R] /DA (/Helv 0 Tf 0 g) /Q 0
                /DR << /Font << /Helv 20 0 R /Cour 21 0 R >> >> >> >>
endobj
10 0 obj  % pole końcowe: typ, wartość i domyślne wyglądy siedzą tutaj
<< /FT /Tx /T (invoice_no) /V (INV-2026-0417)
   /DA (/Cour 10 Tf 0 0 0.6 rg) /Q 2
   /Kids [11 0 R 12 0 R] >>
endobj
11 0 obj  % widget na stronie 1: tylko geometria, rogi podane od górnego prawego rogu
<< /Type /Annot /Subtype /Widget /Parent 10 0 R
   /Rect [300 720 160 700] /F 4 /P 3 0 R >>
endobj
12 0 obj  % widget na stronie 2: tylko geometria
<< /Type /Annot /Subtype /Widget /Parent 10 0 R
   /Rect [160 60 300 80] /F 4 /P 4 0 R >>
endobj

HotPDF szuka każdego wpisu pola na widgetcie, a gdy go tam nie ma, wspina się po /Parent po jednym słowniku; bliższa wartość wygrywa, a tylko /DA i /Q spadają na słownik AcroForm, gdy łańcuch się kończy. Oczekiwane dla pliku powyżej: oba widgety pokazują INV-2026-0417 wyrównane do prawej, Courierem 10-punktowym, ciemnoniebieskim, a ogólnodokumentowe /Helv 0 Tf nigdy nie wchodzi do gry, bo obiekt 10 ma własne /DA. Gdy pole wychodzi zamiast tego jako czarna Helvetica 12-punktowa, oczywistym podejrzanym jest czcionka Courier nieobecna na linuksowej maszynie. Rozmiar zdradza sprawę. To rysuje HotPDF, gdy żadne /DA nie wyskoczy ani na widgetcie, ani na żadnym przodku, ani w słowniku AcroForm. Zrzuć więc widget, przejdź ręcznie po jego /Parent i znajdź, gdzie producent położył /DA — na przykład na słowniku pola, do którego widget nigdy nie linkuje z powrotem

Graf obiektów AcroForm dla renderowania headless HotPDF w Delphi: widgety z samą geometrią wspinają się po /Parent do pola trzymającego /FT, /V, /DA i /Q
Bliższa wartość wzdłuż łańcucha /Parent wygrywa, a tylko /DA i /Q spadają na słownik AcroForm, gdy łańcuch się kończy

Nazwy czcionek z /DA rozwiązują się względem /DR na widgetcie albo polu nadrzędnym, /DR AcroForm i zasobów czcionek strony — w tej kolejności. /DR na poziomie pola jest poza tabelą 222, ale gdy jest, nie zostawia wątpliwości, więc HotPDF go przyjmuje. Wspinaczka ma granice: pętla w drzewie zatrzymuje się z "Widget field inheritance contains a cycle", /Parent niebędący słownikiem z "Widget Parent requires a dictionary", a łańcuch głębszy niż MaxDepth w opcjach renderowania (domyślnie 32) z "Widget field inheritance depth budget exceeded". Komponent VCL stosuje to samo dziedziczenie, gdy czyta dziedziczone wartości AcroForm i resetuje pola

Jak renderer headless rozstrzyga, co rysować?

HPDFRenderHeadlessPagePNG i jego wariant plikowy uruchamiają fallback tylko dla widocznego widgetu, którego /AP brakuje albo jest null; ISO 32000-1 §7.3.7 mówi, że wartość null w słowniku to samo co wpis nieobecny, i renderer traktuje /AP null właśnie tak. Zwykły wygląd, jeśli jest, zawsze wygrywa — nieważne, co mówi /NeedAppearances — więc nieaktualny wygląd z wcześniejszego wypełnienia dalej się pokazuje, dopóki coś go nie wygeneruje na nowo. Wczytany dokument nie jest nigdy modyfikowany. Co zostanie narysowane, zależy od rozwiązanych typu pola i flag:

PoleSyntezowany wygląd
Tekst jednoliniowy, /FT /TxCiąg /V, ułożony z rozwiązanymi /DA i /Q wewnątrz /Rect
Checkbox, /FT /BtnProstokątna ramka z promieniami narożników z /Border, wypełnieniem z /MK /BG i obrysowanym wektorowym ptaszkiem, gdy włączony
Przycisk radio, Btn z bitem 16Okrągła ramka z wypełnioną kropką, gdy włączony
Pushbutton, Btn z bitem 17Prostokątna ramka z napisem /MK /CA wyśrodkowanym
Pole combo, /FT /Ch z bitem 18Tekst wyświetlany wybranego wpisu /Opt jako wyśrodkowany napis
Lista, Ch z wyzerowanym bitem 18Jeden wiersz na opcję, począwszy od /TI, z pasem podświetlenia za każdym zaznaczonym wierszem

Ptaszek wyboru i kropka radia to ścieżki wektorowe, a nie glify ZapfDingbats, jak rysują je liczne generatory, bo chudy kontener Linuksa może nie mieć zainstalowanego ZapfDingbats. Wyglądy pushbuttonów i pól combo zgadzają się z tym, co generator wyglądów VCL HotPDF rysuje dla tego samego pola, więc formularz wygląda podobnie, niezależnie od strony, która wyprodukowała obraz. Napis bierze czcionkę, rozmiar i kolor z /DA; skoro układ napisu nie dopasowuje się sam, rozmiar /DA równy 0 staje się 70 procentami wysokości pola, z limitem 12 punktów. Stan checkboxów i radio pochodzi z własnego /AS widgetu, z fallbackiem na dziedziczone /V, i każdy stan inny niż Off rysuje znacznik. Daj każdemu dziecku radio własne /AS. /V rodzica nazywa zaznaczony stan dla całej grupy i nie odróżni jednego dziecka od drugiego

Przepływ decyzyjny renderera headless HotPDF dla Delphi: obecne /AP zawsze wygrywa, w przeciwnym razie rozwiązany typ pola wybiera syntezowany wygląd
Tymczasowe wyglądy są rysowane tylko, gdy /AP brakuje albo jest null, a wczytany dokument sam w sobie nie jest nigdy modyfikowany
uses
  SysUtils, HPDFHeadlessDocument, HPDFHeadlessCairoDevice;

function RenderFormPage(const FileName, PngName: string; PageIndex: Integer;
  out Diagnostic: string): Boolean;
var
  Doc: THPDFHeadlessDocument;
  Options: THPDFHeadlessRenderOptions;
  Info: THPDFHeadlessRenderResult;
begin
  Result := False;
  Diagnostic := '';
  Doc := THPDFHeadlessDocument.Create;
  try
    Doc.LoadFromFile(FileName);
    Options := THPDFHeadlessRenderOptions.Default;  // RenderAnnotations = True, MaxDepth = 32
    Options.Usage := hruPrint;  // jak wydruk z przeglądarki: widgety bez flagi Print są pomijane
    try
      // PageIndex liczy od zera
      Info := HPDFRenderHeadlessPagePNGFile(Doc.GetRenderAccess(PageIndex),
        PngName, Options);
      Diagnostic := Format('%d appearances drawn', [Info.Appearances]);
      Result := True;
    except
      on E: EHPDFHeadlessRenderError do
        // np. 'Widget field requires a name field type'; przy wypełnionym formularzu nie
        // ponawiaj z RenderAnnotations := False, co wyrzuca każdy widget i jego wartość
        Diagnostic := E.Message;
    end;
  finally
    Doc.Free;
  end;
end;

Fallback pola tekstowego rysuje pola jednoliniowe trzymające wartość ciągu. Dla bogatszego układu tekstu generuj wygląd w czasie wypełniania po stronie Windows, gdzie EnsureLoadedFieldAppearanceStream buduje brakujący strumień /AP, zanim plik zostanie spłaszczony albo wysłany; zadanie na Linuksie dostaje wtedy prawdziwe strumienie wyglądu, a renderer headless wymienia z nazwy każde pole, jakie spotka, a które nadal jakiegoś potrzebuje. Wynik z Linuksa osądzaj jednak rendererem Linuksa. RenderLoadedPageToBitmap rysuje przez backend GDI Windows, który ten fallback nigdy nie uruchamia, więc strona wyglądająca dobrze tam nie mówi ci nic o tym, co urządzenie Cairo wyprodukuje z tego samego pliku

Bity flag pól są numerowane od jedynki

ISO 32000-1 §12.7.3.1 stwierdza, że pozycje bitów w słowie /Ff biegną od 1 na końcu najmłodszym do 32, i tabele 226, 228 oraz 230 używają tej numeracji. W Delphi bit N to zatem 1 shl (N - 1). Przesuń o sam numer bitu, a każdy test przenosi się o pozycję w górę: 1 shl 16 zapisane dla "radio, bit 16" faktycznie testuje flagę pushbuttona, a 1 shl 18 zapisane dla "combo, bit 18" testuje Edit. Obie złe maski to prawdziwe flagi. Kod się kompiluje, działa i sortuje pola do złego gatunku

Flagi pól PDF numerują bity od jedynki w Delphi: linijka pokazuje, czemu radio to 1 shl 15, a 1 shl 16 sięga pushbuttona
Bit N słowa /Ff bierze maskę Delphi 1 shl N minus jeden, a każda maska przesunięta o jeden to prawdziwa flaga sąsiedniego bitu
FlagaTabelaBitMaska Delphi
Multiline228 (tekst)131 shl 12 = 4096
Password228 (tekst)141 shl 13 = 8192
Comb228 (tekst)251 shl 24 = 16777216
RichText228 (tekst)261 shl 25 = 33554432
Radio226 (przycisk)161 shl 15 = 32768
Pushbutton226 (przycisk)171 shl 16 = 65536
Combo230 (wybór)181 shl 17 = 131072
uses
  Classes, SysUtils, HPDFHeadlessDocument;

const
  // ISO 32000-1 numeruje bity Ff od 1 (najmłodszy), więc bit N to 1 shl (N - 1)
  TxMultiline   = 1 shl 12;  // bit 13
  TxPassword    = 1 shl 13;  // bit 14
  TxFileSelect  = 1 shl 20;  // bit 21
  TxDoNotScroll = 1 shl 23;  // bit 24
  TxComb        = 1 shl 24;  // bit 25
  TxRichText    = 1 shl 25;  // bit 26
  TxProducerLayout = TxMultiline or TxPassword or TxFileSelect or
    TxDoNotScroll or TxComb or TxRichText;

procedure ListTextFieldsForProducerAppearance(const FileName: string;
  Report: TStrings);
var
  Doc: THPDFHeadlessDocument;
  Field: THPDFHeadlessFormField;
  I: Integer;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Doc.LoadFromFile(FileName);
    for I := 0 to Doc.FormFieldCount - 1 do
    begin
      // FieldType, Flags i MaxLength przychodzą już odziedziczone od przodków
      Field := Doc.GetFormField(I);
      if Field.FieldType <> 'Tx' then
        Continue;
      if (Field.Flags and TxProducerLayout) <> 0 then
        Report.Add(Format('%s: flags $%.8x, needs a producer /AP',
          [Field.Name, Field.Flags]))
      else if Field.MaxLength > 0 then
        Report.Add(Format('%s: /MaxLen %d, needs a producer /AP',
          [Field.Name, Field.MaxLength]))
      else if Field.Value = '' then
        Report.Add(Format('%s: blank, fill it or give it an /AP', [Field.Name]));
    end;
  finally
    Doc.Free;
  end;
end;

Skan czyta flagi na poziomie pola — tam, gdzie kładą je producenci; renderer honoruje też /Ff na osobnym widgetcie, więc traktuj listę jako pomoc planistyczną, a komunikat renderowania jako słowo ostateczne. Kontrola pustych ma znaczenie, bo pole tekstowe bez /V i bez /AP nie ma czego syntezować: render zatrzymuje się z "A text-field widget without a value has no appearance to synthesize", a lekarstwem jest wartość albo wygląd, zanim plik dotrze do renderera

Co znaczy "Degenerate appearance geometry"?

Znaczy to, że prostokąt albo pudło wyglądu nie ma powierzchni, a odwrócony /Rect nie jest przyczyną. ISO 32000-1 §7.9.5 pozwala prostokątowi wymieniać dowolne dwa przeciwległe narożniki po przekątnej, a jego nota każe oprogramowaniu przetwarzającemu być gotowym na ich normalizację. Naiwny renderer liczący szerokość jako x2 - x1 dostaje z [300 720 160 700] liczbę ujemną i albo odrzuca adnotację, albo lustrowo odbija jej wygląd. HotPDF zamienia współrzędne na porządek lewy-dolny i prawy-górny przed jakąkolwiek kontrolą rozmiaru — dla syntezowanych wyglądów i istniejących strumieni /AP równo — więc widget 11 powyżej renderuje się, jakby jego rogi były konwencjonalne. Błąd jest wyrzucany tylko dla adnotacji mającej strumień wyglądu, gdy po normalizacji /Rect ma zerową szerokość albo wysokość, albo gdy /BBox strumienia zapada się pod jego /Matrix do linii albo punktu. Gdy się pojawi, zrzuć /Rect widgetu razem z /BBox i /Matrix strumienia: jedno z tych trzech ma zerowy wymiar

Widget bez /AP i z /Rect o zerowej powierzchni idzie inną drogą: fallback go pomija i jedzie dalej ze stroną. ISO 32000-2 §12.5.2 zwalnia z wymogu wyglądu tylko adnotacje Popup, Projection i Link oraz prostokąt zapadający się do pojedynczego punktu. Prostokąt zaledwie liniowy nadal wymaga wyglądu według PDF 2.0; renderer pomija jednak i ten — z prostego powodu: nie ma powierzchni do zamalowania

Gdy typu pola nie da się znaleźć

"Widget field requires a name field type" znaczy, że renderer wspiął się od widgetu tak daleko, jak sięgnęły odwołania /Parent, i nie znalazł żadnego /FT. Renderer startuje od tablicy /Annots strony, więc odwołanie w górę /Parent to jego jedyna droga do pola. Wczytywanie idzie drugą stroną, w dół od tablicy /Fields AcroForm przez /Kids, dlatego LoadFromFile może się udać na pliku, którego strona później pada przy renderowaniu. Typowa przyczyna to widget wymieniony w /Kids pola, który nie wskazuje z powrotem. ISO 32000-1, tabela 188, daje na to pewne przykrycie: wymaga /Parent na widgetcie tylko, gdy widget jest jednym z kilku dzieci, i mówi "absent otherwise", więc writer czytający ją dosłownie wyrzuca odwołanie dla pola z jednym osobnym widgetem. ISO 32000-2 rozluźniło ten sam wpis do "optional otherwise". My i tak pisalibyśmy /Parent na każdym widgetcie niescalonym ze swoim polem. ISO 32000-2 na to pozwala, i to jest jedyna droga, jaką czytnik prowadzony od strony — nasz czy cudzy — może dosięgnąć pola. Jeśli nie możesz naprawić producenta, scalenie samotnego widgetu ze słownikiem jego pola daje ten sam rezultat

Widget, który linkuje do rodzica i znajdzie /DA gdziekolwiek po drodze w górę, renderuje się poprawnie bez żadnego /AP w rendererze headless komponentu HotPDF dla Delphi, co czyni drzewo pól — bardziej niż strumienie wyglądu — pierwszą rzeczą do ogarnięcia w linuksowym potoku formularzy