Technischer Artikel

Geparste PDF-Dezimalpräzision beim Save in Delphi bewahren

Ein kalibriertes PDF laden, nichts ändern, unter neuem Namen speichern und das Color-Space-Dictionary diffen: /Gamma [2.22221 2.22221 2.22221] ging hinein, /Gamma [2.2222 2.2222 2.2222] kam heraus. Der Writer hat eine Zahl umformatiert, die er nie anfassen musste, und die Seite rendert jetzt in leicht anderen Farben. Die Kur: jede geparste Dezimalzahl als zwei Dinge zugleich behandeln, als Wert für die Arithmetik und als Literal für die Ausgabe, und das Literal weiter ausgeben, bis jemand tatsächlich einen neuen Wert zuweist

Warum ein No-op-Save CalRGB-Farben verschieben kann

Die Pixel wandern, weil der Renderer leicht andere Kalibrierungsdaten bekommt, während die Bild-Bytes identisch bleiben. ISO 32000-1 §8.6.5.3 definiert einen CalRGB-Farbraum über ein Pflicht-/WhitePoint, ein optionales drei-elementiges /Gamma-Array und eine optionale neun-elementige /Matrix, und alle sind schlicht numerische Objekte im Color-Space-Dictionary. Schickt man sie durch einen Double und einen auf vier Dezimalstellen festgenagelten Formatter, wird aus 2.22221 ein 2.2222, und aus einem Matrix-Eintrag 0.71519 wird 0.7152. Ein Gamma-Wert ist ein Exponent, der über jede Komponente jedes Pixels gelegt wird, das durch diesen Raum läuft, daher zeigt die Änderung überall, wo der Raum benutzt wird

Deklariert eine Datei einen CalRGB-Raum für ein Kopfbild, das auf 35 Seiten wiederkehrt, rendern alle 35 Köpfe mit verschobenen Farben, und sonst ändert sich auf diesen Seiten nichts. Die Kontrollen, zu denen man zuerst greift, kommen sauber zurück. Ein Hash über dekodierte Stream-Bytes vergleicht Bilddaten, und die Bilddaten sind wirklich identisch; der Schaden sitzt in einem Dictionary. Der Revisions-Diff aus dem Artikel zur Modification Level bildet den Fingerabdruck eines normalisierten Objekt-Body, daher hashen die zwei Revisionen auch dort auf denselben Wert. Ein gerenderter Seitenvergleich ist es, der das erwischt. Für jede Pipeline, die PDFs anderer Software umschreibt, würden wir einen Seiten-für-Seiten-Rendervergleich vor jede strukturelle Prüfung setzen

Wie ein rein-Double-Zahlenmodell bei einem No-op-Save in Delphi CalRGB-Präzision verliert: das geparste /Gamma 2.22221 und ein Matrix-Eintrag 0.71519 landen als Double und werden bei der Ausgabe auf vier Dezimalstellen formatiert, jede strukturelle Prüfung meldet ein unverändertes Dokument, und nur der Rendervergleich zeigt alle 35 Kopfbilder verschoben
Die Bild-Bytes sind unschuldig: Die Kalibrierungszahlen drumherum auf fester Präzision umzuformatieren genügt, und Stream-Hashes wie der Fingerprint-Diff melden identische Revisionen, während der Renderer auf jeder Seite leicht andere Farben produziert

Wie eine PDF-Realzahl überhaupt aussehen darf

ISO 32000-1 §7.3.3 definiert eine Realzahl als Dezimalstring: ein optionales Vorzeichen, Ziffern, ein Punkt, weitere Ziffern. Keine Radix-Notation, keine Exponentenform. Anhang C, die Implementierungsgrenzen auflistet, erwartet, dass grob fünf signifikante Dezimalstellen im Bruchteil eingehalten werden. Eine Default-Ausgabepräzision von vier sitzt damit schon eine Stelle unter dem, was die Specification den Readern aufträgt zu behalten, und die Datei hat nichts falsch gemacht, indem sie fünf benutzt

Nahe null macht ein naiver Formatter mehr als eine Stelle kaputt. Der übliche Weg, einen Double auf N Stellen zu drucken, ist mit 10^N multiplizieren, auf einen Integer runden und die Ziffern ausschreiben, und wenn dieser Integer null ist, ist die Ausgabe 0. Gibt man ihm den ersten Operanden von 0.00001 0 0 1 0 0 cm auf vier Stellen, ist das Ergebnis 0 0 0 1 0 0 cm, eine singuläre Matrix, unter der alles verschwindet, was gezeichnet wird. Ein /Matrix-Eintrag von -0.000012345 geht genauso unter

Wenn die PDF Library for Delphi (PDFlibPas) einen Double selbst formatieren muss, hält sie für einen Nicht-null-Wert unterhalb der Präzisionsschrittweite die angeforderte Zahl signifikanter Stellen, statt 0 zu schreiben. Auf vier Stellen wird 0.00001 als 0.00001 geschrieben und 0.0000123 als 0.0000123; erst Größenordnungen unter grob 5E-16 kommen als 0 heraus. Neu geschriebene Zahlen bleiben damit gesund. Eine geparste Zahl, die nie neu formatiert werden musste, ist etwas anderes, und die Bibliothek kümmert sich darum, indem sie sie gar nicht umformatiert

Wie PDFlibPas das geparste Literal behält

TPDFStructure.Decode behält den Quelltext jeder Standard-Real, die er tokenisiert, also eines Tokens mit Dezimalpunkt und ohne Exponentenmarker, direkt neben dem konvertierten Double. TPDFNumeric.Output schreibt diesen Text zurück, wann immer er da ist, und formatiert den Double erst dann nach der PDFPrecNum der Structure, wenn er fehlt. TPDFNumeric.SetTo verwirft den Text, denn eine bearbeitete Zahl ist eine neue Zahl und soll der aktuellen Ausgabepräzision folgen

Wie PDFlibPas geparsten Dezimaltext in Delphi bewahrt: der Tokenizer in TPDFStructure.Decode behält das Quell-Literal neben dem konvertierten Wert für jedes Token mit Dezimalpunkt und ohne Exponent, Output schreibt diesen Text wortwörtlich statt den Double nach PDFPrecNum neu zu formatieren, SetTo löscht ihn, weil eine bearbeitete Zahl eine neue Zahl ist
Der Wert, den Sie dekodieren, und das Literal, das Sie ausgeben, sind zwei verschiedene Dinge: Das geparste Text vorzuziehen hält 2.22221 exakt, während Bibliotheks-eigene und bearbeitete Zahlen weiterhin PDFPrecNum folgen und die Einstellung unberührte Eingabe nie erreicht

Das Zurücksetzen an SetTo zu koppeln, war eine bewusste Entscheidung. Der verlockende Abkürzungsweg ist, den Originaltext auszugeben, solange das Changed-Flag des Objekts nicht gesetzt ist, denn das Flag existiert ohnehin an jedem Objekt. Nur löscht die Save-Pipeline Changed, sobald ein Objekt geschrieben wurde, also würde ein Wert, der in derselben Sitzung bearbeitet, gespeichert und noch einmal bearbeitet wird, beim zweiten Save wieder sein veraltetes Literal ausgeben. Den Text innerhalb der Zuweisung selbst zu löschen heißt: Literal und Wert können nie auseinanderlaufen

Ein paar Grenzen sind absichtlich so. Integers werden nicht als Text gehalten, weil Integer-Formatierung ohnehin verlustfrei ist. Exponentenformen wie 6.02E23 werden bei der Eingabe akzeptiert, Rücksicht auf Produzenten, die sie trotzdem schreiben, aber bei der Ausgabe wie jede bibliothekseigene Zahl formatiert, denn sie zurückzukopieren würde eine Syntax verewigen, die §7.3.3 ausschließt. Der Tokenizer wendet außerdem seine übliche kleine Reparatur an, bevor er den Text speichert: Ein Literal mit führendem Punkt wie .5 wird als 0.5 gehalten, eines mit Punkt am Ende wie 5. als 5.0. Jeder Reader behandelt jedes Paar als dieselbe Zahl, und die reparierte Schreibung wird weit breiter akzeptiert

Das Snippet unten schickt die CalRGB-Werte direkt durch die Objektschicht, sodass sich jede Regel sehen lässt, ohne eine PDF-Datei zu öffnen. Die Assert-Zeilen sind Kontrollen, die wir für Ihre eigenen Tests vorschlagen; es sind keine Aufrufe in die Bibliothek

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]'));

    // Eigene Kontrolle: unangetastete Eingabe überlebt wortwörtlich, auch der
    // Wert, den ein naiver Vier-Stellen-Formatter auf 0 kollabieren ließe
    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');

    // Winzige neue Werte behalten signifikante Stellen, statt zu 0 zu werden
    Assert(Structure.NewNumeric(0.0000123).Output = '0.0000123');

    // Spätes Senken der Präzision erreicht unangetastete Eingabe nicht
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Eine Falle bei Assertions dieser Art: Delphis Round rundet nach der Banker's-Rounding-Regel, und 0.000012345 * 1e8 ergibt binär 1234.4999… statt 1234.5. Wir sind beim Prüfen des Near-zero-Formats genau über diesen Wert gestolpert. Wählen Sie Testeingaben, die keine binären Mittelpunkte sind, wann immer eine Assertion davon abhängt, wie die letzte Stelle rundet

Was steuert SetPrecision?

TPDFlib.SetPrecision setzt die Dezimalstellen der Zahlen, die die Bibliothek schreibt: Grafiken durch den Painter, Zahlen aus einem Double erzeugt, etwa über NewNumeric, und geparste Werte, die inzwischen per SetTo geändert wurden. Sie nimmt 2 bis 8 und liefert für alles außerhalb 0. Eine geparste Dezimalzahl, die niemand anfasste, behält ihren Eingabetext, egal wie die Einstellung steht, und die Einstellung nach LoadFromFile zu ändern greift nicht zurück in sie. Quelle, die an SetObjectFromString geht, läuft durch denselben Tokenizer und wird genauso gehalten

Unsere Empfehlung: SetPrecision in Ruhe lassen, außer die Geometrie, die Sie erzeugen, braucht feinere Schritte. Sie zu erhöhen, um Eingabedateien zu schützen, bringt nichts, denn unangetastete Eingabe behält bereits ihre eigenen Stellen, und es macht jede gezeichnete Zahl länger. Fünf Stellen sind eine sinnvolle Wahl für Zeichenarbeit unterhalb des Punkt-Rasters; acht bläht vor allem Content Streams auf

uses
  PDFlibrary;

var
  PDF: TPDFlib;
begin
  PDF := TPDFlib.Create;
  try
    if PDF.LoadFromFile('calibrated.pdf', '') <> 1 then
      Exit;

    // Regelt Zahlen ab hier; die geparsten /Gamma-,
    // /Matrix- und /WhitePoint-Arrays behalten ihren Originaltext
    PDF.SetPrecision(5);

    PDF.SelectPage(1);
    PDF.SetLineWidth(0.123456);       // gezeichnete Zahlen nutzen fünf Stellen
    PDF.DrawLine(72, 72, 300, 72);

    PDF.SaveToFile('calibrated-out.pdf');
  finally
    PDF.Free;
  end;
end;

Content Streams: Bearbeitungen behalten Operanden, Normalisierung ist Opt-in

Das editierbare Content-Modell behält Operandentext per Default und kanonisiert Zahlen nur dann, wenn kanonische Ausgabe der Sinn des Aufrufs ist. TPDFContentProgram.CanonicalNumbers steht auf dem Default False, daher gibt eine Layer aus ParsePageContentLayer, dasselbe Modell, auf dem der Graphics-State-Tracker aufbaut, jeden unberührten numerischen Operanden exakt so wieder aus, wie er geschrieben war. Seiten-Bearbeitungen, die eine ganze Layer neu schreiben, inklusive ScalePage, TransformPageElement, DeletePageElement, RedactRegion, ConvertTextToPaths und Ihres eigenen, per WritePageContentLayer zurückgeschriebenen Programms, ändern nur die Operanden, die die Bearbeitung anfasst

Das andere Default sähe aufgeräumter aus: bei jedem Parse kanonisieren, sodass der Bearbeitungsweg und NormalizeContentStreams immer dieselbe Schreibung produzieren. Wir halten es für das falsche Default beim Editieren. Ein ScalePage-Aufruf auf einer Layer mit 0.123456 w würde 0.1235 w zurückschreiben, und jeder andere Operand der Layer würde gleich mit umgeschrieben, obwohl der Aufrufer nur Skalierung wollte. Kanonische Form gehört zu Operationen, deren Zweck vergleichbare Ausgabe ist

Diese Operationen bekommen sie weiterhin. NormalizeContentStreams und der Content-Optimierer beim Speichern parsen mit CanonicalNumbers auf True, denn ihre Aufgabe ist stabile Ausgabe aus beliebiger Eingabe; würden 0.50000 0 0 RG und 0.5 0 0 RG unterschiedlich emittiert, driftete jeder Downstream-Vergleich mit den Formatierungsgewohnheiten des Produzenten. Kanonisieren rekursiert durch Arrays und Dictionarys, also sind Strichmuster, TJ-Arrays und Property-Dictionarys von Marked Content abgedeckt, während rohe Inline-Image-Daten unangetastet bleiben. Operanden, die über SetOperand kommen, werden kanonisiert, was auch immer das Flag sagt. Ein Content-Programm trägt einen eigenen Zahlenspeicher mit fester Vier-Stellen-Präzision, unabhängig von SetPrecision, daher kommt ein bearbeiteter Operand mit vier Stellen heraus. Nur Parse liest das Flag, es muss also vor dem Parsen gesetzt sein; für ein Programm, das ParsePageContentLayer bereits geparst hat, das Flag setzen und Parse(Emit) aufrufen

Wann das PDFlibPas-Content-Modell Zahlen normalisiert: mit CanonicalNumbers auf True wird jeder numerische Operand beim Hineinschieben in den Parser kanonisiert, Operanden an SetOperand werden immer kanonisiert, der Durchlauf rekursiert durch Arrays und Dictionarys, 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 emittiert werden
Kanonische numerische Operanden sind es, was NormalizeContentStreams und der Optimierer verlangen: Rohe Inline-Image-Daten bleiben unangetastet, und unberührte Dictionary-Zahlen außerhalb von Content Streams behalten die Wortwörtlich-Garantie, sodass ein schlichtes LoadFromFile-und-SaveToFile-Paar sie weiterhin bewahrt
uses
  PDFlibrary, PDFlibContentModel, PDFlibContentOps;

var
  PDF: TPDFlib;
  Prog: TPDFContentProgram;
  I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    if PDF.LoadFromFile('calibrated.pdf', '') <> 1 then
      Exit;
    PDF.SelectPage(1);
    Prog := PDF.ParsePageContentLayer(1);
    if Prog = nil then
      Exit;
    try
      // Eine Linienbreite ändern; jeder andere Operand behält seinen Quelltext
      I := Prog.FindNextOp(0, coLineWidth);
      if I >= 0 then
        Prog.SetOperand(I, 0, '0.123456');   // emittiert als 0.1235
      PDF.WritePageContentLayer(1, Prog);

      // Kanonische Kopie nur für den Vergleich, nicht zurückgeschrieben:
      // Parse ist der einzige Leser des Flags, also nach dem Setzen neu parsen
      Prog.CanonicalNumbers := True;
      Prog.Parse(Prog.Emit);
      // Prog.Emit schreibt 0.50000 und 0.5 jetzt gleich
    finally
      Prog.Free;
    end;
    PDF.SaveToFile('calibrated-out.pdf');
  finally
    PDF.Free;
  end;
end;

Zusammengekommen ist die Regel für Aufrufer kurz. Ein schlichtes LoadFromFile gefolgt von SaveToFile lässt unberührte Content Streams und unberührte Dictionary-Zahlen so, wie sie waren. Eine über das Content-Modell bearbeitete Seite behält den Text jedes Operanden, den die Bearbeitung nicht änderte. NormalizeContentStreams, der Optimierer, der läuft, wenn Content beim Speichern komprimiert wird, und jedes mit CanonicalNumbers auf True geparste Programm produzieren kanonische Zahlen per Design, und der Rest des Dokuments bleibt weiterhin bewahrt

Was es kostet und wo die Garantie endet

Jedes TPDFNumeric trägt eine AnsiString-Referenz mehr, und jede geparste Dezimalzahl hält ihren Quelltext am Leben, solange das Objekt lebt. Bei einem Dokument mit Millionen von Realzahlen ist das messbarer Speicher, und er gehört in jeden Large-Document-Benchmark, den Sie fahren. Die Garantie gilt zudem nur innerhalb des eigenen Dokuments einer Zahl. Objekte zwischen Dokumenten zu kopieren oder Werte über die Objekt-API neu aufzubauen erzeugt neue Zahlen, und neue Zahlen folgen der Ausgabepräzision

Bewahrte Zahlen und byte-identische Ausgabe sind zwei getrennte Eigenschaften. Ein Load-and-Save eines unangetasteten Dokuments hält die Kalibrierungszahlen, die der Renderer verbraucht, aber Byte-Identität hängt auch an ObjektNummerierung, Stream-Kompression und der Trailer-Identifikator aus dem Artikel zur deterministischen PDF-ID. Der Fingerprint-Diff hasht weiterhin den normalisierten Body, das heißt: Rundungsdifferenzen in Dateien anderer Werkzeuge fallen außerhalb dessen, was er vergleicht; ein Rendervergleich bleibt die Prüfung dafür

Zahlenbehandlung und die exakte SetPrecision-Semantik sind in der Referenz der losLab PDF Developer Library behandelt, zusammen mit den oben benutzten Content-Modell-Aufrufen