每个进入PDF文件的实数都必须用句点写,不管操作系统认为小数点分隔符是什么。HotPDF Delphi组件把它们全部经过HPDFTypes里的一层不变转换,所以德国、法国或巴西的工作站发出0.5 0 0 0.5 5.5 7.25 cm,而不是没有合规读取器会解析的逗号分隔变体
听起来这是两行就能修的事。不是。这个缺陷被一个副作用伪装了好几年,在三个没人会当作数字格式化代码看待的子系统中重现,在某个编译器下消失、在另一个编译器下存活,而它的最后一层跟小数点分隔符毫无关系
为什么十进制逗号区域会破坏PDF内容流?
因为ISO 32000-1 §7.3.3把数字对象定义为可选符号、十进制数字和至多一个句点。逗号不在那个产生式里,按§7.2.2它也不是分隔符,所以读取器在你本想表达一个操作数的地方看到的不是一个操作数。它看到一个无法词法分析(lex)的token,包含它的算子被丢弃。图形状态矩阵里的一个0,5就干掉它周围整个q ... Q块。这个失败在现场还有独特形态:它从不在开发机上复现,以来自某个国家的支持工单出现,而且被读成渲染bug而不是语法错误,因为大多数查看器静默丢掉格式错误的算子、继续渲染页面其余部分
// 宿主进程运行在 DecimalSeparator = ',' 下
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);
// 追加的 /Contents 原本携带的内容:
// 0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
// q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// ISO 32000-1 7.3.3 没有 "0,5" 的产生式,所以修订
// 矩形和拼接后的页面都会在无报错的情况下消失
把缺陷藏了多年的副作用
HotPDF把创建路径上的大多数数字经_CutFloat写出,而这个函数的早期版本做了件安静的坏事:每次调用它都把'.'赋给宿主进程的全局FormatSettings.DecimalSeparator。这会破坏承载组件的应用的区域状态,而且不是线程安全的,但真正有趣的后果是第三个。一旦生成过任何内容,进程分隔符就是句点,于是库别处每个裸用的FloatToStr和StrToFloat都意外地变正确了。几乎每个样例都用的先创建后编辑顺序因此通过,而它掩盖的是所有在第一次_CutFloat调用之前运行的路径:已加载文档编辑器StitchLoadedPage和RedactLoadedRect、文本和图像盖章、复合水印、经Format('%.2f')的条形码绘制、/DA默认外观解析器,以及整个Type 1字体子系统
Type 1解析是所有这些里面输得最难看的一个。FontMatrix、BlueValues、stem hints和Type 2 charstring里的实数都是按Adobe Type 1字体格式规范用句点写的PostScript实数。在逗号区域下解析0.001会抛EConvertError,外层try/except把它吞了,字体矩阵停在零。那个字体的每个字形随后全部塌掉,看起来像字体问题而不是区域问题,让你去调试内嵌字形渲染一整个下午
一层转换而不是零散补丁
HotPDF现在遵循的规则很直白:任何写进或读出PDF、PostScript、JSON或JavaScript语法的数字都要经过显式的不变转换,任何地方都不再有代码依赖全局FormatSettings副作用。HPDFTypes拥有这四个转换、它们共享的设置记录,以及给条形码路径用的一个固定精度助手
// Lib/HPDFTypes.pas - 唯一不受区域设置影响的转换入口
function HPDFInvariantFormatSettings: TFormatSettings;
function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
const Default: Extended): Extended;
function HPDFFormatInvariant2(Value: Extended): string;
两个单元已经各自带了一个私有InvariantFormatSettings函数,HPDFContentStream和HPDFTextExtraction,两者都被削成一行forward声明,而不是留下平行实现。Type 1子系统是刻意的例外:它不带VCL依赖、不引用HPDFTypes,所以保留一个叫HPDFType1Core.Type1StrToFloat的本地镜像,语义相同,理由是复制一个十行函数比把图形层拖进字体解析器负担更小。还有两处故意没动。HPDFHTMLImport解析CSS长度时故意把句点换成区域分隔符再用区域解析,对那种输入这是对的;HPDFType1CB.GlyphCommands连同FontBBox字符串是公开API,调用方用自己的StrToFloat读回,在那里用同一区域生产和消费是自洽的,而不变输出会打破逗号区域里的每个调用方
HPDF_HAS_FORMAT_SETTINGS跨编译器保证了什么?
它保证不变路径在每个受支持工具链里真的能编译,而显而易见的版本门做不到。Delphi XE2引入了TFormatSettings.Create和接受TFormatSettings的FloatToStr/TryStrToFloat重载,所以第一反应是在每个调用点写{$IFDEF XE2+}, InvariantFormatSettings{$ENDIF}。这个本能有个盲点:HotPDF.inc在Free Pascal下定义D2009+但从不定义XE2+,因为XE2+回答的是Delphi版本号的问题,而Free Pascal 3.2.2不管怎样都带这些重载。于是每一道这种门在FPC构建里都静默退化成依赖区域的支路,恰恰是在原始报告来自的那些平台上。HPDF_HAS_FORMAT_SETTINGS在HotPDF.inc的FPC块和XE2+块里都定义,它是数字助手测试的唯一符号
{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
Result := DefaultFormatSettings; // FPC 中没有 TFormatSettings.Create
{$ELSE}
Result := TFormatSettings.Create;
{$ENDIF}
Result.DecimalSeparator := '.';
Result.ThousandSeparator := ',';
end;
{$ENDIF}
函数内部仍然保留一个编译器分支,因为Free Pascal没有TFormatSettings.Create,你得从DefaultFormatSettings起步。只有两个数字分隔符喂给转换,日期和货币字段保持当前区域提供的样子。XE2之前的Delphi根本没有重载,在那里助手退化为用进程区域转换、然后改写结果字符串里的分隔符字符,这不好看但从不碰全局状态
第三层:SVG允许而PDF不允许的指数
修好小数点分隔符是必要的,但不够。W3C SVG 1.1 §4.2在数字产生式里允许指数,XPS标记也一样,所以1E-6在进来时是完全合法的坐标,而ISO 32000-1 §7.3.3完全不允许指数。一个通过不变格式化的值仍然可能以1E-6落到内容流里然后被拒,而且固定格式的FloatToStrF本身在值超出RTL数字范围时也可能退回指数形式。所以HPDFSafeSVG.SVGFloat在不变转换之后自己做额外工作:它把低于1.0E-7的一切冲成零,然后如果E幸存下来,就剥掉指数、手工移动小数点,按位移需要在哪边补零。SVGFinite在门口守卫,值到达格式化器之前拒绝NaN和±1.0E30之外的任何东西,SVGReadNumber配合SVGSkipSeparators逐字符处理入站方向,而不是甩给RTL
// Lib/HPDFSafeSVG.pas - PDF 操作数的十进制记法
if Abs(Value) < 0.0000001 then
Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
SetLength(S, ExponentAt - 1);
// …… 按 ExponentValue 移动小数点,用 '0' 补位
// 在左侧或右侧,然后重新接上符号
end;
这就是喂给SVG页面导出及其图形状态处理的同一个TSVGBuffer,同样的数字纪律也适用于导入侧,当通过HPDFCreateXPSDocumentHandler和THPDFHandledDocumentFormat的hdfXPS成员把XPS和OXPS文档转成PDF时。标记格式对数字语法很慷慨。PDF内容流不是,而转换边界正是这个差异必须被解决的地方
复现区域而不是假设区域的回归测试
一个只在别人机器上出现的区域bug,值得用自己设置区域的测试钉住,HotPDF有两个。LoadedEditsEmitInvariantNumbersUnderCommaLocale在第一次编辑前给FormatSettings.DecimalSeparator赋逗号,跑RedactLoadedRect、StampLoadedPageText、StitchLoadedPage和一个复合盖章,然后把页面内容字节读回来,既断言确切的算子字符串存在,也断言缓冲区里任何地方都没有逗号。DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale覆盖另一半和原罪:它在同样的逗号区域下驱动创建路径,包括带固定两位小数格式化的DirectDrawBarcode,然后断言内容流不带逗号、EndDoc返回时FormatSettings.DecimalSeparator仍然是逗号
第二个断言正是当初写下当天就能抓住原始缺陷的那个,因为它测试的是库对宿主做了什么,而不只是它往磁盘写了什么。这个教训超越PDF:任何带固定词法文法的格式,包括PostScript、JSON和JavaScript,都需要在边界做显式的不变转换。靠全局设置拿到正确结果的做法,直到有人用不同的顺序、在不同的线程、或从不同的编译器调用你的代码为止都有效,而到那时,失败看起来什么都像,唯独不像格式化问题
这里描述的数字格式化层作为HotPDF Delphi组件的一部分发布,面向Delphi和C++Builder,连同依赖它的已加载文档编辑、条形码、SVG导出和XPS转换路径