Ab Version 3.223.0 ordnet PDFium Component for Delphi die Formularfeld-Navigation nach der deklarierten /Tabs-Policy je Seite: R für Zeilenordnung, C für Spaltenordnung, S für Strukturordnung, plus die PDF-2.0-Werte A und W. TPdf.FormFieldTabOrder liefert jene Ordnung, und sein Status-Overload meldet, ob die deklarierte Policy angewendet wurde oder ein Fallback zum Einsatz kam
Die Beschwerde, die das alles motiviert, ist immer dieselbe. Ein Anwender füllt ein zweispaltiges Versicherungsformular, drückt Tab nach dem Feld für den Vornamen, und der Cursor springt an den unteren Rand der rechten Spalte. Nichts ist mit den Indizes kaputt. Der Erzeuger hat einfach die rechte Spalte zuerst in /Annots geschrieben und auf der Seite /Tabs /R deklariert, um die visuelle Ordnung zu fixen, also landet ein Viewer, der /Annots abläuft und die Deklaration ignoriert, am falschen Ort. Der Artikel zu Widget-Index versus Annotations-Index behandelt, wie sich Widget-Ordinalzahlen zum /Annots-Array verhalten. Im PDFium Component gewinnt die deklarierte Policy, wann immer sie sich ermitteln lässt, und wenn nicht, sagt dir die Komponente warum
Was deklariert der /Tabs-Eintrag eigentlich?
/Tabs ist ein optionaler Name im Seitenobjekt (ISO 32000-2 §7.7.3.3; Table 30 in ISO 32000-1), der einem Prozessor sagt, in welcher Ordnung er die Annotationen auf jener Seite besuchen soll, und seine Semantik ist unter Annotationen in §12.5.1 ausgebreitet. PDF 1.5 führte drei Werte ein, und PDF 2.0 fügte zwei weitere hinzu:
/R, Zeilenordnung: Zeilen von oben nach unten, und innerhalb jeder Zeile die Leserichtung des Viewers/C, Spaltenordnung: Spalten in Leserichtung, und von oben nach unten innerhalb jeder Spalte/S, Strukturordnung: die Ordnung, in der der logische Strukturbaum die Annotationen referenziert/A, Annotations-Array-Ordnung: die Ordnung von/Annotsselbst (PDF 2.0)/W, Widget-Ordnung: Widgets zuerst in Array-Ordnung, dann die anderen Annotationen (PDF 2.0)
Das Detail, das handgeschriebene Implementierungen stolpern lässt, ist die Vererbung. /Tabs gehört allein zum Seiten-Dictionary. ISO 32000-2 §7.7.3.4 begrenzt vererbbare Seitenattribute auf Resources, MediaBox, CropBox und Rotate, ein /Tabs-Schlüssel auf einem /Pages-Knoten ist also bedeutungslos, und ein Reader, der die Elternkette hinaufwandert, um einen zu finden, erfindet eine Policy, die der Autor nie gesetzt hat. PDFium Component liest /Tabs von der Seite selbst und nirgendwo sonst
Die deklarierte Ordnung mit FormFieldTabOrder lesen
TPdf.FormFieldTabOrder liefert die Formularfelder der aktuellen Seite als 0-basierte Widget-Ordinalzahlen in der deklarierten Tab-Ordnung, und der Overload mit einem out Status: TPdfFormTabOrderStatus-Parameter erklärt, wie jene Ordnung entstand. Dasselbe Paar existiert auf TPdfView, und der schlichte Overload nutzt dieselbe Policy, während er den Status verwirft
uses
PDFium;
const
ModeNames: array[TPdfFormTabOrderMode] of string =
('unspecified', 'R', 'C', 'S', 'A', 'W', 'unsupported');
procedure DumpTabOrder(const FileName: string; PageNo: Integer);
var
Pdf: TPdf;
Order: TPdfIntegerArray;
Status: TPdfFormTabOrderStatus;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.FormFill := True; // bereits der Default; nötig für Felder
Pdf.Active := True;
if not Pdf.Active or (Pdf.FormType <> ftAcroForm) then
Exit;
Pdf.PageNumber := PageNo;
Order := Pdf.FormFieldTabOrder(Status);
Writeln('requested ', ModeNames[Status.RequestedMode],
', applied ', ModeNames[Status.EffectiveMode]);
if Status.UsedFallback then
Writeln('fallback: ', Status.ErrorMessage);
if Status.UnstructuredWidgetCount > 0 then
Writeln(Status.UnstructuredWidgetCount, ' widgets not in the structure tree');
for I := 0 to High(Order) do
Writeln(I, ': field ', Order[I], ' ',
string(Pdf.FormFieldInfo[Order[I]].Name));
finally
Pdf.Free;
end;
end;
Der Status-Record ist das, was aus einer stillen Ordnungsentscheidung etwas macht, das du loggen kannst. RequestedMode ist, was die Seite deklarierte, EffectiveMode ist, was angewendet wurde, und UsedFallback ist true, wenn die deklarierte Policy nicht einhaltbar war und stattdessen die Annotations-Array-Ordnung zurückkam, mit einem kurzen Grund in ErrorMessage. SnapshotAvailable sagt, ob die Komponente die Dokument-Metadaten überhaupt lesen konnte. Ein fehlendes /Tabs wird als Fallback auf die Annotations-Array-Ordnung gemeldet, statt als deklarierte Policy auszugeben, ein Support-Log kann also "der Autor wollte diese Ordnung" von "die Seite wollte nichts" unterscheiden
Warum können Zeilen- und Spaltenordnung nicht einfach die /Rect-Werte sortieren?
Zeilen- und Spaltenordnung müssen in Anzeigegeometrie gerechnet werden, nicht in rohen Annotationskoordinaten, denn das /Rotate einer Seite und die Leserichtung des Dokuments ändern beide, was eine "Zeile" ist. Sortiere die /Rect-Arrays einer Seite mit /Rotate 90 nach ihrer y-Koordinate, und du bekommst das, was der Viewer als Spalten zeigt. Die Komponente mappt deshalb jedes Widget-Rechteck durch die Anzeigetransformation der Seite, intrinsische Rotation eingeschlossen, bei 1/64-Punkt-Auflösung, und gruppiert von dort aus
Die Leserichtung kommt aus dem ViewerPreferences-Dictionary des Katalogs (ISO 32000-2 §12.2). /Direction /R2L kehrt die Ordnung innerhalb einer Zeile um und kehrt die Ordnung der Spalten um, was ein arabisches oder hebräisches Formular erwartet. Fehlt /Direction, gilt L2R. Hält es irgendetwas anderes als L2R oder R2L, fallen Zeilen- und Spaltenordnung auf die Annotations-Array-Ordnung zurück, mit dem Grund im Status, denn eine Richtung zu raten ist schlimmer, als zuzugeben, dass es keine gibt
Die Gruppierung ist der Ort, an dem Prozessoren legitim differieren, also lohnt es sich, die exakte Regel zu kennen. Bei der Zeilenordnung wird das oberste unbesuchte Widget zum Anker, und jedes Widget, dessen vertikale Mitte zwischen Anker oben und unten fällt, gesellt sich in seine Zeile. Die Spaltenordnung spiegelt das horizontal. Gleichstände werden deterministisch über die Widget-Ordinalzahl entschieden. Auf einem sauberen Gitter passt das zu jedem vernünftigen Viewer. Auf einem Layout mit stark überlappenden oder versetzten Feldern kann ein anderer Prozessor mit anderer Toleranz anders gruppieren, und kein Teil von ISO 32000 macht irgendeine der Antworten falsch
Wie entscheidet die Strukturordnung die Sequenz?
Die Strukturordnung folgt den Objektreferenz- (OBJR) Dictionarys im Strukturbaum, in der Ordnung, in der ein Depth-First-Durchlauf der /K-Arrays sie tatsächlich erreicht. Sie folgt nicht der /ParentTree-Schlüsselordnung und nicht der Ordnung, die eine Aufzählung von StructTreeRoot zufällig erzeugt. Jene Ordnungen fallen in Dateien eines einzelnen Erzeugers oft zusammen und driften auseinander, nachdem ein Editor den Baum umgebaut hat, genau dann zählt die Leseordnung eines getaggten Formulars am meisten. Pflegst du jenen Baum selbst, behandelt der Leitfaden zum Synchronhalten von /ParentTree und /StructParents die Rückwärtszuordnung, die damit konsistent bleiben muss
Widgets, die der Strukturbaum nie referenziert, überlässt die Spezifikation dem Prozessor. PDFium Component hängt sie hinter den strukturierten Widgets in Annotations-Array-Ordnung an und meldet, wie viele es anhing, in UnstructuredWidgetCount. Eine Seite, die /S deklariert, auf einem Dokument ohne jeden Strukturbaum, wird als leere Traversierung behandelt: Jedes Widget landet in jenem Schwanz, die Zahl sagt es, und das Ergebnis wird nicht als voller Fallback markiert. Ein Strukturbaum mit einem Zyklus, ein zweimal referenzierter OBJR oder ein OBJR, dessen /Pg der Seite widerspricht, die die Annotation besitzt, lässt die Strukturordnung zurückfallen. Jene Defekte machen S unbrauchbar, aber sie stören R oder C auf anderen Seiten nicht
Warum sind Tab-Ordnungswerte keine Annotations-Indizes?
Die Werte im Array, das FormFieldTabOrder liefert, zählen nur Widgets, während ein OBJR ein Annotationsobjekt identifiziert, das im vollen /Annots-Array lebt. Die beiden Indexräume zu vermischen ist der mit Abstand wahrscheinlichste Bug in jedem Code, der Strukturordnung konsumiert. Nimm eine Seite, deren /Annots gleich [Link, Widget A, Widget B] ist. Der OBJR für Widget B zeigt auf Annotations-Index 2. Widget B ist Feld-Ordinalzahl 1. Füttere FocusFormField mit 2, und du fokussierst entweder das falsche Feld oder gar nichts, je nachdem, wie viele Widgets die Seite hat
Die Komponente macht jene Konvertierung intern, deshalb kann ihr Ergebnis direkt in FocusFormField und FormFieldInfo wandern. Liest dein eigener Code OBJR-Ziele oder andere Annotations-Indizes, konvertiere sie explizit, bevor du sie als Felder behandelst:
// Wandelt einen Annotations-Index der Seite in die Widget-Ordinalzahl um, die genutzt wird von
// FocusFormField, FormFieldInfo und FormFieldTabOrder; -1, wenn kein Widget
function AnnotationToFieldOrdinal(Pdf: TPdf; AnnotIndex: Integer): Integer;
var
I: Integer;
begin
Result := -1;
if (AnnotIndex < 0) or (AnnotIndex >= Pdf.AnnotationCount) then
Exit;
if Pdf.Annotation[AnnotIndex].Subtype <> anWidget then
Exit; // Links, Notes und Popups sind keine Felder
Result := 0;
for I := 0 to AnnotIndex - 1 do
if Pdf.Annotation[I].Subtype = anWidget then
Inc(Result);
end;
Behandle das Ergebnis nur als gültig, solange die Seite geladen und uneditiert bleibt. Jede Annotation hinzuzufügen oder zu entfernen vor einem Widget verschiebt beide Indexräume, und ein gecachtes Mapping von vor der Änderung ist genau die Art Off-by-one, die sich nur auf Kundendateien reproduziert
Seitenübergreifende Navigation mit FocusNextFormField
FocusNextFormField(AcrossPages: Boolean; WrapDocument: Boolean = True; MaxPagesToScan: Integer = 256) läuft die Tab-Ordnung der aktuellen Seite ab, setzt dann in spätere Seiten fort, und ab 3.223.0 wendet jede besuchte Seite ihre eigene deklarierte Policy an. Der seitenübergreifende Overload kam mit Version 3.220.0, der Rat im Artikel zur Formularfeld-Navigation, den Wrap selbst zu erkennen und PageNumber zu ändern, gilt also jetzt nur für den Overload ohne Argumente, der weiterhin innerhalb einer Seite kreist
procedure TFormViewer.HandleTabKey(Shift: TShiftState);
var
Field: Integer;
begin
if ssShift in Shift then
Field := PdfView.FocusPreviousFormField(True) // seitenübergreifend, mit Wrap
else
Field := PdfView.FocusNextFormField(True);
if Field < 0 then
Beep // nichts Passendes im Scan; Seite und Fokus unverändert
else
UpdateFieldStatus(PdfView.PageNumber, Field);
end;
Der Rückgabewert ist die seitenlokale Feld-Ordinalzahl auf welcher Seite auch immer jetzt den Fokus hält, lies PageNumber also daneben. Die seitenübergreifende Traversierung überspringt Widgets, in die ein Anwender nicht sinnvoll tabben kann: versteckte, unsichtbare, NoView-, readonly-, Signatur-, unbekannten-Typs- und leeres-Rechteck-Widgets. WrapDocument = False zu übergeben stoppt an der Dokumentgrenze mit -1. MaxPagesToScan muss zwischen 1 und 4096 liegen, und jede inspizierte Seite zählt, die aktuelle eingeschlossen. Findet der Scan nichts oder verweigert die native Engine den Fokus auf dem Ziel, liefert der Aufruf -1 und lässt die vorherige Seite und den Fokus exakt, wie sie waren
Wo hört die deklarierte Ordnung auf zu gelten?
Die deklarierte Policy wird nur eingehalten, wenn die Komponente die Seiten-Metadaten verlässlich lesen kann, und jeder Fall, in dem sie es nicht kann, fällt auf die Annotations-Array-Widget-Ordnung zurück, mit explizitem Grund. Die Grenzen sind konkret genug, um sie aufzulisten:
- Metadaten kommen aus einer begrenzten privaten Serialisierung des aktuellen nativen Dokuments, nicht aus den Original-Datei-Bytes, denn In-Memory-Seitenverschiebungen und Änderungen machen die Datei auf der Platte veraltet. Der Snapshot wird je Inhaltsrevision gecacht, gemeldete Änderungen invalidieren ihn also, und gescheiterte Snapshots werden ebenfalls gecacht
- Verschlüsselte Dokumente sowie Snapshots, angefragt während eines Page-Object-Edits, haben keinen Snapshot und fallen immer zurück
- Der Snapshot ist gedeckelt bei 64 MiB, 65536 aktiven Objekten, 4096 Seiten, 4096 Annotationen je Seite, 65536 Annotationen insgesamt und einer Strukturtiefe von 64; größere Dokumente fallen zurück, statt die Navigation festzusetzen
- Nur AcroForm-Dokumente sind abgedeckt. XFA-Formulare bleiben außerhalb dieses Profils
- Zeilen- und Spaltenordnung brauchen finite, nichtleere Widget-Rechtecke und vernünftige Seitenmaße; entartete Geometrie löst den Fallback aus
- Diese APIs treiben die Navigation aus deinem Code an. PDFiums eigene Handhabung einer Tab-Taste, an die Engine weitergeleitet, wird nicht ersetzt, also route die Taste durch
FocusNextFormField, wenn du die deklarierte Ordnung willst
Abfragen bewahren die aktuelle Seite, den nativen Fokus und jeden ausstehenden Edit-Text im fokussierten Feld, und sie fahren keine Dokumenten- oder Seiten-Lebenszyklus-Aktionen. FormFieldTabOrder bei jedem Tastenschlag zu rufen ist also sicher, und nach dem ersten Aufruf auf einer gegebenen Inhaltsrevision ist es billig
Deklarierte Tab-Ordnung, die Status-Diagnosen und die seitenübergreifende Fokus-Navigation sind Teil des PDFium Component for Delphi and C++Builder; die Produktseite verlinkt die volle API-Referenz, inklusive der TPdfFormTabOrderMode- und TPdfFormTabOrderStatus-Einträge