技术文章

面向逗号小数区域 Delphi 应用的区域无关 PDF 数字

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 的 AddPageMatrix 在逗号小数桌面上写出 0,5 0 0 0,25 1E-5 12,75 cm,PDF 解析器把它读成数字 0 加若干未知 token,cm 拿到错误操作数、页面被无声变换,而 PLDoubleToStrConst 写出合法的点号小数
逗号在 PDF 语法里不是数字字符,所以损坏读起来像含义错误的合法 token——而 1E-5 这类 ffGeneral 指数在任何区域下都是非法的,不只逗号区域

两种数字,两族辅助函数

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 更糟

PDFlibPas 把数字格式化一分为二:PLFloatToStr 与 PLStrToFloat 保持绑定区域、服务面向用户的文本,而 PLDoubleToStrConst 与 PLTryStrToFloatInvariant 用点号、无指数、按用途定死六位、四位或三位的精度格式化一切最终成为 PDF 语法的东西
invariant 格式化器是刻意手工写的:剥尾零、从不写指数、保留微小值的有效数字,因为把缩放因子舍成零会让合法矩阵变奇异
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 字符串不再被当数字接受

PDFlibPas 的 SetStructElemBBox 及其同族经 AddTagAttribute 以字符串中转数字,/A 写出器再把这些字符串解析回来,所以 v3.539.32 把两端一起搬:PLDoubleToStrConst 用点号写出,PLTryStrToFloatLenient 先读点号再回落区域,逗号区域的 1,25 仍读作 1.25
只修写出端会让每个结构元素属性退化成 PDF 名称,所以往返要么两端一起动,要么别动
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 产品页