Kontrollkästchen und Radiobuttons flachen als nicht angekreuzt ab, weil der Erscheinungszustand /AS nie mit dem Feldwert /V synchronisiert wurde. PDFium Component, die auf PDFium basierende VCL- und LCL-Komponente für Delphi, C++Builder und Lazarus, liest diesen Wert nun mit FPDFAnnot_GetFormFieldValue, das das übergeordnete Feld-Dictionary auflöst statt der Widget-Annotation
Der Bugreport, der hierher führte, ist von der Art, der man zunächst misstraut. Ein Kunde flacht ein unterschriebenes Einwilligungsformular ab, öffnet das Ergebnis, und jedes Kontrollkästchen ist leer. Öffnet man die Quelldatei in Acrobat, sind die Kästchen sichtbar angekreuzt. Liest man die Quelldatei über dieselbe Komponente zurück, sind die Feldwerte korrekt. Nur die abgeflachte Ausgabe verliert sie, und nur bei Kontrollkästchen und Radiobuttons: Textfelder auf derselben Seite kommen einwandfrei heraus
Warum sind Kontrollkästchen nach dem Abflachen nicht angekreuzt?
Weil das Abflachen nie auf /V schaut. FPDFPage_Flatten backt den Widget-Erscheinungsstream in den Seiteninhalt, und die gewählte Erscheinung ist die, die von /AS benannt wird. Sagt /AS weiterhin /Off, während der Feldwert besagt, dass das Kästchen an ist, backt das Abflachen getreu die Off-Erscheinung. Der Wert ging nie verloren; er wurde nie konsultiert
ISO 32000-1 §12.5.5 definiert das Erscheinungs-Dictionary /AP mit drei möglichen Einträgen, /N, /R und /D. Bei einem Kontrollkästchen oder Radiobutton ist der /N-Eintrag kein Stream, sondern ein Sub-Dictionary, dessen Schlüssel Erscheinungszustandsnamen sind, und §12.5.2 macht /AS zum erforderlichen Selektor, wenn /N ein Sub-Dictionary ist. Ein Kontrollkästchen trägt also zwei vorgefertigte Erscheinungen und einen Zeiger. Liegt der Zeiger falsch, ist das Rendering auf eine Art falsch, die kein noch so korrektes /V repariert. Das ist auch, warum sich der Fehlermodus von Textfeldern unterscheidet, die überhaupt keine vorgefertigte Erscheinung zur Auswahl haben: Ein Textfeld-/N ist ein einzelner Stream, der nach einer Wertänderung von Grund auf neu erzeugt werden muss, sodass GenerateFormAppearances die beiden Fälle über vollständig getrennte Codepfade behandelt und nur der Button-Pfad kaputt war
Wo lebt der Checkbox-Wert tatsächlich?
Am Feld-Dictionary, nicht am Widget. ISO 32000-1 §12.7.5.2 beschreibt Kontrollkästchen und Radiobuttons als Button-Felder, deren /V ein Namensobjekt ist, das den aktuellen Erscheinungszustand benennt, und §12.7.3.1 platziert /V unter den allen Feld-Dictionaries gemeinsamen Einträgen. Die in §12.5.6.19 definierte Widget-Annotation trägt /AS und /AP bei. Nichts in der Spezifikation verpflichtet ein Widget, /V zu tragen
// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off
{ What the two objects look like when the field has several widgets:
12 0 obj % field dictionary (the parent)
<< /FT /Btn /T (Consent) /V /On
/Kids [ 13 0 R 14 0 R ] >>
endobj
13 0 obj % widget annotation (a kid)
<< /Type /Annot /Subtype /Widget /Parent 12 0 R
/AS /Off
/AP << /N << /On 20 0 R /Off 21 0 R >> >> >>
endobj }
FPDFAnnot_GetStringValue ist nicht defekt. Sein Vertrag ist genau das, was sein Name sagt: einen String-Eintrag aus dem übergebenen Annotations-Dictionary holen. Fragt man Objekt 13 nach /V, kommt nichts zurück, weil Objekt 13 tatsächlich kein /V hat. Der Defekt lag beim Aufrufer, der ein flaches Objektmodell annahm, das ISO 32000-1 nie versprochen hat
Wann teilen sich Feld und Widget ein Dictionary?
Immer dann, wenn ein Feld genau ein Widget hat. §12.5.6.19 erlaubt, das Feld-Dictionary und seine einzelne Widget-Annotation zu einem Objekt zusammenzuführen, und die meisten Autorenwerkzeuge nehmen diese Abkürzung. In einem zusammengeführten Objekt sitzen /FT, /T, /V, /AS und /AP allesamt nebeneinander, sodass ein Widget-seitiger Lesevorgang von /V gelingt und der gesamte Bug unsichtbar bleibt
Sobald ein Feld zwei oder mehr Widgets besitzt, ist die Zusammenführung unmöglich, und §12.7.3.1 verlangt, dass die Widgets zu /Kids eines separaten Feld-Dictionary werden. Jede Radiogruppe ist konstruktionsbedingt in dieser Form. Ebenso Einwilligungs-Checkboxen, die in Kopf- und Fußzeile wiederholt werden, und jedes Feld, das ein Autorenwerkzeug auf eine zweite Seite kopiert hat. Das ist die vollständige Erklärung dafür, warum der Defekt eine Regressionssuite überlebte: Der Testkorpus war voller Single-Widget-Formulare, und die Kundendateien waren es nicht. Wer Widgets selbst durchläuft, statt sich auf die Komponente zu verlassen, dem begegnet dieselbe Asymmetrie in der Enumerationsreihenfolge, und die Notizen zur PDF-Formularfeld-Navigation mit PDFium Component behandeln, wie sich ein seitenweiser Annotations-Durchlauf zum dokumentweiten Feldbaum verhält
Den Wert so lesen, wie PDFium es beabsichtigt
FPDFAnnot_GetFormFieldValue ist die korrekte API, und sie war bereits eine Weile in der Komponente eingebunden, ohne dass der Checkbox-Pfad sie verwendete. Sie nimmt sowohl das Formular-Handle als auch die Annotation entgegen, was das entscheidende Signal ist: Mit verfügbarer Form-Fill-Umgebung löst PDFium die Annotation zu ihrem Formularsteuerelement auf und liest den Wert aus dem Feldobjekt, sodass sie für zusammengeführte wie geteilte Layouts gleichermaßen die richtige Antwort liefert
FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
begin
// /AP is prebuilt per state; only /AS has to be synchronised with /V.
// FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
// which is where ISO 32000-1 12.7.5.2 keeps the value.
buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
if buflen >= 4 then
begin
SetLength(OrigVal, buflen div 2 - 1);
FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
end;
end;
Zwei Details in diesem Ausschnitt macht man leicht falsch. Die zurückgegebene Länge ist eine Byte-Zahl für UTF-16-Text einschließlich Terminator, sodass die Zeichenzahl buflen div 2 - 1 ist und ein Wert von 2 einen leeren String bedeutet. Die Wächterbedingung buflen >= 4 bedeutet daher mindestens ein echtes Zeichen, was verhindert, dass bei einem Feld ganz ohne /V dessen /AS mit einem leeren Namen überschrieben wird
Worauf /AS und /AP /N sich tatsächlich einigen
Sie einigen sich auf einen Namen, und der Name wird von demjenigen gewählt, der die Datei erzeugt hat. §12.7.5.2 verlangt, dass der Off-Zustand /Off heißt, und überlässt den On-Zustand vollständig dem Erzeuger. /Yes ist eine Konvention, keine Regel. Acrobat schreibt /Yes, aber zahlreiche Generatoren schreiben /On, /1, /Choice1 oder ein lokalisiertes Wort, und eine Radiogruppe gibt normalerweise jedem Kind einen eigenen On-Zustand-Namen, damit die Gruppe ausdrücken kann, welcher Button gewählt ist. Genau deshalb ist es die richtige Operation, /V wörtlich in /AS zu kopieren, statt ein Workaround zu sein: Für ein angekreuztes Steuerelement meldet PDFium den On-Zustand-Namen, den die Datei selbst definiert, und für ein nicht angekreuztes meldet es Off, sodass der in /AS geschriebene Wert garantiert ein Schlüssel ist, der in diesem Widget-/AP-/N-Sub-Dictionary existiert. /Yes fest zu verdrahten würde bei Acrobat-Ausgabe funktionieren und überall sonst still versagen
Reihenfolge der Operationen, und wo weiterhin Sorgfalt nötig ist
Die Sequenz ist fest und unnachgiebig: Form-Fill aktivieren, Werte zuweisen, Erscheinungen neu erzeugen, abflachen, dann speichern. Überspringt man den Neuerzeugungsschritt, findet FPDFPage_Flatten leere oder veraltete Erscheinungsstreams und backt sie klaglos ein, was ein stiller Datenverlust ist, kein Fehler-Return
Pdf.FileName := FormPath;
Pdf.FormFill := True; // required: FormHandle must exist
Pdf.Active := True;
Pdf.FormField[0] := 'On'; // writes /V only
Pdf.GenerateFormAppearances; // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
Pdf.SaveAs('consent-flat.pdf');
Zwei ehrliche Grenzen bleiben. Erstens schreibt die Synchronisierung den Feldwert in das /AS jedes Widgets dieses Feldes, was für Kontrollkästchen korrekt, für Radiogruppen aber nur eine Annäherung ist, deren Kinder jeweils ihren eigenen On-Zustand-Namen definieren; ein Kind, dessen /AP /N keinen zum geschriebenen /AS passenden Eintrag hat, hat unter §12.5.5 keine wählbare Erscheinung, sodass ein nicht ausgewählter Button zu nichts statt zu einem leeren Kreis abflachen kann. Eine Radiogruppe vor dem Abflachen mit FPDFAnnot_GetFormControlIndex zu prüfen ist die paar Zeilen wert. Zweitens gilt nichts davon für XFA, wo der Wert in einem XML-Datenpaket lebt statt in den AcroForm-Dictionaries, eine Trennung, die in den Notizen zu nicht persistierten XFA-Feldbearbeitungen behandelt wird. Die allgemeine Lehre lohnt sich, über diesen einen Fix hinaus zu behalten: Wann immer eine API zusätzlich zur Annotation das Formular-Handle entgegennimmt, teilt sie mit, dass sie die Feldhierarchie selbst auflösen wird, und wann immer sie nur die Annotation entgegennimmt, liest sie genau das übergebene Objekt. Diese Unterscheidung regiert auch den Datenaustausch, denn das Exportieren und Importieren von XFDF-Formulardaten arbeitet mit vollqualifizierten Feldnamen, nie mit Widget-Positionen
Formular-Abflachung ist eines jener Features, das wie ein einzelner API-Aufruf aussieht und sich als Vertrag zwischen drei Dictionaries entpuppt. Wer lieber gegen eine Komponente arbeitet, die diesen Vertrag bereits einkodiert, findet die hier beschriebene Erscheinungs-Neuerzeugung, das Abflachen und den Formularfeldzugriff als gewöhnliche Eigenschaften und Methoden in der PDFium Component für Delphi und C++Builder