Technischer Artikel

Geparste PDF-Dezimalpräzision beim Save in Delphi bewahren

PDFlibPas, die losLab PDF Developer Library, behält den exakten Dezimaltext, den sie für jede reelle Zahl in einem Dokument geparst hat, und schreibt diesen Text wörtlich zurück, solange der Wert nie verändert wurde. Seit v3.539.19 regelt die Einstellung SetPrecision nur Zahlen, die die Bibliothek selbst erzeugt oder bearbeitet, sodass ein gewöhnliches Laden-und-Speichern ein CalRGB-/Gamma von 2.22221 nicht mehr auf 2.2222 abrundet und die Farben einer Seite verschiebt, die niemand angefasst hat. Die Änderung ist im Code klein und in dem, was sie über Parser sagt, groß: Der Wert, den Sie dekodieren, und das Literal, das Sie emittieren, sind zwei verschiedene Dinge, und ein Double-Round-Trip ist keine Identitätstransformation

Warum verschob ein Save, der nichts änderte, die Seitenfarben?

Weil die Farbraum-Parameter neu formatiert wurden, nicht das Bild. Die Datei, die das offenlegte, ist ein 35-seitiges Office-Dokument im lokalen Regression-Korpus, mit einem Kopfzeilen-Bild, das auf jeder Seite wiederverwendet wird. Es zu laden und direkt zurückzuspeichern produzierte Bildstreams, die byte für byte identisch mit der Eingabe waren, und ein Stream-Hash-Vergleich meldete das Dokument als unverändert. Ein gerenderter Vergleich widersprach: Alle 35 Seiten zeigten Pixelunterschiede in der Kopfzeile und sonst nirgends

Das Kopfzeilen-Bild zeichnet durch einen CalRGB-Farbraum, den ISO 32000-1 §8.6.5.3 über einen /WhitePoint, ein optionales dreielementiges /Gamma-Array und eine optionale neunelementige /Matrix definiert. Diese Arrays sind schlichte numerische Objekte im Farbraum-Dictionary. TPDFNumeric speicherte jedes davon als Double und nichts weiter, und TPDFNumeric.Output formatierte diesen Double durch PDFPrecNum, das standardmäßig vier Dezimalstellen nutzt. /Gamma ging also von 2.22221 auf 2.2222, ein Matrix-Eintrag von 0.71519 auf 0.7152, und der Renderer produzierte treu leicht andere Farben aus leicht anderer Kalibrierung. Die Bild-Bytes waren unschuldig; die Zahlen um sie herum nicht. Das Unangenehme ist, wie unsichtbar das alles war. Dekodierte Stream-Bytes zu vergleichen sieht es nicht, denn die Zahlen leben in einem Dictionary, nicht in einem Stream. Anhang-Nutzlasten zu vergleichen sieht es nicht. Sogar der Revisions-Diff aus dem Artikel zu Modification Levels fingerprintet einen normalisierten Objektkörper, also hashen beide Revisionen auf denselben Wert, und der Diff meldet sie als identisch. Nur das Rendering hat es erwischt – deshalb rendert die Korpus-Baseline jede Seite, statt sich allein auf strukturelle Prüfungen zu verlassen

Wo PDFlibPas bei einem No-op-Save in Delphi CalRGB-Präzision verlor: Das geparste /Gamma 2.22221 und ein 0.71519-Matrix-Eintrag leben in TPDFNumeric als Double, Output formatiert sie durch PLDoubleToStr mit PDFPrecNum auf vier Dezimalstellen, jede strukturelle Prüfung meldet das Dokument als unverändert, und nur der gerenderte Vergleich zeigt alle 35 Kopfzeilen-Bilder verschoben
Die Bild-Bytes waren unschuldig: TPDFNumeric formatierte die Kalibrierungszahlen um sie herum durch PDFPrecNum neu, also meldeten Stream-Hashes und der Fingerprint-Diff beide identische Revisionen, während der Renderer auf jeder Seite leicht andere Farben produzierte

Der Wert, den Sie geparst haben, ist nicht das Literal, das Sie schreiben sollten

Eine reelle PDF-Zahl ist eine Dezimalzeichenkette, und ISO 32000-1 §7.3.3 stellt ausdrücklich klar, dass sie nur eine Dezimalzeichenkette ist: keine Radix-Notation, keine Exponentenform. Annex C listet dann die Präzision auf, die eine Implementierung einzuhalten hat, etwa fünf signifikante Dezimalstellen im Bruchteil. Eine Default-Output-Präzision von vier liegt bereits darunter, und nahe null wird es schlimmer: PLDoubleToStr skaliert den Wert, rundet auf eine Ganzzahl und emittiert 0, wenn das Ergebnis null ist, also verliert ein Matrix-Eintrag von -0.000012345 nicht eine Stelle, er verschwindet vollständig

Den Default anzuheben würde die Klippe nur verschieben. Der Fix besteht darin, nicht mehr sozutun, ein Double sei die Zahl. Wenn der Tokenizer in TPDFStructure.Decode eine Standardrealzahl erkennt – das Token enthält einen Dezimalpunkt und keinen Exponentenmarker –, speichert er den Quelltext im neuen Feld FOriginalText neben dem konvertierten Wert. Output bevorzugt dann diesen Text und fällt nur auf Formatierung zurück, wenn es nichts zu Bevorzugendes gibt

Wie PDFlibPas geparsten Dezimaltext in Delphi bewahrt: Der Tokenizer in TPDFStructure.Decode hält das Quellliteral in FOriginalText für jedes Token mit Dezimalpunkt und ohne Exponenten, Output schreibt diesen Text wörtlich statt PLDoubleToStr aufzurufen, SetTo löscht ihn, denn eine bearbeitete Zahl ist eine neue Zahl
Der Wert, den Sie dekodieren, und das Literal, das Sie emittieren, sind zwei verschiedene Dinge: Der bevorzugte geparste Text hält 2.22221 exakt, während bibliothekseigene und bearbeitete Zahlen weiterhin PDFPrecNum folgen und die Einstellung unberührte Eingabe nie erreicht
// Lib/PDFlibStruct.pas — der komplette Fix auf der Output-Seite
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // eine bearbeitete Zahl ist eine neue Zahl
  FValue:= Value;
  FChanged:= True;
End;

Zwei Grenzen sind beabsichtigt. Integer werden nicht bewahrt, denn Integer-Formatierung ist bereits verlustfrei. Exponentenformen wie 6.02E23 werden auf der Eingabeseite kaputter Producer zuliebe toleriert, aber auf der Ausgabeseite nicht bewahrt, denn sie zurückzuschreiben würde eine Syntax perpetuieren, die §7.3.3 verbietet; sie laufen wie jede bibliothekserzeugte Zahl durch den Formatter. Der Tokenizer wendet außerdem seine übliche minimale Reparatur an, bevor er den Text speichert, also bleibt ein Literal mit führendem Punkt wie .5 als 0.5 erhalten und ein Literal mit abschließendem Punkt wie 5. als 5.0. Beide sind für jeden Reader dieselbe Zahl und werden weit breiter akzeptiert

Was garantiert SetPrecision nach v3.539.19?

TPDFlib.SetPrecision steuert jetzt die Dezimalstellen der Zahlen, die die Bibliothek selbst produziert: durch den Painter gezeichnete Werte, aus einem Double erzeugte Zahlen wie über NewNumeric und jeden geparsten Wert, der seitdem mit SetTo bearbeitet wurde. Beachten Sie, dass Text, der über die Objekt-API dekodiert wird, etwa ein an SetObjectFromString übergebenes Literal, durch denselben Tokenizer läuft und genauso bewahrt wird. Ein geparster, nie veränderter Dezimalwert behält seine Eingabepräzision unabhängig von der Einstellung, und die Einstellung nach dem Laden zu ändern fasst ihn nicht rückwirkend an. Der Referenzeintrag zu SetPrecision wurde im selben Release genau daraufhin aktualisiert, weil die alte Formulierung nahelegte, die Einstellung gelte für jede Zahl in der Datei

Das Leeren passiert in SetTo, statt aus dem Changed-Flag abgeleitet zu werden, und dieser Unterschied hat Gewicht. Die Save-Pipeline setzt Changed an Objekten zurück, sobald sie geschrieben sind, also würde eine Prüfung der Form „emittiere den Originaltext, sofern nicht geändert“ anfangen, veralteten Text für einen Wert zu emittieren, der bearbeitet, gespeichert und in derselben Sitzung erneut bearbeitet wurde. Den Originaltext an die Zuweisung selbst zu koppeln macht es unmöglich, dass beide auseinanderlaufen. Der Regressionstest nagelt jedes dieser Verhalten mit den Werten aus der Originaldatei fest

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // Unbearbeitete Eingabe überlebt wörtlich, einschließlich des Werts,
    // den Vier-Stellen-Formatierung auf 0 hätte zusammenfallen lassen
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Eine Bearbeitung verwirft den Originaltext und folgt PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Die Präzision danach zu senken erreicht unbearbeitete Eingabe nicht
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Warum normalisiert das Content-Modell Zahlen weiterhin?

Weil TPDFContentProgram kanonische numerische Operanden verspricht, und dieses Versprechen mehr wert ist als wörtlicher Text in einem Content-Stream. Das editierbare Content-Modell, dasselbe, auf dem der Graphics-State-Tracker aufbaut, existiert dafür, dass NormalizeContentStreams, der Optimizer und Emit aus beliebiger Eingabe eine stabile, vergleichbare Ausgabe produzieren. Trüge ein geparster Operand seinen Originaltext ins Modell, würde eine Operatorsequenz wie 0.50000 0 0 RG anders emittiert als 0.5 0 0 RG, und jeder nachgelagerte Vergleich würde mit den Formatierungsgewohnheiten des Producers driften

Das Modell streift daher den Originaltext an seinen zwei Einstiegspunkten. NormalizeContentNumbers läuft über jeden Operanden, sobald der Parser ihn schiebt, und erneut in SetOperand, wenn vom Aufrufer gelieferter Quelltext dekodiert wird, und es rekursiert durch Arrays und Dictionaries, sodass Strichmuster, TJ-Arrays und die Property-Dictionarys von Marked Content abgedeckt sind. Auf jeder Zahl SetTo(AsDouble) aufzurufen genügt, denn das ist exakt die Operation, die den Text löscht. Rohe Inline-Bilddaten bleiben unangetastet, wie immer

Warum das PDFlibPas-Content-Modell Zahlen weiterhin normalisiert: NormalizeContentNumbers läuft, wo der Parser jeden Operanden schiebt, und erneut in SetOperand, rekursiert durch Arrays und Dictionaries, sodass Strichmuster, TJ-Arrays und Marked-Content-Property-Dictionarys abgedeckt sind, und SetTo AsDouble löscht den Originaltext, sodass 0.50000 und 0.5 identisch emittieren
Kanonische numerische Operanden sind das Versprechen des Content-Modells: Rohe Inline-Bilddaten bleiben unangetastet, und unberührte Dictionary-Zahlen außerhalb von Content-Streams behalten die Wörtlich-Garantie, sodass ein schlichtes LoadFromFile-und-SaveToFile-Paar sie weiterhin bewahrt
// Lib/PDFlibContentModel.pas — das Content-Modell hält seinen Kontrakt
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

Die praktische Regel für Aufrufer ist also simpel. Ein schlichtes LoadFromFile gefolgt von SaveToFile lässt unberührte Content-Streams und unberührte Dictionary-Zahlen, wie sie waren. Eine Seite, die durch NormalizeContentStreams geht, oder jede über das Content-Modell gemachte Bearbeitung kommt konstruktionsbedingt kanonisch heraus, und der Rest des Dokuments bleibt bewahrt. Das sind zwei verschiedene Anfragen, und sie tun jetzt zwei verschiedene Dinge

Was es kostet und wo die Garantie endet

Jedes TPDFNumeric trägt jetzt eine zusätzliche AnsiString-Referenz, und jeder geparste Dezimalwert hält seinen Quelltext für die Lebensdauer des Objekts am Leben. Bei einem Dokument mit Millionen reeller Zahlen ist das echter Speicher, und er gehört in jede Large-Document-Messung, statt weggewunken zu werden. Die Garantie ist außerdem auf das eigene Dokument einer Zahl beschränkt: Objekte zwischen Dokumenten zu kopieren oder Werte über die Objekt-API zu rekonstruieren produziert neue Zahlen, die wie jede andere neue Zahl der Output-Präzision folgen. Es lohnt sich, präzise zu sein, was das Release behauptet und was nicht. Ein Laden-und-Speichern eines unberührten Dokuments bewahrt jetzt die Kalibrierungszahlen, die der Renderer tatsächlich konsumiert – die Eigenschaft, die die Korpus-Baseline prüft. Es behauptet keine byte-identische Ausgabe, die auch von der Objektnummerierung, der Stream-Kompression und der Trailer-ID abhängt, über die der Artikel zur deterministischen PDF-ID spricht. Und es bringt den Fingerprint-Diff nicht dazu, Rundungsdifferenzen in Dateien anderer Software zu sehen, denn die hashen weiterhin den normalisierten Körper. Die Lehre generalisiert weit über CalRGB hinaus: Behält ein Parser nur den konvertierten Wert, ist jeder Save eine Bearbeitung, und der einzige Weg, es zu bemerken, ist, sich das gerenderte Ergebnis anzusehen. Die Zahlenbehandlung und die SetPrecision-Semantik sind auf der Produktseite der losLab PDF Developer Library dokumentiert