Teknisk artikel

Widget-index vs annoteringsindex i PDFium Delphi-formulär

I PDFium Component, den PDFium-baserade VCL/LCL-komponenten för Delphi, C++Builder och Lazarus, är ett formulärfältindex inte ett annoteringsindex. En sida bär Link-, Text- och Ink-annoteringar bredvid sina widgetar, så fältuppräkning måste filtrera på FPDFAnnot_GetSubtype och exponera ett nollbaserat logiskt index, mappat tillbaka till en verklig annoteringsposition endast vid det inbyggda anropet

Buggen som exponerar det här är omisskännlig när man väl har sett den. En testare trycker Tab i ett ifyllt fakturaformulär och markören försvinner, eftersom fokus gick till en hyperlänk i sidfoten. Eller värre, ingenting händer alls: din kod registrerar fält 3 som fokuserat, UI-panelen uppdateras, och FORM_SetFocusedAnnot returnerade tyst false hela tiden. Båda symptomen kommer från samma designmisstag, och ett av dem har en andra grundorsak gömd under

De två indexrymderna PDFium ger dig

PDFium exponerar två numreringsscheman över samma sida, och de sammanfaller bara på dokument som råkar innehålla inget annat än formulärwidgetar. Den första är annoteringsindexet: en position i sidans /Annots-array, vilket är vad FPDFPage_GetAnnotCount räknar och vad FPDFPage_GetAnnot tar (ISO 32000-1 §12.5.2). Den andra är det logiska fältindexet som ett applikationsnivå-API bör erbjuda, som löper från noll över de interaktiva fält en användare faktiskt kan nå. ISO 32000-1 §12.5.6.19 definierar widget-annoteringar som den visuella representationen av interaktiva formulärfält, och §12.7 definierar själva formuläret. Allt annat på sidan är en annan undertyp med annan semantik: en Link-annotering har en destination, en Ink-annotering har en strecklista, en Text-annotering är en klisterlapp. Ingen av dem hör hemma i en fälträkning, och ingen av dem kan ta emot formulärfokus. Ändå sitter de i /Annots-arrayen sammanflätade med widgetarna i vilken ordning den producerande applikationen skrev dem, vilket ofta inte är den ordning något annat i dokumentet antyder

Varför landar Tab på en hyperlänk istället för nästa fält?

Därför att fälträkningen egentligen var en annoteringsräkning. Den ursprungliga implementationen returnerade FPDFPage_GetAnnotCount direkt från FormFieldCount, medan fältinformationsaccessorn, tab-ordningshjälparen och fokushjälparen alla behandlade samma heltal som en widgetposition. På en ren AcroForm-sida med sex widgetar och inget annat är sex lika med sex och varje test klarar sig. Lägg till en hyperlänk i sidfoten och en granskningskommentar i marginalen, och räkningen rapporterar åtta fält, index 6 och 7 löses till icke-formulärobjekt, och Tab går rakt in i dem

Fixen i uppräkningsänden är att räkna undertyper snarare än annoteringar. Öppna varje annotering, fråga efter dess undertyp, behåll widgetarna, och stäng handtaget i ett finally-block, eftersom FPDFPage_GetAnnot returnerar ett ägt handtag som måste gå tillbaka genom 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;

Notera vad detta avsiktligt inte gör. Det frågar inte formulärifyllningsmiljön om någonting, och det behöver inget formulärhandtag, eftersom undertypen bor i annoteringsordlistan och är läsbar från sidan ensam. Det spelar roll för ordning: räkningen är tillgänglig innan du har bestämt om dokumentet ens förtjänar en formulärifyllningsmiljö, vilket artikeln om AcroForm-JavaScript och värdhändelser täcker som ett säkerhetsbeslut snarare än en bekvämlighet

Att mappa det logiska indexet tillbaka vid den inbyggda gränsen

Regeln som hindrar de två rymderna från att läcka in i varandra är enkel: det logiska indexet är det enda talet som korsar ditt publika API, och det omvandlas till ett annoteringsindex i den sista funktionen innan det inbyggda anropet. En mappningshjälpfunktion, använd av fältinfo, fokus, flaggsättare och tab-ordning likaledes, är det som gör den regeln genomdrivbar

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;

Två egenskaper hos den här hjälpfunktionen är värda att säga rakt ut. Det är en linjär genomsökning, så en naiv loop över varje fält kostar ett kvadratiskt antal annoteringsöppningar på en sida med hundratals widgetar; om du räknar upp hela sidan, gå igenom annoteringarna en gång och samla widget-handtagen medan du går istället för att anropa mapparen per fält. Och den returnerar -1 snarare än att höja ett undantag, vilket låter anroparen bestämma om ett föråldrat index är ett programmeringsfel värt ett undantag eller ett race värt att ignorera, till exempel efter en redigering som tog bort en annotering som en cachad UI-lista fortfarande refererar till

Varför misslyckas FORM_SetFocusedAnnot på en headless-sida?

Därför att PDFium vägrar fokusera en widget vars sidvy aldrig markerades giltig. FORM_SetFocusedAnnot löser annoteringen till en sidvy inuti formulärifyllningsmiljön, och om den sidvyn inte existerar returnerar den false utan någon diagnostik. Att bara korrigera indexmappningen fixar därför Tab som landar på en hyperlänk men lämnar det andra symptomet orört: din logiska fokusregistrering säger fält 3, den inbyggda fokuserade widgeten är fortfarande ingenting, och varje accessor byggd på det inbyggda fokuset, fokuserad text, fokuserat värde, valstatustillstånd, fortsätter att returnera tomt. Sidvyn skapas av FORM_OnAfterLoadPage och förstörs av FORM_OnBeforeClosePage. I en visare byggd kring en visuell kontroll sker dessa anrop som en del av att visa en sida, vilket är varför felet så ofta ser ut som en headless-bugg: samma kod som fungerar i GUI-demot misslyckas i batchverktyget. Livscykeln hör till dokumentobjektet, inte visaren, så PDFium Component utfärdar nu båda anropen närhelst en sida laddas eller avlastas med ett formulärhandtag närvarande. C-signaturen tar sidan först och formulärhandtaget sedan, vilket är lätt att invertera när man skriver bindningen för hand

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;

Kontrollen som bevisar fixen är den som jämför de två sidorna. Anropa FocusFormField med ett logiskt index, läs sedan ett värde genom en accessor som går genom den inbyggda fokuserade widgeten snarare än genom din egen registrering, som FocusedFormFieldValue eller FocusedFormOptionSelected. Om det logiska indexet rundtrippar men den inbyggda accessorn kommer tillbaka tom, saknas sidvyn, inte mappningen

Vad det logiska fältindexet inte lovar

Ett nollbaserat fältindex är en bekvämlighet, inte en semantisk identitet, och fyra begränsningar följer av det. Det är per sida, inte per dokument, så index 0 på sida 2 är en annan widget än index 0 på sida 1 och att jämföra dem är meningslöst. Det är positionellt, så att infoga eller ta bort en annotering ogiltigförklarar varje cachat index ovanför ändringen; behandla ett lagrat index som giltigt bara så länge sidan förblir laddad och oredigerad

Den tredje begränsningen är den som överraskar folk som granskar en fältlista. Indexet räknar upp widgetar, inte fält. En radiogrupp är ett fält med flera widget-kids, så en trekknappsgrupp bidrar med tre på varandra följande index som alla rapporterar samma Name. TPdfFormFieldInfo-posten bär GroupCount och GroupIndex för exakt det här fallet, och ett list-UI som ignorerar dem visar samma fält tre gånger. Den fjärde begränsningen gäller genomgångsordning: tab-ordningen som exponeras här är widget-uppräkningsordningen, vilket följer /Annots-arrayen, inte sidans /Tabs-post (ISO 32000-1 §7.7.3.3) och inte AcroForm-fältträdet. För de flesta producenter stämmer dessa överens; för ett formulär utlagt i två kolumner av en generator som skrev ut den högra kolumnen först, gör de inte det, och tangentbordsvägen beskriven i artikeln om formulärfältnavigering kommer att kännas fel även om varje index är korrekt. När en kundfil beter sig konstigt, dumpa båda indexrymderna sida vid sida innan du teoretiserar: annoteringsvyn och fältvyn av samma sida, utskrivna tillsammans, gör vanligtvis orsaken uppenbar i en enda blick

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;

Ett annoteringsantal långt över fältantalet betyder att sidan blandar undertyper, vilket är normalt i granskade dokument och är precis den situation mappningen finns för; artikeln om granskningsarbetsflöde för annoteringar tittar på samma sida från markeringssidan. Lika antal på varje testfil, å andra sidan, betyder att dina fixturer inte kan upptäcka den här sortens bugg alls, och det ärliga svaret är att lägga till en formulärfixtur som bär en länk och en klisterlapp

Fält-uppräkningen, fokuset och annoterings-API:erna beskrivna här levereras med PDFium Component för Delphi, C++Builder och Lazarus, vars produktsida innehåller den fullständiga formulärfältreferensen inklusive fältinformationsposten och fokusaccessorerna