Articol tehnic

Index widget vs index adnotare în formulare PDFium Delphi

În PDFium Component, componenta VCL/LCL bazată pe PDFium pentru Delphi, C++Builder și Lazarus, un index de câmp de formular nu e un index de adnotare. O pagină poartă adnotări Link, Text și Ink alături de widget-urile ei, așa că enumerarea câmpurilor trebuie să filtreze pe FPDFAnnot_GetSubtype și să expună un index logic bazat pe zero, mapat înapoi la o poziție reală de adnotare doar la apelul nativ

Bug-ul care expune asta e de nerecunoscut greșit odată ce l-ai văzut. Un tester apasă Tab într-un formular de factură completat, iar caret-ul dispare, pentru că focus-ul a mers pe un hyperlink din footer. Sau mai rău, nu se întâmplă absolut nimic: codul tău înregistrează câmpul 3 ca focusat, panoul de UI se actualizează, iar FORM_SetFocusedAnnot a returnat tăcut false tot timpul. Ambele simptome vin din aceeași greșeală de design, iar unul dintre ele are o a doua cauză rădăcină ascunsă dedesubt

Cele două spații de index pe care ți le dă PDFium

PDFium expune două scheme de numerotare peste aceeași pagină, iar ele coincid doar pe documente care se întâmplă să nu conțină nimic altceva decât widget-uri de formular. Prima e indexul de adnotare: o poziție în array-ul /Annots al paginii, ceea ce numără FPDFPage_GetAnnotCount și ceea ce ia FPDFPage_GetAnnot (ISO 32000-1 §12.5.2). A doua e indexul logic de câmp pe care ar trebui să-l ofere un API la nivel de aplicație, rulând de la zero peste câmpurile interactive pe care un utilizator le poate atinge de fapt. ISO 32000-1 §12.5.6.19 definește adnotările widget ca reprezentarea vizuală a câmpurilor de formular interactive, iar §12.7 definește formularul însuși. Tot restul de pe pagină e un subtip diferit cu semantică diferită: o adnotare Link are o destinație, o adnotare Ink are o listă de trăsături, o adnotare Text e un bilețel lipicios. Niciuna dintre ele nu aparține unei numărători de câmpuri, și niciuna dintre ele nu poate accepta focus de formular. Totuși, în array-ul /Annots stau intercalate cu widget-urile, în orice ordine le-a scris aplicația producătoare, ceea ce adesea nu e ordinea sugerată de altceva din document

De ce ajunge Tab pe un hyperlink în loc de următorul câmp?

Pentru că numărătoarea de câmpuri era de fapt o numărătoare de adnotări. Implementarea originală returna FPDFPage_GetAnnotCount direct din FormFieldCount, în timp ce accesorul de informații de câmp, helper-ul de ordine de tab și helper-ul de focus tratau toate acel același întreg ca o poziție de widget. Pe o pagină AcroForm curată, cu șase widget-uri și nimic altceva, șase e egal cu șase și fiecare test trece. Adaugă un hyperlink în footer și un comentariu de reviewer pe margine, iar numărătoarea raportează opt câmpuri, indicii 6 și 7 se rezolvă la obiecte non-formular, iar Tab merge drept în ele

Fix-ul la capătul de enumerare e să numeri subtipuri, nu adnotări. Deschide fiecare adnotare, cere-i subtipul, păstrează widget-urile și închide handle-ul într-un bloc finally, pentru că FPDFPage_GetAnnot returnează un handle deținut care trebuie întors prin 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;

Observă ce anume nu face asta, deliberat. Nu întreabă mediul de form-fill nimic, și nu are nevoie de un handle de formular, pentru că subtipul trăiește în dicționarul de adnotare și e citibil doar din pagină. Asta contează pentru ordonare: numărătoarea e disponibilă înainte să fi decis dacă documentul merită deloc un mediu de form-fill, ceea ce articolul despre JavaScript AcroForm și evenimentele de gazdă tratează ca o decizie de securitate, nu una de conveniență

Maparea indexului logic înapoi la granița nativă

Regula care ține cele două spații să nu se scurgă unul în celălalt e simplă: indexul logic e singurul număr care traversează API-ul tău public, și e convertit la un index de adnotare în ultima funcție înainte de apelul nativ. Un singur helper de mapare, folosit deopotrivă de informațiile de câmp, focus, setterele de flag și ordinea de tab, e ceea ce face acea regulă aplicabilă

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;

Merită menționate clar două proprietăți ale acestui helper. E o scanare liniară, așa că o buclă naivă peste fiecare câmp costă un număr pătratic de deschideri de adnotare pe o pagină cu sute de widget-uri; dacă enumerezi întreaga pagină, parcurge adnotările o singură dată și colectează handle-urile de widget pe măsură ce mergi, în loc să apelezi mapper-ul per câmp. Și returnează -1, nu ridică o excepție, ceea ce lasă apelantul să decidă dacă un index învechit e o eroare de programare demnă de o excepție sau o cursă demnă de ignorat, de exemplu după ce o editare a eliminat o adnotare la care o listă UI cache-uită încă se referă

De ce eșuează FORM_SetFocusedAnnot pe o pagină headless?

Pentru că PDFium refuză să focuseze un widget a cărui page view n-a fost niciodată marcată validă. FORM_SetFocusedAnnot rezolvă adnotarea la o page view din interiorul mediului de form-fill, iar dacă acea page view nu există, returnează false fără niciun diagnostic. A corecta doar maparea de index repară deci Tab-ul care ajunge pe un hyperlink, dar lasă neatins al doilea simptom: înregistrarea ta logică de focus spune câmpul 3, widget-ul nativ focusat tot nu e nimic, iar fiecare accesor construit pe focus-ul nativ — text focusat, valoare focusată, stare de selecție choice — continuă să returneze gol. Page view-ul e creată de FORM_OnAfterLoadPage și distrusă de FORM_OnBeforeClosePage. Într-un viewer construit în jurul unui control vizual, acele apeluri se întâmplă ca parte a afișării unei pagini, motiv pentru care defecțiunea arată atât de des ca un bug doar-headless: același cod care funcționează în demo-ul GUI eșuează în unealta de lot. Ciclul de viață aparține obiectului document, nu viewer-ului, așa că PDFium Component emite acum ambele apeluri oricând o pagină e încărcată sau descărcată cu un handle de formular prezent. Semnătura C ia pagina prima și handle-ul de formular al doilea, ceea ce e ușor de inversat când scrii binding-ul de mână

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;

Verificarea care dovedește fix-ul e cea care compară cele două părți. Apelează FocusFormField cu un index logic, apoi citește o valoare printr-un accesor care trece prin widget-ul nativ focusat, nu prin propria ta înregistrare, precum FocusedFormFieldValue sau FocusedFormOptionSelected. Dacă indexul logic face round-trip, dar accesorul nativ se întoarce gol, page view-ul lipsește, nu maparea

Ce nu promite indexul logic de câmp

Un index de câmp bazat pe zero e o conveniență, nu o identitate semantică, iar din asta decurg patru limite. E per pagină, nu per document, așa că indexul 0 de pe pagina 2 e un widget diferit de indexul 0 de pe pagina 1, iar a le compara e lipsit de sens. E pozițional, așa că inserarea sau ștergerea unei adnotări invalidează fiecare index cache-uit de deasupra schimbării; tratează un index stocat ca valid doar atât timp cât pagina rămâne încărcată și needitată

A treia limită e cea care surprinde lumea care revizuiește o listă de câmpuri. Indexul enumerează widget-uri, nu câmpuri. Un grup radio e un singur câmp cu mai mulți copii widget, așa că un grup cu trei butoane contribuie cu trei indici consecutivi care raportează toți același Name. Înregistrarea TPdfFormFieldInfo poartă GroupCount și GroupIndex exact pentru acest caz, iar un UI de listă care le ignoră arată același câmp de trei ori. A patra limită privește ordinea de parcurgere: ordinea de tab expusă aici e ordinea de enumerare a widget-urilor, care urmează array-ul /Annots, nu intrarea /Tabs a paginii (ISO 32000-1 §7.7.3.3) și nici arborele de câmpuri AcroForm. Pentru majoritatea producătorilor, acestea sunt de acord; pentru un formular aranjat pe două coloane de un generator care a emis mai întâi coloana dreaptă, nu sunt, iar traseul de tastatură descris în articolul de navigare a câmpurilor de formular se va simți greșit, chiar dacă fiecare index e corect. Când un fișier de client se comportă ciudat, scoate ambele spații de index unul lângă altul înainte să teoretizezi: vizualizarea de adnotare și vizualizarea de câmp ale aceleiași pagini, tipărite împreună, fac de obicei cauza evidentă dintr-o singură privire

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;

O numărătoare de adnotări mult peste numărătoarea de câmpuri înseamnă că pagina amestecă subtipuri, ceea ce e normal în documentele revizuite și e exact situația pentru care există maparea; articolul despre fluxul de revizuire a adnotărilor privește aceeași pagină din partea de markup. Numărători egale pe fiecare fișier de test, pe de altă parte, înseamnă că fixture-urile tale nu pot detecta deloc această clasă de bug, iar răspunsul onest e să adaugi un fixture de formular care poartă un link și un bilețel

API-urile de enumerare a câmpurilor, focus și adnotare descrise aici sunt livrate cu PDFium Component pentru Delphi, C++Builder și Lazarus, a cărui pagină de produs conține referința completă de câmp de formular, inclusiv înregistrarea de informații de câmp și accesorii de focus