Jede reelle Zahl, die in eine PDF-Datei gelangt, muss mit einem Punkt geschrieben werden, egal was das Betriebssystem für den Dezimaltrenner hält. Die HotPDF Delphi Component leitet alle durch eine invariante Konvertierungsschicht in HPDFTypes, sodass eine deutsche, französische oder brasilianische Workstation 0.5 0 0 0.5 5.5 7.25 cm emittiert und nicht die kommaseparierte Variante, die kein konformer Reader parst
Das klingt nach einem Zwei-Zeilen-Fix. War es nicht. Der Defekt war jahrelang von einem Nebeneffekt getarnt, tauchte in drei Subsystemen wieder auf, die niemand als Zahlenformatierungscode betrachtet, verschwand unter einem Compiler und überlebte unter einem anderen – und die letzte Schicht hatte mit dem Dezimaltrenner gar nichts zu tun
Warum zerstört eine Dezimalkomma-Locale einen PDF-Content-Stream?
Weil ISO 32000-1 §7.3.3 ein numerisches Objekt als optionales Vorzeichen, Dezimalziffern und höchstens einen Punkt definiert. Ein Komma ist kein Teil dieser Produktion, und nach §7.2.2 auch kein Trennzeichen, daher sieht der Reader nicht zwei Operanden, wo Sie einen gemeint haben. Er sieht ein Token, das nicht lexen will, und der Operator, der es enthielt, wird verworfen. Ein einziges 0,5 in einer Grafikzustandsmatrix nimmt den ganzen q ... Q-Block drumherum mit. Der Fehler hat im Feld auch eine charakteristische Form: Er reproduziert nie auf der Entwicklermaschine, kommt als Support-Ticket aus einem einzigen Land und liest sich wie ein Rendering-Bug statt wie ein Syntaxfehler, denn die meisten Viewer verwerfen den kaputten Operator stillschweigend und rendern den Rest der Seite
// Hostprozess läuft mit DecimalSeparator = ','
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);
// Was das angehängte /Contents zuvor enthielt:
// 0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
// q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// ISO 32000-1 7.3.3 hat keine Produktion für "0,5", daher verschwinden
// das Redaktionsrechteck und die gestitchte Seite beide ohne Fehler
Der Nebeneffekt, der den Defekt jahrelang versteckte
HotPDF schreibt die meisten Zahlen des Erzeugungspfads über _CutFloat, und frühe Revisionen dieser Funktion taten etwas still Feindseliges: Bei jedem einzelnen Aufruf wies sie dem globalen FormatSettings.DecimalSeparator des Hostprozesses '.' zu. Das korrumpiert den Locale-Zustand der Anwendung, die die Komponente hostet, und es ist nicht thread-sicher – aber die interessante Konsequenz ist die dritte. Sobald irgendein Inhalt erzeugt war, war der Prozess-Trenner ein Punkt, daher wurde jedes nackte FloatToStr und StrToFloat sonst in der Bibliothek zufällig korrekt. Die Erzeugen-dann-Bearbeiten-Reihenfolge, die fast jedes Beispiel nutzt, bestand daher – und was sie maskierte, war jeder Pfad, der vor dem ersten _CutFloat-Aufruf läuft: die Loaded-Document-Editoren StitchLoadedPage und RedactLoadedRect, die Text- und Bildstempel, zusammengesetzte Wasserzeichen, Barcode-Zeichnung über Format('%.2f'), der /DA-Default-Appearance-Parser und das gesamte Type-1-Schriftsubsystem
Type-1-Parsing scheiterte am hässlichsten von allen. FontMatrix, BlueValues, Stem-Hints und die reellen Zahlen in Type-2-Charstrings sind PostScript-Reals mit Punkten, laut Adobe-Type-1-Font-Format-Spezifikation. 0.001 unter einer Komma-Locale zu parsen wirft EConvertError, ein umschließendes try/except schluckte es, und die Font-Matrix blieb bei null. Jede Glyphe aus dieser Schrift kollabierte dann – was wie ein Font-Problem aussieht statt wie ein Locale-Problem und Sie für einen Nachmittag zum Debuggen des Renderns eingebetteter Glyphen schickt
Eine Konvertierungsschicht statt verstreuter Flicken
Die Regel, der HotPDF jetzt folgt, ist schlicht: Jede Zahl, die in PDF-, PostScript-, JSON- oder JavaScript-Syntax geschrieben oder daraus gelesen wird, läuft durch eine explizite invariante Konvertierung, und kein Code hängt irgendwo von einem globalen FormatSettings-Nebeneffekt ab. HPDFTypes besitzt die vier Konvertierungen plus den Settings-Record, den sie teilen, und einen Festkomma-Helfer für den Barcode-Pfad
// Lib/HPDFTypes.pas - die einzige invariante Konvertierungsoberfläche
function HPDFInvariantFormatSettings: TFormatSettings;
function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
const Default: Extended): Extended;
function HPDFFormatInvariant2(Value: Extended): string;
Zwei Units trugen bereits eine eigene private InvariantFormatSettings-Funktion, HPDFContentStream und HPDFTextExtraction, und beide wurden auf einen einzeiligen Forward reduziert statt als Parallelimplementierungen stehen gelassen. Das Type-1-Subsystem ist die bewusste Ausnahme: Es trägt keine VCL-Abhängigkeit und referenziert HPDFTypes nicht, daher behält es einen lokalen Spiegel namens HPDFType1Core.Type1StrToFloat mit identischer Semantik – nach der Theorie, dass eine duplizierte Zehn-Zeilen-Funktion eine kleinere Last ist, als die Grafikschicht in einen Font-Parser zu schleppen. Zwei andere Stellen ließ man mit Absicht in Ruhe. HPDFHTMLImport parst CSS-Längen, indem es bewusst den Punkt gegen den Locale-Trenner tauscht und dann mit der Locale parst – richtig für diese Eingabe –, und HPDFType1CB.GlyphCommands samt den FontBBox-Zeichenketten ist öffentliche API, die Aufrufer mit ihrem eigenen StrToFloat zurücklesen, wo Erzeugen und Konsumieren in derselben Locale selbstkonsistent ist und invariante Ausgabe jeden Aufrufer in einer Komma-Region brechen würde
Was garantiert HPDF_HAS_FORMAT_SETTINGS über Compiler hinweg?
Es garantiert, dass der invariante Pfad in jeder unterstützten Toolchain wirklich kompiliert – was das naheliegende Versionsgate nicht tat. Delphi XE2 führte TFormatSettings.Create und die FloatToStr-/TryStrToFloat-Überladungen mit TFormatSettings ein, daher ist der erste Instinkt, an jeder Aufrufstelle {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} zu schreiben. Dieser Instinkt hat einen blinden Fleck: HotPDF.inc definiert unter Free Pascal D2009+, aber nie XE2+, denn XE2+ beantwortet eine Frage nach Delphi-Versionsnummern, während Free Pascal 3.2.2 diese Überladungen unabhängig davon mitbringt. Jedes dieser Gates degradierte daher in FPC-Builds stillschweigend zum Locale-abhängigen Zweig – genau auf den Plattformen, von denen die ursprünglichen Meldungen kamen. HPDF_HAS_FORMAT_SETTINGS ist sowohl im FPC-Block als auch im XE2+-Block von HotPDF.inc definiert, und es ist das einzige Symbol, das die Zahlenhelfer testen
{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
Result := DefaultFormatSettings; // kein TFormatSettings.Create in FPC
{$ELSE}
Result := TFormatSettings.Create;
{$ENDIF}
Result.DecimalSeparator := '.';
Result.ThousandSeparator := ',';
end;
{$ENDIF}
Ein Compiler-Zweig überlebt in der Funktion, denn Free Pascal hat kein TFormatSettings.Create, und man muss von DefaultFormatSettings ausgehen. Nur die beiden Zahlentrenner speisen die Konvertierungen, daher behalten Datums- und Währungsfelder das, was die aktuelle Locale liefert. Pre-XE2-Delphi hat gar keine Überladungen, und dort weichen die Helfer darauf aus, mit der Prozess-Locale zu konvertieren und danach das Trennzeichen im Ergebnisstring umzuschreiben – hässlich, aber es fasst nie globalen Zustand an
Die dritte Schicht: Exponenten, die SVG erlaubt und PDF nicht
Den Dezimaltrenner zu fixen ist notwendig und nicht hinreichend. W3C-SVG-1.1 §4.2 lässt in seiner Zahlenproduktion einen Exponenten zu, und XPS-Markup tut dasselbe, daher ist 1E-6 auf dem Weg hinein eine völlig gültige Koordinate, während ISO 32000-1 §7.3.3 gar keinen Exponenten zulässt. Ein Wert, der die invariante Formatierung übersteht, kann trotzdem als 1E-6 in einem Content-Stream landen und abgelehnt werden, und FloatToStrF im Festformat kann selbst auf Exponentenform zurückfallen, sobald der Wert den Ziffernbereich der RTL verlässt. HPDFSafeSVG.SVGFloat macht deshalb nach der invarianten Konvertierung eigene Arbeit: Es flusht alles unter 1.0E-7 auf null, entfernt dann – wenn ein E übrig bleibt – den Exponenten und verschiebt den Dezimalpunkt von Hand, mit Nullen-Auffüllung auf der Seite, die die Verschiebung verlangt. SVGFinite bewacht den Eingang, indem es NaN und alles außerhalb von ±1.0E30 ablehnt, bevor es den Formatter erreicht, und SVGReadNumber mit SVGSkipSeparators erledigt die eingehende Richtung zeichenweise statt an die RTL zu delegieren
// Lib/HPDFSafeSVG.pas - Dezimalnotation für PDF-Operanden
if Abs(Value) < 0.0000001 then
Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
SetLength(S, ExponentAt - 1);
// ... den Dezimalpunkt um ExponentValue verschieben, links oder rechts
// mit '0' auffüllen, dann das Vorzeichen wieder anbringen
end;
Das ist derselbe TSVGBuffer, der den SVG-Seitenexport und seine Grafikzustandsbehandlung speist, und dieselbe Zahlendisziplin gilt auf der Importseite beim Konvertieren von XPS- und OXPS-Dokumenten nach PDF über HPDFCreateXPSDocumentHandler und das hdfXPS-Mitglied von THPDFHandledDocumentFormat. Markup-Formate sind großzügig bei der Zahlsyntax. Der PDF-Content-Stream ist es nicht, und die Konvertierungsgrenze ist der Ort, an dem dieser Unterschied aufgelöst werden muss
Regressionstests, die die Locale reproduzieren statt sie anzunehmen
Ein Locale-Bug, der nur auf dem Rechner eines anderen auftritt, verdient Tests, die die Locale selbst setzen – und HotPDF trägt zwei davon. LoadedEditsEmitInvariantNumbersUnderCommaLocale weist vor der ersten Bearbeitung FormatSettings.DecimalSeparator ein Komma zu, führt RedactLoadedRect, StampLoadedPageText, StitchLoadedPage und einen zusammengesetzten Stempel aus, liest dann die Seiten-Content-Bytes zurück und assertet beides: dass die exakten Operatorstrings vorhanden sind und dass der Puffer nirgendwo ein Komma enthält. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale deckt die andere Hälfte und die Erbsünde ab: Es treibt den Erzeugungspfad unter derselben Komma-Locale, einschließlich DirectDrawBarcode mit seiner festen Zwei-Dezimalen-Formatierung, und assertet dann, dass der Content-Stream kein Komma trägt und dass FormatSettings.DecimalSeparator beim Rückkehr von EndDoc immer noch ein Komma ist
Genau diese zweite Assertion hätte den ursprünglichen Defekt am Tag seiner Entstehung gefangen, denn sie testet, was die Bibliothek ihrem Host antut, nicht nur, was sie auf die Platte schreibt. Die Lektion verallgemeinert über PDF hinaus: Jedes Format mit fester lexikalischer Grammatik – PostScript, JSON und JavaScript eingeschlossen – braucht eine explizite invariante Konvertierung an der Grenze. Nach einem globalen Setting zu greifen, um die richtige Antwort zu bekommen, funktioniert, bis jemand Ihren Code in anderer Reihenfolge, auf einem anderen Thread oder von einem anderen Compiler aus aufruft – und dann sieht der Fehler nach allem aus, nur nicht nach einem Formatierungsproblem
Die hier beschriebene Zahlenformatierungsschicht wird als Teil der HotPDF Delphi Component für Delphi und C++Builder ausgeliefert, zusammen mit den Loaded-Document-Edit-, Barcode-, SVG-Export- und XPS-Konvertierungspfaden, die davon abhängen