PDFlibPas, die losLab PDF Developer Library für Delphi, schreibt jede Zahl, die sie in einen Content-Stream einträgt, mit Punkt als Dezimaltrenner und ohne Exponent, egal was die regionalen Windows-Einstellungen sagen. Seit v3.539.26 formatieren AddPageMatrix, ScalePage, DeskewPage, RedactRegion, Text-to-Path-Ausgabe und Umfärbung ihre Operanden durch PLDoubleToStrConst, und seit v3.539.33 lesen die Parser, die diese Zahlen zurücklesen, mit PLTryStrToFloatInvariant statt mit dem System-Locale. Auf einer deutschen, französischen oder brasilianischen Maschine erzeugt derselbe Code jetzt dieselben Bytes wie auf einer US-Maschine — das einzige Verhalten, das ein Dateiformat tolerieren kann
Warum korrumpiert ein Komma-Dezimal-Locale eine PDF ohne Fehlermeldung?
Ein Komma-Dezimal-Locale korrumpiert eine PDF lautlos, weil das Komma in der PDF-Syntax kein Zahlenzeichen ist — der Schaden liest sich also als gültige Tokens mit falscher Bedeutung. Vor dem Fix war PLFloatToStr nichts als ein nackter FloatToStr-Aufruf, und FloatToStr folgt FormatSettings.DecimalSeparator. Mit Komma als Trenner schrieb AddPageMatrix(0.5, 0.5, 0, 0) ein 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 erlaubt in einer Zahl Ziffern, einen Punkt und ein führendes Vorzeichen und nichts sonst, ein Content-Parser liest diese Zeile also als die Zahl 0 gefolgt von einem unbekannten Token ,5, und der cm-Operator landet bei falschen Operanden. Nichts wird geworfen, nichts loggt. Die Seite rendert schlicht mit einer weggedrifteten Transformationsmatrix, und von einer verschobenen Zeichnung rückwärts auf eine Locale-Einstellung zu schließen, ist ein elender Nachmittag
Der zweite Defekt versteckt sich hinter dem ersten. FloatToStr benutzt das Format ffGeneral, das ab einer Größenordnung unter 1E-4 auf Exponentialschreibweise umschaltet, ein winziger Offset kam also als 1E-5 heraus. Dasselbe §7.3.3 stellt fest, dass PDF die Exponentialform nicht unterstützt — selbst eine US-Locale-Maschine konnte bei kleinem genug Wert einen ungültigen Operanden schreiben. Die Regressionstests dieses Releases nageln beide Fehlerformen fest: Sie drehen den Trenner auf Komma, rufen die API auf und scannen den entstandenen Content nach jedem Token ab, das ein Komma oder einen Exponenten enthält
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
OldSeparator: Char;
begin
Lib := TPDFlib.Create;
try
Lib.SetPageDimensions(300, 200);
Lib.DrawBox(10, 10, 20, 20, 1);
OldSeparator := FormatSettings.DecimalSeparator;
try
FormatSettings.DecimalSeparator := ','; // einen de-DE-Desktop simulieren
Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
// v3.539.26 und später schreiben: 0.5 0 0 0.25 0.00001 12.75 cm
// ältere Builds schrieben: 0,5 0 0 0,25 1E-5 12,75 cm
Writeln(Lib.GetPageContentToString);
finally
FormatSettings.DecimalSeparator := OldSeparator;
end;
finally
Lib.Free;
end;
end;
Zwei Sorten Zahlen, zwei Familien von Helfern
Der Fix in PDFlibPas ist eine strenge Trennung: Zahlen für Menschen dürfen dem Locale folgen, Zahlen für eine Maschine tun es nie. PLFloatToStr und PLStrToFloat bleiben in PDFlibExtra.pas für benutzergerichteten Text, und ihre Deklaration trägt jetzt einen Kommentar, der genau das sagt. Alles, was als PDF-Syntax endet, läuft durch PLDoubleToStrConst mit einer festen Anzahl von Dezimalstellen, die für den Job gewählt ist: sechs für Matrizen, vier für Koordinaten und TJ-Adjustierungen, drei für Farben und FDF-Rechtecke. Die Auditierung für v3.539.26 berührte mehr Aufrufstellen, als der ursprüngliche Bugreport vermuten ließ:
AddPageMatrix,ScalePageundDeskewPage, die alle eincmvor den bestehenden Seitencontent setzen- Die Seitenelement-Builder, die
Tm-Resets,TJ-Advances undcm-Transformationen emittieren - Glyphenplatzierungsmatrizen und Outline-Punkte im Text-to-Path-Konverter
- Die schwarze Füllbox, die
RedactRegionvoranstellt, die/Rect-Werte im FDF-Export und die Operanden, die die Umfärbung schreibt
PLDoubleToStrConst ist ein handgestrickter Formatter statt eines Wrappers um FloatToStrF, und drei seiner Eigenschaften sind hier relevant. Er schreibt immer einen Punkt und stript nachlaufende Nullen, 0.5 bleibt also 0.5 statt 0.500000. Er schreibt für endliche Eingaben nie einen Exponenten. Und ein Nicht-Null-Wert unterhalb der angeforderten Präzision behält seine signifikanten Stellen, statt auf null zu kollabieren — PLDoubleToStrConst(1E-9, 6) liefert 0.000000001, erst Werte unter etwa 5E-16 werden zu 0. Die letzte Regel existiert, weil ein winziger Skalierungsfaktor, auf null gerundet, aus einer gültigen Matrix eine singuläre macht — ein schlimmerer Bug als der, der hier behoben wird
uses
PDFlibExtra;
procedure CheckNumberHelpers;
var
V: Double;
begin
// Maschinenausgabe: Punkt-Dezimalstelle, kein Exponent, Nullen am Ende gestrippt
Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235'); // behält 4 signifikante Stellen
Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');
// Maschineneingabe: weiches Scheitern statt EConvertError
Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
Assert(not PLTryStrToFloatInvariant('0,5', V)); // Content-Zahlen benutzen nie ein Komma
end;
Warum ist die Parseseite gefährlicher als die Schreibseite?
Die Parseseite ist gefährlicher, weil ein am Locale hängender Parser keine falsche Zahl produziert, sondern wirft. PLStrToFloat ruft StrToFloat auf, das eine EConvertError auslöst, wenn der Text nicht zum Systemtrenner passt. Auf einem Komma-Dezimal-System hieß das: RecolorPage brach in dem Moment ab, in dem sie auf einen ganz gewöhnlichen 0.5 g-Operator stieß — jede reale Seite scheiterte also, nicht nur exotische. RenderPageRegionToFile wies sein eigenes dokumentiertes Clip-Format "10.5,20.5,50.5,40.5" zurück, und SVG-Längenattribute, SVG-Exportfarben, Annotations-Vertexlisten und Output-Intent-Dichte-Werte wurden entweder verweigert oder lautlos durch Defaults ersetzt. Eine Bibliothek, die auf der Entwicklermaschine perfekt funktioniert und am ersten Kunden in München scheitert, ist genau die Sorte Code, die — wie die Fälle in dem Artikel über Delphi-Code, der nur zufällig funktioniert — allein deshalb korrekt aussieht, weil dort getestet wurde, wo sie zufällig lief
v3.539.33 hat jeden StrToFloat- und TryStrToFloat-Aufruf danach klassifiziert, woher seine Eingabe kommt. Content-Stream-Operanden, SVG-Attribute, Painter-Farbstrings und kommagetrennte Clip- und Vertexlisten haben alle eine feste Punkt-Syntax, sie laufen also jetzt durch PLTryStrToFloatInvariant, das den Text trimmt, mit PLInvariantFormatSettings parst und für leere, missratene oder nicht endliche Eingaben False liefert, statt zu werfen. Eine kommagetrennte Liste lässt keinen Kompromiss zu, denn ein Komma kann nicht gleichzeitig Listendelimiter und Dezimalzeichen sein. Derselbe Durchlauf fixte auch einen Out-of-Bounds-Write: RenderPageRegionToFile speicherte früher einen fünften Clip-Wert hinter seinem Vier-Element-Buffer. Für die in der Anleitung zum Konvertieren einer PDF in einen Farbraum beschriebene Umfärbe-Pipeline ist das praktische Ergebnis, dass RecolorPage und RecolorDocument auf einem Komma-Dezimal-System nicht mehr abbrechen. Regelwerte, die ein Aufrufer in CheckDocumentPolicy eintippt, sind der eine Parse-Fall, der stattdessen den nachsichtigen Helfer benutzt — aus dem Grund, den der nächste Abschnitt erklärt
Was passiert, wenn Sie nur ein Ende eines Roundtrips reparieren?
Repariert man nur ein Ende eines Locale-Roundtrips, bricht man Code, der funktioniert hat — deshalb hat die Strukturattribut-Änderung in v3.539.32 Writer und Reader zusammen verschoben. Die SetStructElem*-Wrapper reichen Zahlen als Strings weiter: SetStructElemBBox formatiert vier Werte in einen String, speichert ihn über AddTagAttribute, und der /A-Writer parst diesen String später, um zu entscheiden, ob er zu einer Zahl, einem Array oder einem Namen wird. Beide Enden benutzten das System-Locale, der Roundtrip war auf einem Komma-System also in sich konsistent. Der Bug zeigte sich erst, wenn ein Aufrufer der Dokumentation folgte und "0.5" an AddTagAttribute übergab: Der Reader konnte es nicht parsen und emittierte den PDF-Namen /0.5. Der PDF/VCR-Platzhalter hatte das spiegelbildliche Problem, denn die Bibliothek erzeugte GTS_BBox mit Punkt und validierte es vor dem Speichern mit dem Locale
Nur den Writer auf Punkt umzustellen wäre schlimmer als nichts zu tun gewesen, denn jeder SetStructElem*-Wert wäre dann an dem am Locale hängenden Reader gescheitert und zu einem Namen degradiert. Die Writer benutzen jetzt PLDoubleToStrConst(v, 6), und der Reader den neuen PLTryStrToFloatLenient, der zuerst die Punktform versucht und dann aufs System-Locale zurückfällt. Ein Komma-Locale-Aufrufer, der früher "1,25" übergab, bekommt weiterhin die Zahl 1.25. Der Trade-off ist bewusst und dokumentiert: Auf einem deutschen System wurde "1.500" früher zu einem Namen, weil StrToFloat Tausendertrenner ablehnt, und liest sich jetzt als 1.5, während die Literal-Strings NAN und INF nicht länger als Zahlen akzeptiert werden
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
begin
FormatSettings.DecimalSeparator := ','; // Komma-Dezimal-Aufrufer
Lib := TPDFlib.Create;
try
Lib.BeginTag('Figure', 'Sales chart', '');
Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5'); // /SpaceAfter 0.5, früher /0.5
Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // weiterhin /StartIndent 1.25
Lib.DrawText(20, 20, 'figure');
Lib.EndTag;
Lib.SaveToFile('tagged.pdf');
finally
Lib.Free;
end;
end;
Wo NaN und Unendlich gestoppt werden
AddPageMatrix, ScalePage und RedactRegion weisen NaN und unendliche Argumente jetzt vorne ab und geben 0 zurück, denn keine PDF-Zahl kann sie darstellen. ScalePage verweigerte Faktoren von null oder darunter bereits, aber NaN besteht einen <= 0-Test, ein NaN-Skalierungsfaktor reiste also bis zum Formatter durch. In v3.539.26 rief dieser Formatter bei NaN noch Round auf, was unter Win32 ein EInvalidOp auslöst, weil die x87-Einheit ungültige Operationen nicht maskiert; v3.539.31 brachte PLDoubleToStrConst dazu, für NaN eine 0 zu schreiben als letzte Verteidigungslinie, aber eine Null in einer Matrix ist eine singuläre Transformation, die API-Prüfung bleibt also der eigentliche Fix. Zwei Grenzfälle bleiben absichtlich, wie sie sind. Metafile-State-Strings werden innerhalb eines Prozesses mit dem Locale geschrieben und gelesen und verlassen ihn nie, sie blieben also unangetastet. Und ein Test, der 1E-5 durch den Seitenelement-Pfad formatiert, muss den Content lesen, bevor die Ebene neu geschrieben wird, denn das erneute Emittieren der Operanden in Dokumentpräzision verwandelt diesen Wert legitim in 0
Verschickt Ihre Anwendung an Kunden außerhalb der Punkt-Dezimal-Welt, ist die sicherste Gewohnheit die, die die PDFlibPas-Testsuite inzwischen benutzt: die PDF-erzeugenden Pfade einmal mit einem auf Komma gesetzten FormatSettings.DecimalSeparator fahren und die Ausgabe nach Kommas und Exponenten absuchen. Der Artikel zum Bewahren geparster Dezimalpräzision behandelt die andere Hälfte derselben Geschichte, wie Zahlen aus einer bestehenden Datei beim Speichern ihren exakten Text behalten. Downloads, die vollständige API-Referenz und der Trial-Build stehen auf der PDFlibPas Delphi PDF library Produktseite