Technický článek

Widget Index vs Annotation Index v PDFium formulářích

V PDFium Component, komponentě VCL/LCL nad PDFium pro Delphi, C++Builder a Lazarus, není index pole formuláře indexem anotace. Stránka nese anotace Link, Text a Ink vedle svých widgetů, takže výčet polí musí filtrovat podle FPDFAnnot_GetSubtype a nabízet logický index od nuly, který se zpět na skutečnou pozici anotace mapuje až přímo u nativního volání

Chyba, která tohle odhaluje, je jasná na první pohled. Tester stiskne Tab ve vyplněném fakturačním formuláři a kurzor zmizí, protože fokus přešel na hypertextový odkaz v patičce. Nebo hůř: nestane se vůbec nic, váš kód zaznamená pole 3 jako zaostřené, panel UI se aktualizuje, a FORM_SetFocusedAnnot celou dobu potichu vracelo false. Oba příznaky pramení ze stejné konstrukční chyby a jeden z nich má pod sebou ještě druhou skrytou příčinu

Dva prostory indexů, které PDFium poskytuje

PDFium vystavuje nad stejnou stránkou dvě číslovací schémata a ta se shodují jen na dokumentech, které náhodou neobsahují nic jiného než widgety formuláře. První je index anotace: pozice v poli stránky /Annots, kterou počítá FPDFPage_GetAnnotCount a kterou bere FPDFPage_GetAnnot (ISO 32000-1 §12.5.2). Druhý je logický index pole, který by mělo nabízet API na úrovni aplikace, a ten běží od nuly přes interaktivní pole, ke kterým se uživatel skutečně může dostat. ISO 32000-1 §12.5.6.19 definuje widget anotace jako vizuální reprezentaci interaktivních polí formuláře a §12.7 definuje samotný formulář. Všechno ostatní na stránce je jiný podtyp s jinou sémantikou: anotace Link má cíl, anotace Ink má seznam tahů, anotace Text je poznámková samolepka. Nic z toho nepatří do počtu polí a nic z toho nemůže přijmout fokus formuláře. Přesto v poli /Annots sedí promíchané s widgety v pořadí, v jakém je zapsala produkující aplikace, což často není pořadí, které by naznačovalo cokoli jiného na dokumentu

Proč Tab skončí na hypertextovém odkazu místo dalšího pole?

Protože počet polí byl ve skutečnosti počet anotací. Původní implementace vracela FPDFPage_GetAnnotCount přímo z FormFieldCount, zatímco přístupový objekt informací o poli, pomůcka pro pořadí tabulátoru i pomůcka pro fokus zacházely se stejným celým číslem jako s pozicí widgetu. Na čisté stránce AcroForm se šesti widgety a ničím jiným se šest rovná šesti a každý test projde. Přidejte hypertextový odkaz v patičce a recenzní poznámku na okraji a počet nahlásí osm polí, indexy 6 a 7 se rozřeší na objekty, které nejsou formulářem, a Tab jde přímo do nich

Oprava na straně výčtu spočívá v tom, počítat podtypy, ne anotace. Otevřete každou anotaci, zeptejte se na její podtyp, ponechte widgety a zavřete handle v bloku finally, protože FPDFPage_GetAnnot vrací vlastněný handle, který se musí vrátit zpátky přes 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;

Všimněte si, co tohle záměrně nedělá. Neptá se ničeho prostředí form-fill a nepotřebuje handle formuláře, protože podtyp žije ve slovníku anotace a je čitelný ze samotné stránky. To má význam pro pořadí: počet je dostupný dřív, než jste se rozhodli, jestli si dokument vůbec zaslouží prostředí form-fill, což článek o JavaScriptu AcroForm a hostitelských událostech pokrývá jako bezpečnostní rozhodnutí, ne jako pohodlnost

Mapování logického indexu zpět na nativní hranici

Pravidlo, které brání oběma prostorům, aby do sebe prosakovaly, je jednoduché: logický index je jediné číslo, které přechází přes vaše veřejné API, a na index anotace se převádí až v poslední funkci před nativním voláním. Jediná mapovací pomůcka, kterou používá informace o poli, fokus, nastavovače příznaků i pořadí tabulátoru stejně, je to, co dělá toto pravidlo vynutitelným

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;

Dvě vlastnosti této pomůcky stojí za to říct otevřeně. Je to lineární průchod, takže naivní smyčka přes každé pole stojí kvadratický počet otevření anotací na stránce se stovkami widgetů; pokud vypisujete celou stránku, projděte anotace jednou a sbírejte handly widgetů za pochodu, místo abyste volali mapovací funkci pro každé pole. A vrací -1 místo toho, aby vyvolala výjimku, což nechává na volajícím rozhodnout, jestli je zastaralý index programátorská chyba hodná výjimky, nebo souběh, který lze ignorovat, například poté, co úprava odstranila anotaci, na kterou se stále odkazuje uložený seznam UI z cache

Proč FORM_SetFocusedAnnot selže na headless stránce?

Protože PDFium odmítá zaostřit widget, jehož zobrazení stránky nebylo nikdy označeno jako platné. FORM_SetFocusedAnnot převádí anotaci na zobrazení stránky uvnitř prostředí form-fill, a pokud toto zobrazení stránky neexistuje, vrátí false bez jakékoli diagnostiky. Samotná oprava mapování indexu proto vyřeší, že Tab přistane na hypertextovém odkazu, ale ponechá druhý příznak nedotčený: váš záznam logického fokusu říká pole 3, nativně zaostřený widget je pořád nic, a každý přístupový prvek postavený na nativním fokusu, zaostřený text, zaostřená hodnota, stav výběru z voleb, dál vrací prázdno. Zobrazení stránky vytváří FORM_OnAfterLoadPage a ruší FORM_OnBeforeClosePage. Ve vieweru postaveném kolem vizuálního ovládacího prvku se tato volání dějí jako součást zobrazování stránky, a proto tato chyba tak často vypadá jako chyba pouze v headless režimu: stejný kód, který funguje v GUI demu, selhává v dávkovém nástroji. Životní cyklus patří dokumentovému objektu, ne vieweru, takže PDFium Component teď vydává obě volání pokaždé, když se stránka načte nebo uvolní s přítomným handlem formuláře. C signatura bere jako první parametr stránku a jako druhý handle formuláře, což se při ručním psaní bindingu snadno prohodí

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;

Kontrola, která dokazuje opravu, je ta, co porovná obě strany. Zavolejte FocusFormField s logickým indexem a poté přečtěte hodnotu přes přístupový prvek, který jde přes nativně zaostřený widget, a ne přes váš vlastní záznam, jako FocusedFormFieldValue nebo FocusedFormOptionSelected. Pokud logický index projde tam a zpátky správně, ale nativní přístupový prvek se vrátí prázdný, chybí zobrazení stránky, ne mapování

Co logický index pole neslibuje

Index pole od nuly je pohodlí, ne sémantická identita, a z toho plynou čtyři omezení. Je vztažený ke stránce, ne k dokumentu, takže index 0 na stránce 2 je jiný widget než index 0 na stránce 1 a jejich porovnávání nemá smysl. Je poziční, takže vložení nebo smazání anotace zneplatní každý uložený index nad místem změny; s uloženým indexem zacházejte jako s platným jen tak dlouho, dokud stránka zůstává načtená a needitovaná

Třetí omezení je to, co překvapí lidi při kontrole seznamu polí. Index vyčísluje widgety, ne pole. Skupina přepínačů je jedno pole s několika potomky widgetů, takže tříprvková skupina přispívá třemi po sobě jdoucími indexy, které všechny hlásí stejné Name. Záznam TPdfFormFieldInfo nese GroupCount a GroupIndex přesně pro tento případ, a UI seznamu, které je ignoruje, zobrazí stejné pole třikrát. Čtvrté omezení se týká pořadí procházení: pořadí tabulátoru vystavené zde je pořadí výčtu widgetů, které následuje pole /Annots, ne položku stránky /Tabs (ISO 32000-1 §7.7.3.3) a ne strom polí AcroForm. U většiny producentů se tyto shodují; u formuláře rozvrženého do dvou sloupců generátorem, který nejdřív vypsal pravý sloupec, se neshodují, a cesta klávesnicí popsaná v článku o navigaci polí formuláře bude působit špatně, přestože každý index je správný. Když se zákaznický soubor chová divně, vypište oba prostory indexů vedle sebe, dřív než začnete teoretizovat: pohled na anotace a pohled na pole u stejné stránky, vytištěné společně, obvykle udělají příčinu zjevnou na jeden pohled

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;

Počet anotací výrazně vyšší než počet polí znamená, že stránka míchá podtypy, což je u kontrolovaných dokumentů normální a přesně pro tuto situaci mapování existuje; článek o workflow revize anotací se dívá na stejnou stránku ze strany poznámek. Stejné počty na každém testovacím souboru naopak znamenají, že vaše fixtures tuto třídu chyby vůbec nedokážou odhalit, a poctivá reakce je přidat fixture formuláře, který nese odkaz i poznámkovou samolepku

API pro výčet polí, fokus a anotace popsané zde je součástí PDFium Component pro Delphi, C++Builder a Lazarus, jehož produktová stránka nese kompletní referenci polí formuláře včetně záznamu informací o poli a přístupových prvků pro fokus