PDFlibPas,也就是 losLab PDF Developer Library,会把文档里每一个实数解析到的确切十进制文本留着,只要这个值从来没被改过,就把它原样写回去。从 v3.539.19 起,SetPrecision 只管辖库自己创建或编辑的数字,所以一次普通的加载再保存不会再把一个 CalRGB 的 /Gamma 从 2.22221 四舍五入成 2.2222、也不会让一个谁都没碰过的页面颜色发生偏移。这个改动代码量很小,但它对解析器说的话很大:你解码出来的值和你写出去的原文是两回事,而一次 Double 往返并不是恒等变换
为什么一次什么都没改的保存会让页面颜色偏移?
因为被重新格式化的是颜色空间参数,不是图像。暴露这一点的文件是本地回归语料里的一份 35 页办公文档,每一页都复用同一张页眉图。把它加载再直接存回去,产出的图像流与输入逐字节相同,流哈希比较报告文档未变。渲染比较却不同意:35 页里每一页都在页眉上出现像素差异,别处一处都没有
页眉图经由一个 CalRGB 颜色空间绘制,ISO 32000-1 §8.6.5.3 用一个 /WhitePoint、一个可选的三元素 /Gamma 数组和一个可选的九元素 /Matrix 定义它。这些数组就是颜色空间字典里的普通数值对象。TPDFNumeric 把每一个都只存成一个 Double,别无其他,而 TPDFNumeric.Output 通过 PDFPrecNum 格式化那个 Double,默认四位小数。于是 /Gamma 从 2.22221 变成了 2.2222,一个矩阵元素从 0.71519 变成了 0.7152,而渲染器忠实地从略有不同的标定里产出了略有不同的颜色。图像字节是无辜的;它们周围的数字不是。让人不安的是这件事有多隐形。比较解码后的流字节看不到它,因为这些数字住在字典里,不在流里。比较附件载荷也看不到。连修改层级那篇里描述的修订比较,取的都是规范化后的对象体指纹,于是两个修订哈希到同一个值,比较报告它们相同。只有渲染抓住了它,这正是语料基线渲染每一页、而不是只信结构检查的原因
你解析出的值不是你应该写出去的原文
一个 PDF 实数是一个十进制字符串,ISO 32000-1 §7.3.3 说得很明确,它就只是一个十进制字符串:不许基数记法,不许指数形式。附录 C 随后列出实现应当尊重的精度,小数部分大约五位有效十进制数字。四位小数的默认输出精度已经低于这个要求,而且在接近零的时候更糟:PLDoubleToStr 会缩放该值、四舍五入成整数,结果为零时就直接输出 0,所以一个 -0.000012345 的矩阵元素不是丢一位,而是整个消失
提高默认值只是把悬崖往后挪。修法是别再假装 Double 就是那个数。当 TPDFStructure.Decode 里的分词器认出一个标准实数——也就是该 token 带小数点且没有指数字母——它就把源文本存进新增的 FOriginalText 字段,与转换后的值并排放着。Output 随后优先用那段文本,只有在没有可优先的东西时才退回格式化
// Lib/PDFlibStruct.pas —— 输出侧的全部修法
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:= ''; // 被编辑过的数就是一个新数
FValue:= Value;
FChanged:= True;
End;
有两条边界是刻意的。整数不做保留,因为整数的格式化本来就是无损的。像 6.02E23 这样的指数形式在输入时出于照顾坏生成器而容忍,但输出时不保留,因为写回去会让 §7.3.3 禁止的一种语法一直活下去;它们跟任何库生成的数字一样走格式化器。分词器还会在存文本之前做它一贯的最小修复,所以像 .5 这样前导点的原文会被保留成 0.5,像 5. 这样尾随点的原文保留成 5.0。对每个读取器来说这都是同一个数,而被接受的范围要宽得多
v3.539.19 之后 SetPrecision 保证什么?
TPDFlib.SetPrecision 现在管的是库自己产出的那些数字的小数位数:经 painter 绘制的值、从 Double 创建的数字(例如通过 NewNumeric),以及任何之后被 SetTo 编辑过的解析值。注意通过对象 API 解码的文本——比如传给 SetObjectFromString 的一段原文——走的是同一个分词器,也同样被保留下来。一个从未被修改过的解析小数不管这个设置是什么都保持输入精度,而加载之后再改这个设置也不会回溯性地碰到它。SetPrecision 的参考条目在同一个版本里被改成正好这么说,因为旧措辞暗示这个设置作用于文件里的每一个数字
清空发生在 SetTo 里,而不是从 Changed 标志推导出来,这个区别很重要。保存管线在对象被写出去之后就重置它们的 Changed,所以「除非已改变,否则输出原文」这种检查形式,会在同一个会话里对一个被编辑、保存、又再次编辑过的值开始输出过期的文本。把原文和那次赋值本身绑在一起,两者就不可能不一致。回归测试用原始文件里的那些值把每一种行为都钉住了
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]'));
// 没被编辑过的输入原样保留,包括那个会被
// 四位格式化塌成 0 的值
Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');
// 一次编辑会丢掉原文,改为服从 PDFPrecNum
Number := TPDFNumeric(Values.Item[0]);
Number.SetTo(0.123456);
Assert(Number.Output = '0.1235');
Assert(Structure.NewNumeric(0.123456).Output = '0.1235');
// 之后降低精度碰不到没被编辑过的输入
Structure.PDFPrecNum := 2;
Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
finally
Structure.Free;
end;
end;
为什么内容模型仍然对数字做规范化?
因为 TPDFContentProgram 承诺的是规范化的数值操作数,而这个承诺在内容流内部比原文文本更值钱。可编辑内容模型——也就是图形状态跟踪器建立在其上的那套——存在的意义就是让 NormalizeContentStreams、优化器和 Emit 从任意输入产出稳定、可比较的输出。如果解析出的操作数把原始文本带进模型,那么 0.50000 0 0 RG 这样的算子序列就会和 0.5 0 0 RG 输出得不一样,下游每一次比较都会随生成器的格式化习惯而漂移
所以模型在自己的两个入口把原文剥掉。NormalizeContentNumbers 在解析器推入每个操作数时对它们各跑一次,并在解码调用方提供的源时在 SetOperand 里再跑一次;它还会递归进数组和字典,好把虚线样式、TJ 数组和标记内容的属性字典都覆盖到。对每个数值调用 SetTo(AsDouble) 就够了,因为这正好就是那个清掉文本的操作。原始的内联图像数据照旧不动
// Lib/PDFlibContentModel.pas —— 内容模型守住自己的契约
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;
所以给调用方的实用规则很简单。一次普通的 LoadFromFile 后接 SaveToFile,会把没被碰过的内容流和没被碰过的字典数字原样留下。一个经过了 NormalizeContentStreams 的页面,或者任何通过内容模型做的编辑,按设计输出规范化结果,而文档的其余部分仍然被保留。这是两种不同的请求,而它们现在做的是两件不同的事
代价是什么,保证到哪里为止
每个 TPDFNumeric 现在多带一个 AnsiString 引用,而每个解析出的小数都会让它的源文本活到这个对象的生命期结束。在一份有数百万个实数的文档上这是实打实的内存,任何大文档测量都该把它算进去,而不是挥手带过。这个保证的范围也限于数字自己所在的文档:在文档之间复制对象,或者通过对象 API 重建值,产出的都是新数字,它们跟其他任何新数字一样服从输出精度。把这次发布声称什么、不声称什么说清楚是值得的。一次对未被触及文档的加载再保存,现在会保住渲染器实际消费的那些标定数字,这正是语料基线检查的性质。它不声称输出逐字节相同,那还取决于对象编号、流压缩,以及确定性 PDF ID 那篇里讨论的 trailer 标识符。它也不会让指纹比较看得到别的软件产出的文件里的四舍五入差异,因为那些仍然对规范化后的对象体取哈希。这个教训远远超出 CalRGB 的范围:当一个解析器只留下转换后的值,每一次保存就都是一次编辑,而唯一能发现它的办法就是去看渲染结果。数字处理和 SetPrecision 的语义都记在 losLab PDF Developer Library 产品页上