In PDFium Component, der auf PDFium basierenden VCL/LCL-Komponente für Delphi, C++Builder und Lazarus, ist ein Formularfeld-Index kein Annotations-Index. Eine Seite trägt Link-, Text- und Ink-Annotationen neben ihren Widgets, sodass die Feld-Enumeration nach FPDFAnnot_GetSubtype filtern und einen nullbasierten logischen Index anbieten muss, erst beim nativen Aufruf zurück auf eine echte Annotationsposition gemappt
Der Bug, der das offenlegt, ist unverkennbar, sobald man ihn einmal gesehen hat. Ein Tester drückt Tab in einem ausgefüllten Rechnungsformular, und der Cursor verschwindet, weil der Fokus zu einem Hyperlink in der Fußzeile ging. Oder schlimmer: nichts geschieht überhaupt: Der eigene Code vermerkt Feld 3 als fokussiert, das UI-Panel aktualisiert sich, und FORM_SetFocusedAnnot gab die ganze Zeit still false zurück. Beide Symptome stammen aus demselben Designfehler, und eines davon hat eine zweite Grundursache darunter versteckt
Die zwei Indexräume, die PDFium einem gibt
PDFium legt zwei Nummerierungsschemata über dieselbe Seite, und sie stimmen nur bei Dokumenten überein, die zufällig nichts als Formular-Widgets enthalten. Der erste ist der Annotations-Index: eine Position im Seiten-Array /Annots, was FPDFPage_GetAnnotCount zählt und was FPDFPage_GetAnnot entgegennimmt (ISO 32000-1 §12.5.2). Der zweite ist der logische Feld-Index, den eine API auf Anwendungsebene anbieten sollte, laufend von null über die interaktiven Felder, die ein Nutzer tatsächlich erreichen kann. ISO 32000-1 §12.5.6.19 definiert Widget-Annotationen als die visuelle Darstellung interaktiver Formularfelder, und §12.7 definiert das Formular selbst. Alles andere auf der Seite ist ein anderer Subtyp mit anderer Semantik: Eine Link-Annotation hat ein Ziel, eine Ink-Annotation hat eine Strichliste, eine Text-Annotation ist eine Haftnotiz. Keine davon gehört in eine Feldzählung, und keine kann Formularfokus annehmen. Dennoch sitzen sie im /Annots-Array durchmischt mit den Widgets, in welcher Reihenfolge auch immer die erzeugende Anwendung sie geschrieben hat, was häufig nicht die Reihenfolge ist, die sonst irgendetwas am Dokument nahelegt
Warum landet Tab auf einem Hyperlink statt dem nächsten Feld?
Weil die Feldzählung eigentlich eine Annotationszählung war. Die ursprüngliche Implementierung gab FPDFPage_GetAnnotCount direkt aus FormFieldCount zurück, während der Feldinformations-Accessor, der Tab-Reihenfolge-Helfer und der Fokus-Helfer allesamt dieselbe Ganzzahl als Widget-Position behandelten. Auf einer sauberen AcroForm-Seite mit sechs Widgets und sonst nichts ist sechs gleich sechs, und jeder Test besteht. Fügt man einen Hyperlink in der Fußzeile und einen Reviewer-Kommentar am Rand hinzu, meldet die Zählung acht Felder, die Indizes 6 und 7 lösen zu Nicht-Formular-Objekten auf, und Tab läuft geradewegs hinein
Der Fix am Enumerationsende besteht darin, Subtypen statt Annotationen zu zählen. Jede Annotation öffnen, nach ihrem Subtyp fragen, die Widgets behalten und das Handle in einem finally-Block schließen, denn FPDFPage_GetAnnot gibt ein eigenes Handle zurück, das über FPDFPage_CloseAnnot zurückgegeben werden muss
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;
Man beachte, was das bewusst nicht tut. Es fragt die Form-Fill-Umgebung nichts, und es braucht kein Formular-Handle, denn der Subtyp lebt im Annotations-Dictionary und ist allein von der Seite aus lesbar. Das ist wichtig für die Reihenfolge: Die Zählung ist verfügbar, bevor man überhaupt entschieden hat, ob das Dokument sich eine Form-Fill-Umgebung verdient, was der Artikel zu AcroForm-JavaScript und Host-Ereignissen als Sicherheitsentscheidung behandelt, nicht als Bequemlichkeit
Den logischen Index an der nativen Grenze zurückmappen
Die Regel, die verhindert, dass die beiden Räume ineinander durchsickern, ist einfach: Der logische Index ist die einzige Zahl, die die öffentliche API überquert, und er wird in der letzten Funktion vor dem nativen Aufruf in einen Annotations-Index umgewandelt. Ein Mapping-Helfer, verwendet von Feldinformation, Fokus, Flag-Settern und Tab-Reihenfolge gleichermaßen, ist es, der diese Regel durchsetzbar macht
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;
Zwei Eigenschaften dieses Helfers sind es wert, klar benannt zu werden. Es ist ein linearer Scan, sodass eine naive Schleife über jedes Feld eine quadratische Anzahl von Annotationsöffnungen auf einer Seite mit Hunderten von Widgets kostet; wer die gesamte Seite enumeriert, durchläuft die Annotationen einmal und sammelt die Widget-Handles unterwegs, statt den Mapper pro Feld aufzurufen. Und es gibt -1 zurück statt auszulösen, was dem Aufrufer überlässt, ob ein veralteter Index ein Programmierfehler ist, der eine Exception wert ist, oder ein Rennen, das ignoriert werden kann, etwa nachdem eine Bearbeitung eine Annotation entfernt hat, auf die eine gecachte UI-Liste noch verweist
Warum schlägt FORM_SetFocusedAnnot auf einer Headless-Seite fehl?
Weil PDFium sich weigert, ein Widget zu fokussieren, dessen Seitenansicht nie als gültig markiert wurde. FORM_SetFocusedAnnot löst die Annotation zu einer Seitenansicht innerhalb der Form-Fill-Umgebung auf, und existiert diese Seitenansicht nicht, gibt es false zurück, ohne jede Diagnose. Die Indexzuordnung allein zu korrigieren behebt daher, dass Tab auf einem Hyperlink landet, lässt aber das zweite Symptom unberührt: Der eigene logische Fokus-Datensatz besagt Feld 3, das native fokussierte Widget ist weiterhin nichts, und jeder auf dem nativen Fokus aufgebaute Accessor, fokussierter Text, fokussierter Wert, Auswahlzustand einer Choice, gibt weiterhin leer zurück. Die Seitenansicht wird von FORM_OnAfterLoadPage erzeugt und von FORM_OnBeforeClosePage zerstört. In einem um ein visuelles Steuerelement herum gebauten Viewer geschehen diese Aufrufe als Teil der Anzeige einer Seite, weshalb der Fehlschlag so oft wie ein reiner Headless-Bug aussieht: derselbe Code, der in der GUI-Demo funktioniert, versagt im Batch-Tool. Der Lebenszyklus gehört dem Dokumentobjekt, nicht dem Viewer, sodass PDFium Component nun beide Aufrufe ausgibt, wann immer eine Seite mit vorhandenem Formular-Handle geladen oder entladen wird. Die C-Signatur nimmt zuerst die Seite und danach das Formular-Handle entgegen, was leicht zu vertauschen ist, wenn man das Binding von Hand schreibt
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;
Die Prüfung, die den Fix beweist, ist die, die beide Seiten vergleicht. FocusFormField mit einem logischen Index aufrufen, dann einen Wert über einen Accessor lesen, der über das native fokussierte Widget geht statt über den eigenen Datensatz, etwa FocusedFormFieldValue oder FocusedFormOptionSelected. Kommt der logische Index round-trip-fähig zurück, aber der native Accessor kommt leer zurück, fehlt die Seitenansicht, nicht die Zuordnung
Was der logische Feld-Index nicht verspricht
Ein nullbasierter Feld-Index ist eine Bequemlichkeit, keine semantische Identität, und daraus folgen vier Grenzen. Er gilt pro Seite, nicht pro Dokument, sodass Index 0 auf Seite 2 ein anderes Widget ist als Index 0 auf Seite 1, und beide zu vergleichen ist bedeutungslos. Er ist positionell, sodass das Einfügen oder Löschen einer Annotation jeden gecachten Index oberhalb der Änderung ungültig macht; einen gespeicherten Index nur so lange als gültig behandeln, wie die Seite geladen und unbearbeitet bleibt
Die dritte Grenze ist die, die beim Prüfen einer Feldliste überrascht. Der Index enumeriert Widgets, nicht Felder. Eine Radiogruppe ist ein Feld mit mehreren Widget-Kindern, sodass eine Drei-Button-Gruppe drei aufeinanderfolgende Indizes beisteuert, die allesamt denselben Name melden. Der Datensatz TPdfFormFieldInfo trägt GroupCount und GroupIndex genau für diesen Fall, und eine Listen-UI, die sie ignoriert, zeigt dasselbe Feld dreimal. Die vierte Grenze betrifft die Durchlaufreihenfolge: Die hier freigelegte Tab-Reihenfolge ist die Widget-Enumerationsreihenfolge, die dem /Annots-Array folgt, nicht dem Seiteneintrag /Tabs (ISO 32000-1 §7.7.3.3) und nicht dem AcroForm-Feldbaum. Bei den meisten Erzeugern stimmen diese überein; bei einem Formular, das von einem Generator zweispaltig angelegt wurde, der die rechte Spalte zuerst emittierte, tun sie es nicht, und der im Artikel zur Formularfeld-Navigation beschriebene Tastaturpfad wird sich falsch anfühlen, obwohl jeder Index korrekt ist. Verhält sich eine Kundendatei seltsam, beide Indexräume nebeneinander ausgeben, bevor man theoretisiert: die Annotationsansicht und die Feldansicht derselben Seite, zusammen ausgedruckt, machen die Ursache meist mit einem Blick offensichtlich
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;
Eine Annotationszählung weit über der Feldzählung bedeutet, die Seite mischt Subtypen, was in geprüften Dokumenten normal ist und genau die Situation, für die die Zuordnung existiert; der Artikel zum Annotations-Review-Workflow betrachtet dieselbe Seite von der Markup-Seite aus. Gleiche Zählungen auf jeder Testdatei hingegen bedeuten, dass die eigenen Fixtures diese Fehlerklasse überhaupt nicht erkennen können, und die ehrliche Reaktion ist, eine Formular-Fixture hinzuzufügen, die einen Link und eine Haftnotiz trägt
Die hier beschriebenen Feld-Enumerations-, Fokus- und Annotations-APIs werden mit PDFium Component für Delphi, C++Builder und Lazarus ausgeliefert, deren Produktseite die vollständige Formularfeld-Referenz führt, einschließlich des Feldinformations-Datensatzes und der Fokus-Accessoren