PDFlibPas(losLab 的 Delphi PDF Developer Library)写进内容流的每个数字都用点号作小数分隔符、不带指数,Windows 区域设置说什么都没用。从 v3.539.26 起,AddPageMatrix、ScalePage、DeskewPage、RedactRegion、文字转路径输出和重新着色都经 PLDoubleToStrConst 格式化操作数;从 v3.539.33 起,把这些数字读回来的解析器用 PLTryStrToFloatInvariant 而不再看系统区域。在德语、法语或巴西葡语机器上,同样的代码产出与美国机器上相同的字节——这是文件格式唯一能容忍的行为
为什么逗号小数区域无声损坏 PDF 而不报错?
逗号小数区域之所以无声损坏 PDF,是因为逗号在 PDF 语法里不是数字字符,于是损坏呈现为含义错误的合法 token。修复之前,PLFloatToStr 就是一次裸的 FloatToStr 调用,而 FloatToStr 跟随 FormatSettings.DecimalSeparator。分隔符是逗号时,AddPageMatrix(0.5, 0.5, 0, 0) 会写出 0,5 0 0 0,5 0 0 cm。ISO 32000-1 §7.3.3 只允许数字里出现数位、一个小数点和前置符号,别的都不行,所以内容解析器把这一行读成数字 0 加一个未知 token ,5,cm 操作符拿到的操作数就此错了。不抛异常,不记日志。页面只是带着一个漂移了的变换矩阵渲染出来,而从一张画错位置的图反推到某个区域设置,是一个相当难受的下午
第二个缺陷藏在第一个后面。FloatToStr 用 ffGeneral 格式,数量级一旦低于 1E-4 就切到指数记法,于是很小的偏移输出成了 1E-5。同一个 §7.3.3 声明 PDF 不支持指数形式,这意味着哪怕美式区域的机器,只要值足够小也会写出非法操作数。这个版本的回归测试把两种失败形状都钉住了:把分隔符切成逗号、调 API、然后扫描产物内容里任何含逗号或指数的 token
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
OldSeparator: Char;
begin
Lib := TPDFlib.Create;
try
Lib.SetPageDimensions(300, 200);
Lib.DrawBox(10, 10, 20, 20, 1);
OldSeparator := FormatSettings.DecimalSeparator;
try
FormatSettings.DecimalSeparator := ','; // 模拟一台 de-DE 桌面
Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
// v3.539.26 及以后写出:0.5 0 0 0.25 0.00001 12.75 cm
// 更早的构建写出: 0,5 0 0 0,25 1E-5 12,75 cm
Writeln(Lib.GetPageContentToString);
finally
FormatSettings.DecimalSeparator := OldSeparator;
end;
finally
Lib.Free;
end;
end;
两种数字,两族辅助函数
PDFlibPas 的修法是一条严格分界:给人看的数字可以跟随区域,写给机器的数字永远不跟。PLFloatToStr 和 PLStrToFloat 留在 PDFlibExtra.pas 里服务面向用户的文本,它们的声明现在带着注释把这一点写明。凡最终成为 PDF 语法的东西一律走 PLDoubleToStrConst,小数位数按用途定死:矩阵六位,坐标和 TJ 调整四位,颜色和 FDF 矩形三位。v3.539.26 的审计摸过的调用点比原始 bug 报告暗示的多:
AddPageMatrix、ScalePage和DeskewPage,它们都在既有页面内容前面插一个cm- 发出
Tm重置、TJ前进和cm变换的页面元素构建器 - 文字转路径转换器里的字形摆放矩阵和轮廓点
RedactRegion前插的黑色填充框、FDF 导出里的/Rect值,以及重新着色写出的操作数
PLDoubleToStrConst 是手工写的格式化器,不是包在 FloatToStrF 外面的壳,它的三个性质在这里要紧。它总是写点号并剥掉尾零,所以 0.5 保持 0.5 而不是 0.500000。有限输入它从不写指数。小于请求精度的非零值保留有效数字而不是坍缩成零,所以 PLDoubleToStrConst(1E-9, 6) 返回 0.000000001;只有大约低于 5E-16 的值才变成 0。最后这条规则的存在理由是:把一个微小的缩放因子舍入成零,会把合法矩阵变成奇异矩阵,这比正在修的那个 bug 更糟
uses
PDFlibExtra;
procedure CheckNumberHelpers;
var
V: Double;
begin
// 机器输出:点号小数、无指数、剥掉尾零
Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235'); // 保留 4 位有效数字
Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');
// 机器输入:软失败而不是 EConvertError
Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
Assert(not PLTryStrToFloatInvariant('0,5', V)); // 内容数字从不用逗号
end;
为什么解析端比写出端更危险?
解析端更危险,因为绑定区域的解析器不会产出一个错误的数字,它直接抛。PLStrToFloat 调 StrToFloat,文本不匹配系统分隔符时抛 EConvertError。在逗号小数系统上,这意味着 RecolorPage 一碰到普通的 0.5 g 操作符就中止,于是每个真实世界的页面都失败,而非只有怪页面。RenderPageRegionToFile 拒绝它自己文档里写的裁剪格式 "10.5,20.5,50.5,40.5",SVG 长度属性、SVG 导出颜色、注释顶点列表和 output intent 实度值要么被拒、要么被默认值无声替换。在开发者机器上完美、在慕尼黑第一位客户那里就挂的库,正是碰巧能跑的 Delphi 代码那篇讲的那类代码:它显得正确,只因为测试它的地方恰好对
v3.539.33 按输入来源给每个 StrToFloat 和 TryStrToFloat 调用分类。内容流操作数、SVG 属性、painter 颜色串、逗号分隔的裁剪与顶点列表都有固定的点号语法,所以它们现在都走 PLTryStrToFloatInvariant:先裁掉空白,用 PLInvariantFormatSettings 解析,空、畸形或非有限输入返回 False 而不是抛异常。逗号分隔的列表没有妥协余地,因为逗号不能同时充当列表分隔符和小数点。同一轮还修了一个越界写入:RenderPageRegionToFile 曾把第五个裁剪值存到四元素缓冲之外。把 PDF 转到单一色彩空间指南里讲的重新着色管线,实际结果是 RecolorPage 和 RecolorDocument 在逗号小数系统上不再中止。调用者敲进 CheckDocumentPolicy 的规则值是唯一改用宽松辅助函数的解析场景,理由见下一节
只修往返的一端会怎样?
只修区域往返的一端会弄坏原本能用的代码,这就是 v3.539.32 的结构属性改动把写出端和读取端一起搬的原因。SetStructElem* 包装器以字符串形式中转数字:SetStructElemBBox 把四个值格式化进一个字符串,经 AddTagAttribute 存起来,/A 写出器稍后解析这个字符串来决定它成为数字、数组还是名称。两端都曾用系统区域,所以在逗号小数系统上往返是自洽的。bug 只在调用者按文档传 "0.5" 给 AddTagAttribute 时现形:读取端解析不了,吐出 PDF 名称 /0.5。PDF/VCR 占位符有镜像版的问题,因为库先用点号生成 GTS_BBox,保存前又用区域校验它
只把写出端改成点号会比什么都不做更糟,因为每个 SetStructElem* 值都会过不了绑定区域的读取端、退化成名称。所以写出端现在用 PLDoubleToStrConst(v, 6),读取端用新的 PLTryStrToFloatLenient:先试点号形式,再回落系统区域。过去传 "1,25" 的逗号区域调用者仍然得到数字 1.25。取舍是刻意且写进文档的:在德语系统上 "1.500" 曾因 StrToFloat 拒绝千位分隔符而变成名称,现在读作 1.5,而字面的 NAN 和 INF 字符串不再被当数字接受
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
begin
FormatSettings.DecimalSeparator := ','; // 逗号小数的调用方
Lib := TPDFlib.Create;
try
Lib.BeginTag('Figure', 'Sales chart', '');
Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5'); // /SpaceAfter 0.5,此前会写成 /0.5
Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // 仍是 /StartIndent 1.25
Lib.DrawText(20, 20, 'figure');
Lib.EndTag;
Lib.SaveToFile('tagged.pdf');
finally
Lib.Free;
end;
end;
NaN 和无穷大在哪里被拦下
AddPageMatrix、ScalePage 和 RedactRegion 现在在前门就拒绝 NaN 和无穷参数并返回 0,因为没有哪个 PDF 数字能表示它们。ScalePage 本来就拒绝零或负的缩放因子,但 NaN 能通过 <= 0 测试,所以 NaN 缩放因子曾一路走到格式化器。v3.539.26 那时格式化器还会对 NaN 调 Round,在 x87 单元不屏蔽非法操作的 Win32 上抛 EInvalidOp;v3.539.31 让 PLDoubleToStrConst 对 NaN 写 0 作为最后一道防线,但矩阵里的零是奇异变换,所以 API 层的检查才是真正的修复。两处边界刻意保留:元文件状态串在一个进程内用区域读写、从不离开进程,所以不动;对 1E-5 走页面元素路径做格式化的那个测试,必须在层被重写之前读内容,因为按文档精度重排操作数会合法地把这个值变成 0
如果你的应用要交付给点号世界之外的客户,最保险的习惯是 PDFlibPas 测试套件现在用的那个:把 FormatSettings.DecimalSeparator 设成逗号跑一遍所有产出 PDF 的路径,然后扫描输出里的逗号和指数。保留解析出的十进制精度一文讲同一个故事的另一半:从既有文件读出的数字如何在保存时保住自己的原文。下载、完整 API 参考和试用版都在 PDFlibPas Delphi PDF library 产品页