Technický článek

Chyba flatten checkboxů PDF: hodnota pole vs widget

Zaškrtávací pole a přepínače se ukládají do flatten jako nezaškrtnuté, protože stav vzhledu /AS nikdy nebyl synchronizován s hodnotou pole /V. PDFium Component, komponenta VCL a LCL postavená na PDFium pro Delphi, C++Builder a Lazarus, nyní čte tuto hodnotu pomocí FPDFAnnot_GetFormFieldValue, která rozřeší slovník rodičovského pole místo widget anotace

Hlášení chyby, které sem vedlo, je typ, kterému zpočátku nevěříte. Zákazník provede flatten podepsaného souhlasového formuláře, otevře výsledek a každé zaškrtávací pole je prázdné. Otevřete zdrojový soubor v Acrobatu a políčka jsou viditelně zaškrtnutá. Přečtěte zdrojový soubor zpátky stejnou komponentou a hodnoty polí jsou správné. Jen výstup po flatten je ztrácí, a jen u zaškrtávacích polí a přepínačů: textová pole na stejné stránce vyjdou v pořádku

Proč jsou zaškrtávací pole po flatten nezaškrtnutá?

Protože flatten se nikdy nedívá na /V. FPDFPage_Flatten zapeče stream vzhledu widgetu do obsahu stránky a vzhled, který vybere, je ten pojmenovaný v /AS. Pokud /AS stále říká /Off, zatímco hodnota pole říká, že políčko je zapnuté, flatten věrně zapeče vypnutý vzhled. Hodnota se nikdy neztratila; nikdy nebyla konzultována

ISO 32000-1 §12.5.5 definuje slovník vzhledu /AP se třemi možnými položkami, /N, /R a /D. U zaškrtávacího pole nebo přepínače není položka /N stream, ale podslovník, jehož klíče jsou názvy stavů vzhledu, a §12.5.2 dělá z /AS povinný selektor, když je /N podslovníkem. Zaškrtávací pole tedy nese dva předem vytvořené vzhledy a jeden ukazatel. Zvolíte-li špatný ukazatel, vykreslení je špatné způsobem, který žádné množství správného /V neopraví. To je také důvod, proč se režim selhání liší od textových polí, která nemají vůbec žádný předem vytvořený vzhled k výběru: /N textového pole je jediný stream, který musí být po změně hodnoty vygenerován znovu od nuly, takže GenerateFormAppearances obsluhuje tyto dva případy přes úplně oddělené cesty kódu a rozbitá byla jen cesta pro tlačítka

Kde vlastně žije hodnota zaškrtávacího pole?

Na slovníku pole, nikoli na widgetu. ISO 32000-1 §12.7.5.2 popisuje zaškrtávací pole a přepínače jako tlačítková pole, jejichž /V je objekt jména pojmenovávající aktuální stav vzhledu, a §12.7.3.1 umísťuje /V mezi položky společné všem slovníkům polí. Widget anotace definovaná v §12.5.6.19 přispívá /AS a /AP. Nic ve specifikaci nenutí widget nést /V

// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off

{ What the two objects look like when the field has several widgets:

  12 0 obj                          % field dictionary (the parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (a kid)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

FPDFAnnot_GetStringValue není vadná. Její kontrakt je přesně to, co říká její jméno: vytáhnout řetězcovou položku ze slovníku anotace, který jste jí předali. Ptát se jí na /V na objektu 13 nevrátí nic, protože objekt 13 opravdu žádné /V nemá. Vada byla u volajícího, který předpokládal plochý objektový model, který ISO 32000-1 nikdy neslíbil

Kdy sdílejí pole a widget jeden slovník?

Vždy, když má pole přesně jeden widget. §12.5.6.19 povoluje sloučit slovník pole a jeho jedinou widget anotaci do jednoho objektu a většina autorských nástrojů si tuto zkratku bere. Ve sloučeném objektu sedí /FT, /T, /V, /AS a /AP vedle sebe, takže čtení /V na úrovni widgetu uspěje a celá chyba zůstává neviditelná

V okamžiku, kdy pole vlastní dva nebo více widgetů, je sloučení nemožné a §12.7.3.1 vyžaduje, aby se widgety staly /Kids samostatného slovníku pole. Každá skupina přepínačů má tento tvar konstrukčně. Stejně tak souhlasová zaškrtávací pole opakovaná v hlavičce a patičce a jakékoli pole, které autorský nástroj zkopíroval na druhou stránku. To je celé vysvětlení, proč tato vada přežila regresní sadu: testovací korpus byl plný formulářů s jedním widgetem, zatímco zákaznické soubory nikoli. Pokud procházíte widgety sami místo spoléhání na komponentu, stejná asymetrie se projeví v pořadí výčtu, a poznámky o navigaci polí formuláře PDF s PDFium Component popisují, jak souvisí průchod anotacemi na úrovni stránky se stromem polí na úrovni dokumentu

Čtení hodnoty způsobem, jaký zamýšlí PDFium

FPDFAnnot_GetFormFieldValue je správné API a v komponentě bylo navázané už nějakou dobu, aniž by jej cesta pro zaškrtávací pole používala. Přebírá handle formuláře stejně jako anotaci, a to je signál, na kterém záleží: s dostupným form-fill prostředím PDFium rozřeší anotaci na její formulářový ovládací prvek a přečte hodnotu z objektu pole, takže vrátí správnou odpověď jak u sloučeného, tak u rozděleného rozvržení

FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP is prebuilt per state; only /AS has to be synchronised with /V.
    // FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
    // which is where ISO 32000-1 12.7.5.2 keeps the value.
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

V tomto útržku se dají snadno splést dva detaily. Vrácená délka je počet bajtů pro text UTF-16 včetně terminátoru, takže počet znaků je buflen div 2 - 1 a hodnota 2 znamená prázdný řetězec. Pojistka buflen >= 4 tedy znamená alespoň jeden skutečný znak, což chrání pole bez jakéhokoli /V před tím, aby mělo své /AS přepsáno prázdným jménem

Na čem se /AS a /AP /N skutečně shodují

Shodují se na jménu, a to jméno volí ten, kdo soubor vyprodukoval. §12.7.5.2 vyžaduje, aby se vypnutý stav jmenoval /Off, a zapnutý stav ponechává zcela na producentovi. /Yes je konvence, nikoli pravidlo. Acrobat zapisuje /Yes, ale spousta generátorů zapisuje /On, /1, /Choice1 nebo lokalizované slovo, a skupina přepínačů obvykle dává každému potomkovi odlišné jméno zapnutého stavu, aby skupina mohla vyjádřit, který přepínač je vybraný. To je přesně důvod, proč je zkopírování /V doslovně do /AS správnou operací, a ne hackem: u zaškrtnutého prvku PDFium hlásí jméno zapnutého stavu, které sám soubor definuje, a u nezaškrtnutého hlásí Off, takže hodnota, kterou zapíšete do /AS, je garantovaně klíč, který existuje v podslovníku /AP /N daného widgetu. Natvrdo zapsané /Yes by fungovalo na výstupu Acrobatu a potichu by se rozbilo všude jinde

Pořadí operací a kde je stále potřeba opatrnost

Sekvence je pevná a nekompromisní: zapnout form fill, přiřadit hodnoty, znovu vygenerovat vzhledy, flatten, pak uložit. Vynechejte krok regenerace a FPDFPage_Flatten najde prázdné nebo zastaralé streamy vzhledu a bez námitek je zapeče, což je tichá ztráta dat namísto vrácení chyby

Pdf.FileName := FormPath;
Pdf.FormFill := True;          // required: FormHandle must exist
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // writes /V only

Pdf.GenerateFormAppearances;   // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

Zůstávají dva poctivé limity. Za prvé, synchronizace zapisuje hodnotu pole do /AS každého widgetu tohoto pole, což je správné pro zaškrtávací pole, ale přibližné pro skupiny přepínačů, jejichž potomci si každý definuje vlastní jméno zapnutého stavu; potomek, jehož /AP /N nemá žádnou položku odpovídající zapsanému /AS, nemá podle §12.5.5 žádný vzhled k výběru, takže nevybrané tlačítko se může do flatten uložit jako nic místo prázdného kroužku. Audit skupiny přepínačů pomocí FPDFAnnot_GetFormControlIndex před flatten stojí za těch pár řádků. Za druhé, nic z toho neplatí pro XFA, kde hodnota žije v balíčku dat XML místo ve slovnících AcroForm, což je rozlišení popsané v poznámkách o úpravách polí XFA, které se neukládají. Obecné poučení stojí za zapamatování i za touto jedinou opravou: kdykoli API kromě anotace přebírá i handle formuláře, říká vám tím, že za vás rozřeší hierarchii pole, a kdykoli přebírá jen anotaci, přečte přesně ten objekt, který jste předali. Toto rozlišení také řídí výměnu dat, protože export a import dat formuláře XFDF pracuje s plně kvalifikovanými jmény polí, nikdy s pozicemi widgetů

Flatten formuláře je jedna z těch funkcí, která vypadá jako jediné volání API a nakonec se ukáže být kontraktem mezi třemi slovníky. Pokud byste raději pracovali proti komponentě, která už tento kontrakt zakódovala, PDFium Component pro Delphi a C++Builder dodává zde popsanou regeneraci vzhledů, flatten a přístup k polím formuláře jako obyčejné vlastnosti a metody