Technical Article

Preserving Parsed PDF Decimal Precision on Save in Delphi

PDFlibPas, the losLab PDF Developer Library, keeps the exact decimal text it parsed for every real number in a document and writes that text back verbatim whenever the value was never modified. Since v3.539.19 the SetPrecision setting governs only numbers the library creates or edits, so an ordinary load-and-save no longer rounds a CalRGB /Gamma of 2.22221 down to 2.2222 and shifts the colors of a page nobody touched. The change is small in code and large in what it says about parsers: the value you decode and the literal you emit are two different things, and a Double round trip is not an identity transform

Why did a save that changed nothing shift the page colors?

Because the color space parameters were being reformatted, not the image. The file that exposed this is a 35-page office document in the local regression corpus, with a header image reused on every page. Loading it and saving it straight back produced image streams that were byte-for-byte identical to the input, and a stream-hash comparison reported the document unchanged. A rendered comparison disagreed: every one of the 35 pages showed pixel differences in the header, and nowhere else

The header image draws through a CalRGB color space, which ISO 32000-1 §8.6.5.3 defines by a /WhitePoint, an optional three-element /Gamma array and an optional nine-element /Matrix. Those arrays are plain numeric objects in the color space dictionary. TPDFNumeric stored each one as a Double and nothing else, and TPDFNumeric.Output formatted that Double through PDFPrecNum, which defaults to four decimal places. So /Gamma went from 2.22221 to 2.2222, a matrix entry went from 0.71519 to 0.7152, and the renderer faithfully produced slightly different colors from slightly different calibration. The image bytes were innocent; the numbers around them were not. The uncomfortable part is how invisible this was. Comparing decoded stream bytes cannot see it, because the numbers live in a dictionary, not a stream. Comparing attachment payloads cannot see it. Even the revision diff described in the modification level article fingerprints a normalized object body, so both revisions hash to the same value and the diff reports them identical. Only rendering caught it, which is why the corpus baseline renders every page rather than trusting structural checks alone

Where PDFlibPas lost CalRGB precision on a no-op save in Delphi: the parsed /Gamma 2.22221 and a 0.71519 matrix entry live in TPDFNumeric as a Double, Output formats them through PLDoubleToStr with PDFPrecNum at four decimal places, every structural check reports the document unchanged, and only the rendered comparison shows all 35 header images shifted
The image bytes were innocent: TPDFNumeric reformatted the calibration numbers around them through PDFPrecNum, so stream hashes and the fingerprint diff both reported identical revisions while the renderer produced slightly different colors on every page

The value you parsed is not the literal you should write

A PDF real number is a decimal string, and ISO 32000-1 §7.3.3 is explicit that it is only a decimal string: no radix notation, no exponent form. Annex C then lists the precision an implementation is expected to honor, approximately five significant decimal digits in the fractional part. A default output precision of four is already below that, and it gets worse near zero: PLDoubleToStr scales the value, rounds to an integer and emits 0 when the result is zero, so a matrix entry of -0.000012345 does not lose a digit, it disappears entirely

Raising the default would only move the cliff. The fix is to stop pretending that a Double is the number. When the tokenizer in TPDFStructure.Decode recognizes a standard real, meaning the token contains a decimal point and no exponent marker, it stores the source text in the new FOriginalText field alongside the converted value. Output then prefers that text and falls back to formatting only when there is nothing to prefer

How PDFlibPas preserves parsed decimal text in Delphi: the tokenizer in TPDFStructure.Decode keeps the source literal in FOriginalText for any token with a decimal point and no exponent, Output writes that text verbatim instead of calling PLDoubleToStr, SetTo clears it because an edited number is a new number
The value you decode and the literal you emit are two different things: preferring the parsed text keeps 2.22221 exact, while library-created and edited numbers still follow PDFPrecNum and the setting never reaches untouched input
// Lib/PDFlibStruct.pas — the whole fix on the output side
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:= '';   // an edited number is a new number
  FValue:= Value;
  FChanged:= True;
End;

Two boundaries are deliberate. Integers are not preserved, because integer formatting is already lossless. Exponent forms such as 6.02E23 are tolerated on input for the sake of broken producers but are not preserved on output, since writing them back would perpetuate a syntax §7.3.3 forbids; they go through the formatter like any library-generated number. The tokenizer also applies its usual minimal repair before storing the text, so a leading-dot literal like .5 is kept as 0.5 and a trailing-dot literal like 5. as 5.0. Both are the same number to every reader and are far more widely accepted

What does SetPrecision guarantee after v3.539.19?

TPDFlib.SetPrecision now controls the decimal places of numbers the library itself produces: values drawn through the painter, numbers created from a Double such as through NewNumeric, and any parsed value that has since been edited with SetTo. Note that text decoded through the object API, for example a literal passed to SetObjectFromString, goes through the same tokenizer and is preserved the same way. A parsed decimal that was never modified keeps its input precision regardless of the setting, and changing the setting after the load does not retroactively touch it. The SetPrecision reference entry was updated in the same release to say exactly this, because the old wording implied the setting applied to every number in the file

The clearing happens in SetTo rather than being derived from the Changed flag, and that distinction matters. The save pipeline resets Changed on objects once they have been written, so a check of the form "emit original text unless changed" would start emitting stale text for a value that was edited, saved, and edited again in the same session. Tying the original text to the assignment itself makes it impossible for the two to disagree. The regression test pins each of these behaviors with the values from the original file

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]'));
    // Unedited input survives verbatim, including the value that
    // four-place formatting would have collapsed to 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // An edit discards the original text and follows PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Lowering the precision afterwards does not reach unedited input
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Why does the content model still normalize numbers?

Because TPDFContentProgram promises canonical numeric operands, and that promise is worth more than verbatim text inside a content stream. The editable content model, the same one the graphics-state tracker is built on, exists so that NormalizeContentStreams, the optimizer and Emit produce stable, comparable output from arbitrary input. If a parsed operand carried its original text into the model, an operator sequence like 0.50000 0 0 RG would emit differently from 0.5 0 0 RG, and every downstream comparison would drift with the producer's formatting habits

So the model strips the original text at its two entry points. NormalizeContentNumbers runs on each operand as the parser pushes it and again inside SetOperand when caller-supplied source is decoded, and it recurses through arrays and dictionaries so that dash patterns, TJ arrays and the property dictionaries of marked content are covered. Calling SetTo(AsDouble) on each numeric is enough, since that is precisely the operation that clears the text. Raw inline image data is left alone, as it always was

Why the PDFlibPas content model still normalizes numbers: NormalizeContentNumbers runs where the parser pushes each operand and again inside SetOperand, recurses through arrays and dictionaries so dash patterns, TJ arrays and marked-content property dictionaries are covered, and SetTo AsDouble clears the original text so 0.50000 and 0.5 emit identically
Canonical numeric operands are the content model's promise: raw inline image data is left alone, and untouched dictionary numbers outside content streams keep the verbatim guarantee, so a plain LoadFromFile and SaveToFile pair still preserves them
// Lib/PDFlibContentModel.pas — the content model keeps its contract
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;

The practical rule for callers is therefore simple. A plain LoadFromFile followed by SaveToFile leaves untouched content streams and untouched dictionary numbers as they were. A page that goes through NormalizeContentStreams, or any edit made through the content model, comes out canonical by design, and the rest of the document is still preserved. Those are two different requests, and they now do two different things

What it costs, and where the guarantee stops

Every TPDFNumeric now carries one extra AnsiString reference, and every parsed decimal keeps its source text alive for the lifetime of the object. On a document with millions of real numbers that is real memory, and it belongs in any large-document measurement rather than being waved away. The guarantee is also scoped to a number's own document: copying objects between documents or reconstructing values through the object API produces new numbers, which follow the output precision like any other new number. It is worth being precise about what the release does and does not claim. A load-and-save of an untouched document now preserves the calibration numbers that the renderer actually consumes, which is the property the corpus baseline checks. It does not claim byte-identical output, which also depends on object numbering, stream compression and the trailer identifier discussed in the deterministic PDF ID article. And it does not make the fingerprint diff see rounding differences in files produced by other software, since those still hash the normalized body. The lesson generalizes well beyond CalRGB: when a parser keeps only the converted value, every save is an edit, and the only way to notice is to look at the rendered result. The numeric handling and the SetPrecision semantics are documented on the losLab PDF Developer Library product page