Technischer Artikel

Warum ein Single AcroForm-Field-Flags in Delphi korrumpiert

HotPDF Delphi Component speichert jede PDF-Zahl in THPDFNumericObject.Value, das als Single deklariert ist, und ein Single kann nicht jede 32-Bit-Ganzzahl halten. AcroForm-Field-Flags (/Ff) sind eine 32-Bit-Maske, also wurde jede Maske mit gesetztem High-Bit beim Laden gerundet: $8000FFFF kam als $80010000 zurück. Seit v2.989.5 parst das Lesen der Field-Flags das ursprüngliche Integer-Token exakt und kopiert seine Bits in die bestehende Integer-API

Der Bug ist einen genauen Blick wert, selbst wenn Sie nie PDF-Formulare anfassen. Nichts stürzte ab, keine Exception wurde ausgelöst, und die meisten Testdateien bestanden, weil die meisten echten Masken klein sind. Der Fehlschlag erschien erst, wenn ein High-Bit und die niedrigen Standard-Flags zusammen auftraten, und dann verlor er nicht ein Bit, sondern schrieb gleich mehrere davon um. Eine Bitmaske in einem Fließkommatyp zu speichern ist eine klassische Delphi-Falle, und so sieht sie in Produktionscode aus

Warum verliert ein Single AcroForm-Field-Flag-Bits?

Ein Delphi-Single hat eine 24-Bit-Mantisse, also stellt er jede Ganzzahl bis 2^24 (16777216) exakt dar und darüber hinaus nichts mehr ohne Lücken. Zwischen 2^31 und 2^32 liegen aufeinanderfolgende Single-Werte 256 auseinander. Eine Field-Flag-Maske mit gesetztem Bit 32 liegt exakt in diesem Bereich, also werden ihre unteren acht Bits, oft noch mehr, auf das nächste Vielfache von 256 gerundet

HotPDF-AcroForm-Field-Flags-Rundungsdiagramm für Delphi: die 32-Bit-/Ff-Maske $8000FFFF, 2147549183, läuft durch einen Single mit 24-Bit-Mantisse und kommt als $80010000 zurück, weil darstellbare Single-Werte zwischen 2^31 und 2^32 im Abstand von 256 liegen
Oberhalb von 2^31 kann ein Delphi-Single nur Vielfache von 256 speichern, sodass Round-to-Nearest das untere Byte einer 32-Bit-Field-Flag-Maske ohne jede Exception umschreibt

ISO 32000-1 §7.3.3 definiert Integer- und Real-Objekte als getrennte Zahlarten, und Anhang C listet für jede unterschiedliche Implementierungsgrenzen, also ist eine PDF-Ganzzahl nicht verpflichtet, in einen 32-Bit-Float zu passen. Der Field-Flags-Eintrag ist in Tabelle 221 als Integer definiert und wird bitweise gelesen, nicht als Größe. Eine Größe um einen winzigen Bruchteil zu runden ist harmlos; eine Maske zu runden trägt in Nachbarbits hinein. Den ganzen Effekt können Sie in ein paar Zeilen purem Delphi reproduzieren:

var
  S: Single;
  Mask: Int64;
begin
  S := $8000FFFF;              // 2147549183, als Single nicht darstellbar
  Mask := Round(S);            // 2147549184
  Writeln(IntToHex(Mask, 8));  // 80010000: Bits 1-16 gelöscht, Bit 17 gesetzt

  S := $80003FFF;
  Mask := Round(S);
  Writeln(IntToHex(Mask, 8));  // 80004000: Bits 1-14 gelöscht, Bit 15 gesetzt
end;

Die Lehre lässt sich über PDF hinaus verallgemeinern. Ein Single, ein Double, ein Variant mit einem Float oder eine in einen Float dekodierte JSON-Zahl ist der falsche Container für ein Bitfeld, selbst wenn die Werte, mit denen Sie zufällig testen, klein sind. Double schiebt den exakten Bereich auf 2^53, was das Problem für 32-Bit-Masken versteckt, aber dem Typ weiterhin die falsche Absicht lässt. Verwenden Sie Cardinal, Integer oder Int64 und konvertieren Sie aus dem Quelltext, nicht aus einem bereits gerundeten Float

Welche Field-Flags kippen: Radio, Pushbutton, Password und Multiline

Die beiden Masken oben sind nicht willkürlich; sie zeigen, wie ein einziger Rundungsschritt den Feldtyp ändert, den ein Reader sieht. PDF zählt Flag-Bits von 1, also hat Bit n den Wert 2^(n-1). ISO 32000-1 §12.7.3.1 Tabelle 226 definiert die Bits 1 bis 3 für jedes Feld, Tabelle 228 die Button-Flags, und Tabelle 230 die Textfeld-Flags

  • $8000FFFF zu $80010000: Bit 16 (Radio, $8000, Tabelle 228) wird gelöscht und Bit 17 (Pushbutton, $10000) gesetzt, ein Radiobutton wird also zum Pushbutton
  • Dieselbe Rundung löscht die Bits 1 bis 3, also verschwinden ReadOnly, Required und NoExport aus Tabelle 226 zusammen mit Radio
  • $80003FFF zu $80004000: Bit 13 (Multiline, $1000) und Bit 14 (Password, $2000) aus Tabelle 230 werden beide gelöscht, eine maskierte Passwortbox wird also ein einzeiliges Plaintext-Feld
  • Unterhalb von Bit 32 beginnt der Schaden bei 2^24: 16777219 (Comb plus ReadOnly und Required) rundet zu 16777220, was ReadOnly und Required fallen lässt und NoExport hinzufügt

Der letzte Fall war für HotPDF auch außerhalb der Formularbearbeitung relevant. ExportLoadedPagesStructured baut sein JSON- und XML-Formularmodell aus der effektiven Maske und zieht NoExport in seiner Entscheidung über Wertunterdrückung heran, sodass eine gerundete Maske einen gewöhnlichen Wert verstecken oder die falschen readOnly- und required-Member melden konnte. Der strukturierte Export-Pfad bekam dasselbe exakte Lesen in v2.989.8

HotPDF-AcroForm-Field-Flags-Bitmap für Delphi: die /Ff-Maske $8000FFFF mit gesetztem Bit 32 und den Bits 1 bis 16 verliert die Bits 1 bis 16 in einer einzigen Single-Rundung und gewinnt Pushbutton-Bit 17 und wird zu $80010000
Die Flags aus ISO 32000-1 Tabelle 226, 228 und 230 wandern als Gruppe: Radio kippt zu Pushbutton, während ReadOnly, Required, NoExport, Multiline und Password im selben Rundungsschritt verschwinden können

Wie HotPDF das exakte 32-Bit-Integer-Token liest

HotPDF Delphi Component repariert die Leseseite, ohne das numerische Objektmodell zu ändern. Der Parser merkt sich den ursprünglichen Tokentext bereits neben dem Single-Wert, also parst der Field-Flag-Reader dieses Token als Integer, akkumuliert ihn in einem begrenzten Int64 und kopiert die resultierenden 32 Bits in das Integer, das die öffentliche API zurückgibt. Generische numerische APIs und der alte Nicht-Integer-Fallback bleiben, wie sie waren, und nur Field-Flag-Konsumenten nehmen den exakten Pfad

HotPDFs exakter AcroForm-Field-Flag-Lesepfad in Delphi: der Parser behält das Quelltoken neben dem Single-Wert, TryParseFlagMask akkumuliert es in einem begrenzten Int64, und die 32 Bits erreichen GetLoadedFormFieldFlags und SetLoadedFormFieldFlags als Integer
Field-Flags parsen den gemerkten Tokentext statt des gerundeten Single, sodass beide Schreibweisen einer High-Bit-Maske, 2147549183 und -2147418113, mit identischen Bits ankommen

Der Parser muss beide Weisen akzeptieren, wie echte Dateien eine High-Bit-Maske schreiben. Manche Writer emittieren den vorzeichenlosen Wert 2147549183, der über der Integer-Grenze von 2^31 - 1 aus Anhang C liegt, aber in der Praxis häufig ist; andere emittieren den vorzeichenbehafteten Wert -2147418113. Beide beschreiben dieselben Bits. Eine Version des begrenzten Parsers sieht so aus:

function TryParseFlagMask(const Token: AnsiString; out Mask: Cardinal): Boolean;
var
  P, Digit: Integer;
  Negative: Boolean;
  Number, Limit: Int64;
begin
  Result := False;
  Mask := 0;
  if Token = '' then Exit;
  P := 1;
  Negative := Token[1] = '-';
  if Token[1] in ['+', '-'] then Inc(P);
  if P > Length(Token) then Exit;
  // Vorzeichenbehaftete Tokens bis -2^31, vorzeichenlose bis 2^32 - 1
  if Negative then Limit := 2147483648 else Limit := 4294967295;
  Number := 0;
  while P <= Length(Token) do
  begin
    if not (Token[P] in ['0'..'9']) then Exit;  // Reals nehmen den alten Fallback
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // begrenzt, kein Overflow
    Number := Number * 10 + Digit;
    Inc(P);
  end;
  if Negative and (Number <> 0) then Number := 4294967296 - Number;
  Mask := Cardinal(Number);
  Result := True;
end;

Zwei Details sind absichtlich so. Die Grenze wird vor jeder Multiplikation geprüft, also scheitert ein 400-stelliges Token sauber, statt Int64 überlaufen zu lassen. Und ein Token wie 3.0 wird abgewiesen, was den Aufrufer zurück zu Round(Value) schickt; der exakte Pfad verbessert Integer-Masken, ohne neue Semantik für fehlerhafte zu erfinden

Aus Anwendungscode heraus ändert sich an den Signaturen nichts. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer gibt die effektive Maske zurück und läuft die /Parent-Kette genauso ab, wie unter wie HotPDF geerbte AcroForm-Feldwerte auflöst und zurücksetzt beschrieben, und THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) schreibt eine vollständige lokale Maske. Eine Maske mit gesetztem Bit 32 ist schlicht ein negatives Integer, also bewegen Sie die Bits, statt den Wert zu konvertieren:

const
  FF_RADIO      = $00008000;  // Bit 16, ISO 32000-1 Tabelle 228
  FF_PUSHBUTTON = $00010000;  // Bit 17, Tabelle 228
var
  Pdf: THotPDF;
  I, Native: Integer;
  Bits: Cardinal;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    for I := 0 to Pdf.GetFormFieldCount - 1 do
    begin
      Native := Pdf.GetLoadedFormFieldFlags(I);
      Move(Native, Bits, SizeOf(Bits));   // dieselben 32 Bits, keine Konvertierung
      if (Bits and FF_RADIO) <> 0 then
        Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
    end;

    Bits := $80000002;                    // hohes Bit plus Required
    Move(Bits, Native, SizeOf(Native));   // Native = -2147483646
    Pdf.SetLoadedFormFieldFlags(0, Native);
    Pdf.SaveLoadedDocument('survey-out.pdf');
  finally
    Pdf.Free;
  end;
end;

Das Lesen einer geerbten Maske lässt das Child-Dictionary unberührt; das Schreiben ersetzt die ganze Maske durch einen lokalen Eintrag am gewählten Feld. Die Abkürzungen SetLoadedFormFieldReadOnly und SetLoadedFormFieldRequired starten von der effektiven geerbten Maske, schalten ein Bit um und halten jedes andere Bit exakt. Für das Füllen von Werten statt Flags siehe Formularfeldwerte in einem geladenen PDF mit HotPDF setzen

Warum die Reihenfolge von Value und RememberSourceToken zählt

Die Reihenfolge zählt, weil THPDFNumericObject seinem gemerkten Token nur vertraut, solange der Single-Wert unverändert ist. RememberSourceToken speichert das Token zusammen mit einem Snapshot des aktuellen Value, und der SourceToken-Getter liefert einen leeren String, sobald Value von diesem Snapshot abweicht. Diese Invalidierungsregel hält eine bearbeitete Zahl davon ab, mit ihrem veralteten Originaltext gespeichert zu werden

Auf der Schreibseite wird die Regel zur Falle. Merken Sie das Token zuerst und weisen den Wert danach zu, hält der Snapshot den alten Wert, der Getter meldet kein Token, und der Serializer fällt auf das Formatieren des gerundeten Single zurück. Jeder Flag-Writer in der Bibliothek, inklusive der Abkürzungen und der nativen Feldkonstruktoren, folgt derselben Sequenz:

// Richtig: der Snapshot passt zu Value, also überlebt das exakte Token das Speichern
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// Falsch: der Snapshot wird genommen, bevor sich Value ändert, SourceToken
// wird also ungültig und stattdessen der gerundete Single geschrieben
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

Weil SaveNumericObject das gemerkte Token schreibt, wann immer eines gültig ist, ist die richtige Reihenfolge genau das, was eine gespeicherte und wieder geöffnete Datei die ursprüngliche Ganzzahl tragen lässt, nicht eine gerundete Näherung davon

Das Null-Token, das zwei Felder einen Namen teilen ließ

Dasselbe Release reparierte einen zweiten Parserdefekt, der beim Testen dieser Masken aufflog: Ein null-Wert in einem aus dem Stream gelesenen Dictionary wurde nie konsumiert. HotPDF schaut sich das nächste Token an, um es zu klassifizieren, und diese Prüfung lässt die Stream-Position am Tokenanfang stehen. Der otNull-Zweig des Streaming-Dictionary-Readers erzeugte das Null-Objekt, lief aber nicht über die vier Bytes von null hinaus

Die Folge war kein Hänger, sondern ein stilles Fehllesen. Die nächste Schlüsselsuche scannte von der falschen Stelle vorwärts, lief über das Ende des aktuellen Dictionarys hinaus und las Schlüssel aus dem folgenden indirekten Objekt, das dann das aktuelle Feld überschrieb. In der Feldliste zeigte sich das Symptom als zwei verschiedene Felder, die denselben Namen und denselben Wert meldeten. Array- und Span-Parser konsumierten null bereits korrekt, also lässt der Fix in genau diesem einen Zweig den Stream um vier Bytes vorrücken, statt den allgemeinen Parser umzuschreiben. Ein Cursor, der sich nicht bewegt, gehört zur selben Bug-Familie wie die PDF-Parser-Cursor-Stalls, die HotPDF an fehlerhafter Eingabe aufhängten, nur mit anderem Symptom

Der Fix stellte auch die Vererbung klar. Ein lokales /Ff null oder eine Referenz, die sich zu null auflöst, wird als abwesend behandelt und die Suche läuft zum Parent weiter, während ein explizites 0 eine echte Maske ist, die den Parent überschreibt

Was eine {$R-}-Direktive beweist und was nicht

Die erste Hypothese zum Flag-Bug war ein Range-Check-Fehler, wenn eine große Maske an ein Integer zugewiesen wird. Sie war falsch, und der Grund ist ein Delphi-Detail, das es zu kennen lohnt: Eine lokale {$R-}-Direktive vor ParseSimpleDictionary in HPDFDoc.pas hatte keine passende Wiederherstellung, also kompilierte jede Funktion danach in der Unit mit abgeschalteten Range-Checks. Delphi setzt eine Schalter-Direktive am Ende einer Funktion nicht zurück, und eine Include-Datei erbt den Direktivenzustand an dem Punkt, an dem sie eingebunden wird

Ein Kommandozeilen--$R+ oder -Cr beweist also nur die Grundeinstellung der Unit. Es sagt nichts über eine bestimmte Funktion weiter unten. Um zu wissen, ob Range-Checking für eine bestimmte Routine aktiv ist, suchen Sie die nächste Direktive oberhalb, und bestätigen Sie das Verhalten dann mit einem echten Grenzwert. Die beiden älteren lokalen Flag-Konsumenten lösten nie die Range-Exception aus, die die Hypothese vorhersagte, sodass diese fehlende Exception nicht als Beweis einer Behebung gelten konnte. Der echte Defekt war die Single-Rundung quer über bekannte Flag-Bits

Das Exakt-Token-Lesen ist auch ehrlich bezüglich seines Umfangs. Es ändert den unitweiten Direktivenzustand nicht, weist reservierten Bits keine Bedeutung zu und behauptet nicht, dass jede Bitkombination ein gültiges Feld ist. Es garantiert, dass die 32 Bits in der Datei die 32 Bits sind, die Ihr Code sieht

FAQ

Warum nicht THPDFNumericObject.Value auf Double oder Int64 umstellen?

Den Typ eines öffentlichen Felds zu ändern, würde jeden Aufrufer brechen, der es liest oder zuweist, und PDF-Zahlen sind genauso oft Real wie Integer. Single zu behalten und die Field-Flags aus dem Original-Token zu lesen, behebt den Bug für Bitmasken, ohne eine bestehende Deklaration oder Aufrufkonvention zu ändern

Nimmt SetLoadedFormFieldFlags Masken mit gesetztem Bit 32 an?

Ja. Übergeben Sie die Maske als vorzeichenbehaftetes Integer; $80000002 und -2147483646 sind dieselben 32 Bits. GetLoadedFormFieldFlags liefert denselben Wert nach dem Speichern und erneuten Laden, Required eingeschlossen

Welche HotPDF-Version liest exakte AcroForm-Field-Flags?

Exakte Field-Flag-Lesevorgänge und der Null-Token-Fix kamen in HotPDF v2.989.5, und der strukturierte JSON- und XML-Export übernahm die exakte Maske in v2.989.8. Frühere Versionen runden jede Maske über 2^24 durch Single

Exakte AcroForm-Field-Flags, die geerbte Feldsuche und das Bearbeiten geladener Formulare sind Teil der HotPDF Component for Delphi and C++Builder, einer nativen PDF-Bibliothek zum Erstellen, Laden und Bearbeiten von PDF-Dokumenten