Articolo tecnico

Indice Widget vs Indice Annotazione nei Form PDFium Delphi

In PDFium Component, il componente VCL/LCL basato su PDFium per Delphi, C++Builder, e Lazarus, un indice di campo form non è un indice di annotazione. Una pagina porta annotazioni Link, Text, e Ink accanto alle sue widget, quindi l'enumerazione dei campi deve filtrare su FPDFAnnot_GetSubtype ed esporre un indice logico a base zero, mappato indietro a una posizione di annotazione reale solo alla chiamata nativa

Il bug che espone questo è inconfondibile una volta che lo hai visto. Un tester preme Tab in un modulo fattura compilato e il cursore scompare, perché il focus è andato a un collegamento ipertestuale nel footer. O peggio, non succede nulla: il tuo codice registra il campo 3 come focalizzato, il pannello UI si aggiorna, e FORM_SetFocusedAnnot ha silenziosamente restituito false per tutto il tempo. Entrambi i sintomi derivano dallo stesso errore di design, e uno di essi ha una seconda causa radice nascosta sotto

I due spazi di indice che PDFium ti consegna

PDFium espone due schemi di numerazione sulla stessa pagina, e coincidono solo su documenti che capitano di contenere nient'altro che widget form. Il primo è l'indice di annotazione: una posizione nell'array /Annots della pagina, che è ciò che conta FPDFPage_GetAnnotCount e ciò che prende FPDFPage_GetAnnot (ISO 32000-1 §12.5.2). Il secondo è l'indice logico di campo che una API a livello applicazione dovrebbe offrire, che corre da zero sui campi interattivi che un utente può effettivamente raggiungere. ISO 32000-1 §12.5.6.19 definisce le widget annotation come la rappresentazione visiva dei campi form interattivi, e §12.7 definisce il form stesso. Tutto il resto sulla pagina è un sottotipo diverso con semantica diversa: un'annotazione Link ha una destinazione, un'annotazione Ink ha una lista di tratti, un'annotazione Text è una nota adesiva. Nessuna di esse appartiene a un conteggio di campi, e nessuna di esse può accettare il focus del form. Eppure nell'array /Annots stanno intervallate con le widget in qualunque ordine l'applicazione produttrice le abbia scritte, il che frequentemente non è l'ordine che qualunque altra cosa nel documento suggerisce

Perché Tab atterra su un collegamento ipertestuale invece che sul campo successivo?

Perché il conteggio dei campi era in realtà un conteggio di annotazioni. L'implementazione originale restituiva FPDFPage_GetAnnotCount direttamente da FormFieldCount, mentre l'accessore di informazioni di campo, l'helper dell'ordine di tabulazione, e l'helper del focus trattavano tutti quello stesso intero come una posizione widget. Su una pagina AcroForm pulita con sei widget e nient'altro, sei uguale sei e ogni test passa. Aggiungi un collegamento ipertestuale nel footer e un commento di revisore nel margine, e il conteggio riporta otto campi, gli indici 6 e 7 si risolvono in oggetti non-form, e Tab ci cammina dritto dentro

La correzione lato enumerazione è contare i sottotipi anziché le annotazioni. Apri ogni annotazione, chiedi il suo sottotipo, mantieni le widget, e chiudi l'handle in un blocco finally, perché FPDFPage_GetAnnot restituisce un handle posseduto che deve tornare tramite 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;

Nota cosa questo deliberatamente non fa. Non chiede nulla all'ambiente form-fill, e non ha bisogno di un handle form, perché il sottotipo vive nel dizionario di annotazione ed è leggibile dalla sola pagina. Questo conta per l'ordinamento: il conteggio è disponibile prima ancora che tu abbia deciso se il documento meriti affatto un ambiente form-fill, il che l'articolo su AcroForm JavaScript e host event tratta come una decisione di sicurezza anziché una di comodità

Mappare l'indice logico al confine nativo

La regola che impedisce ai due spazi di sconfinare l'uno nell'altro è semplice: l'indice logico è l'unico numero che attraversa la tua API pubblica, e viene convertito in un indice di annotazione nell'ultima funzione prima della chiamata nativa. Un unico helper di mappatura, usato allo stesso modo da field info, focus, setter di flag, e ordine di tabulazione, è ciò che rende quella regola applicabile

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;

Due proprietà di questo helper vale la pena dichiarare apertamente. È una scansione lineare, quindi un ciclo ingenuo su ogni campo costa un numero quadratico di aperture di annotazione su una pagina con centinaia di widget; se stai enumerando l'intera pagina, percorri le annotazioni una volta sola e raccogli gli handle delle widget man mano invece di chiamare il mapper per ogni campo. E restituisce -1 anziché sollevare un'eccezione, il che lascia al chiamante decidere se un indice obsoleto sia un errore di programmazione che merita un'eccezione o una race condition che merita di essere ignorata, per esempio dopo che una modifica ha rimosso un'annotazione a cui una lista UI in cache ancora fa riferimento

Perché FORM_SetFocusedAnnot fallisce su una pagina headless?

Perché PDFium rifiuta di focalizzare una widget la cui page view non è mai stata contrassegnata come valida. FORM_SetFocusedAnnot risolve l'annotazione a una page view dentro l'ambiente form-fill, e se quella page view non esiste restituisce false senza alcuna diagnostica. Correggere solo la mappatura degli indici quindi corregge Tab che atterra su un collegamento ipertestuale ma lascia intatto il secondo sintomo: il tuo record di focus logico dice campo 3, la widget nativa focalizzata è ancora nulla, e ogni accessore costruito sul focus nativo, testo focalizzato, valore focalizzato, stato di selezione scelta, continua a restituire vuoto. La page view viene creata da FORM_OnAfterLoadPage e distrutta da FORM_OnBeforeClosePage. In un viewer costruito attorno a un controllo visivo quelle chiamate avvengono come parte della visualizzazione di una pagina, motivo per cui il fallimento sembra così spesso un bug solo-headless: lo stesso codice che funziona nella demo GUI fallisce nello strumento batch. Il ciclo di vita appartiene all'oggetto documento, non al viewer, quindi PDFium Component ora emette entrambe le chiamate ogni volta che una pagina viene caricata o scaricata con un handle form presente. La firma C prende prima la pagina e poi l'handle form, il che è facile invertire quando si scrive il binding a mano

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;

Il controllo che dimostra la correzione è quello che confronta i due lati. Chiama FocusFormField con un indice logico, poi leggi un valore tramite un accessore che passa attraverso la widget nativa focalizzata anziché attraverso il tuo record, come FocusedFormFieldValue o FocusedFormOptionSelected. Se l'indice logico fa round-trip ma l'accessore nativo torna vuoto, manca la page view, non la mappatura

Cosa non promette l'indice logico di campo

Un indice di campo a base zero è una comodità, non un'identità semantica, e da questo seguono quattro limiti. È per pagina, non per documento, quindi l'indice 0 sulla pagina 2 è una widget diversa dall'indice 0 sulla pagina 1 e confrontarli è privo di senso. È posizionale, quindi inserire o eliminare un'annotazione invalida ogni indice in cache sopra la modifica; tratta un indice memorizzato come valido solo finché la pagina resta caricata e non modificata

Il terzo limite è quello che sorprende le persone che rivedono un elenco di campi. L'indice enumera widget, non campi. Un gruppo radio è un unico campo con diversi widget kid, quindi un gruppo a tre pulsanti contribuisce tre indici consecutivi che riportano tutti lo stesso Name. Il record TPdfFormFieldInfo porta GroupCount e GroupIndex esattamente per questo caso, e una UI a elenco che li ignora mostra lo stesso campo tre volte. Il quarto limite riguarda l'ordine di attraversamento: l'ordine di tabulazione esposto qui è l'ordine di enumerazione delle widget, che segue l'array /Annots, non la voce /Tabs della pagina (ISO 32000-1 §7.7.3.3) e non l'albero dei campi AcroForm. Per la maggior parte dei produttori questi concordano; per un form disposto in due colonne da un generatore che ha emesso prima la colonna destra, non concordano, e il percorso da tastiera descritto nell'articolo sulla navigazione dei campi form sembrerà sbagliato anche se ogni indice è corretto. Quando un file del cliente si comporta in modo strano, esporta entrambi gli spazi di indice fianco a fianco prima di teorizzare: la vista annotazione e la vista campo della stessa pagina, stampate insieme, di solito rendono la causa evidente a colpo d'occhio

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;

Un conteggio di annotazioni molto sopra il conteggio di campi significa che la pagina mescola sottotipi, il che è normale nei documenti revisionati ed è esattamente la situazione per cui esiste la mappatura; l'articolo sul workflow di revisione annotazioni guarda la stessa pagina dal lato markup. Conteggi uguali su ogni file di test, d'altra parte, significano che le tue fixture non possono rilevare affatto questa classe di bug, e la risposta onesta è aggiungere una fixture di form che porti un collegamento e una nota adesiva

Le API di enumerazione campi, focus, e annotazione descritte qui vengono fornite con PDFium Component per Delphi, C++Builder, e Lazarus, la cui pagina prodotto riporta il riferimento completo dei campi form incluso il record di informazioni di campo e gli accessori del focus