PDF Library for Delphi 在保存期的 peephole 内容流优化里会删除单位文本矩阵操作符 1 0 0 1 0 0 Tm,但只在文本矩阵和文本行矩阵本来就是单位阵时:紧跟 BT 之后,或紧跟更早的一个单位 Tm 之后。单位 cm 仍然总是被删,因为 cm 是与 CTM 相乘,而 Tm 是把两个文本矩阵整个换掉。从 v3.539.28 起,其余的单位 Tm 一律留在流里
这次修的 bug 属于安静型。报表生成器输出 BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET,靠那个单位 Tm 把第二个字符串送回文本空间原点,然后才套用自己的定位逻辑。旧优化器看到六个拼出单位阵的数字,断定这个操作符不可能改变任何东西,把它删了。什么都没失败,什么都没记警告,保存出来的页面上「Total」紧贴着「Invoice」画在同一条基线上——这正是那种直到客户把 PDF 打印出来才有人发现的缺陷
为什么 1 0 0 1 0 0 Tm 不总是 no-op?
单位 Tm 只在它要替换的两个矩阵已经持有单位阵时才是 no-op,而这是它前面那些操作符的性质,不是它自己操作数的性质。ISO 32000-1 §9.4.1 说 BT 把文本矩阵(Tm)和文本行矩阵(Tlm)都初始化为单位阵,§9.4.2 把 Tm 定义成把两者设置为给定值,而不是拼接上去。对比 cm(§8.4.4),它是右乘当前变换矩阵:乘单位阵不改变任何 CTM,所以 1 0 0 1 0 0 cm 在哪儿删都安全。文本对象里则是另一幅图景。Td、TD、T* 和非单位的 Tm 都会移动 Tlm,每个文本显示操作符(Tj、TJ、'、")都会按所画字形的宽度推进 Tm。过了其中任何一个,单位 Tm 就是一次货真价实的回原点。如果你用内容流 CTM 与文本矩阵状态跟踪器手工追踪过文本位置,这就是拼接状态与替换状态的同一条分界
反向扫描怎么决定删哪个单位 Tm
TPDFContentPeepholeOptimizer.RemoveIdentityMatrices 现在从每个单位 Tm 向后回扫,只有先碰到 BT 或另一个单位 Tm 才删。前一个单位 Tm 无论被保留还是自己也刚被列入删除名单都算数,因为两种情况下它都把两个矩阵留在了单位阵,和 BT 干的事一模一样。这条规则把能遇到的每个操作符分进两组:
- 停下并保留 Tm:
Td、TD、T*、非单位Tm、Tj、TJ、'、"、ET、解析器不认识的任何操作符,或流的开头 - 跨过去继续扫:从不碰 Tm 或 Tlm 的操作符,比如
Tf、Tc、颜色设置、gs、marked-content 操作符和cm
保守情形是故意的。未知操作符可能是任何东西,扫描拒绝越过它推理。ET 关闭文本对象,它之后的 Tm 没有 BT 为矩阵值作保。扫描还一次只看一个内容流,这对 /Contents 是数组的页面有影响:某一层从文本对象中间开始、自己没有 BT,它的单位 Tm 会保留,即使上一层本可以让它变成冗余。在怪文件上多花几个字节,换来从不挪动字形。如果你在指令层面编辑页面文本,比如字符到内容字节映射实战那篇,优化器重写的正是同一个解析出的 TPDFContentProgram 模型
uses
PDFlibContentModel, PDFlibContentOptimize;
function OptimizeSnippet(const Source: AnsiString): AnsiString;
var
Prog: TPDFContentProgram;
Optimizer: TPDFContentPeepholeOptimizer;
begin
Result := Source;
Prog := TPDFContentProgram.Create;
try
if not Prog.Parse(Source) then
Exit; // 流损坏:字节原样保留
Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
try
Optimizer.Run; // 返回删除的指令数
finally
Optimizer.Free;
end;
Result := Prog.Emit; // 每行一条指令
finally
Prog.Free;
end;
end;
// 会删:紧跟 BT 的 Tm,以及连续两个单位 Tm 里的第二个
// OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// 会留:Td 之后、Tj 之后、非单位 Tm 之后的 Tm,以及 BT 之外的 Tm
// OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')
把开头那个发票流喂给这个辅助函数,单位 Tm 活下来,因为反向扫描在到达 BT 之前先撞上 Tj。在 BT 和单位 Tm 之间塞进 /F1 12 Tf、2 Tc 和 0 g,它照样被删,因为那些都不碰文本矩阵。像 BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm 这样的序列恰好删掉一个操作符:第一个单位 Tm 重置了被 Td 移动的矩阵,冗余的只有第二个
peephole 优化器到底什么时候跑?
优化器只在压缩环节里跑,也就是 TPDFPageTree.Compress 内部,而且只处理尚未 Flate 压缩的内容流。TPDFlib.SetOptimizeContentStreams(1) 是默认值,同一个开关也暴露为 TPDFlibSaveOptions 的 OptimizeContentStreams 字段;CompressContent 和 CompressPage 都遵守它。/Filter 已经是 /FlateDecode 的流整个被跳过,所以加载一个已压缩的 PDF 再保存,不会重写它的操作符。流解析失败时,原始解码字节原样压缩。TPDFlib.NormalizeContentStreams 按规范间距和数字解析并重排内容,但从不调优化器,这使它成为很好的基线,方便你看出 peephole 规则贡献了多少体积差——此外还有 用字体子集化优化 PDF 文件体积里那些更大的收益
var
Lib: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') <> 1 then
Exit;
// 未压缩流先过 peephole 规则,再走 Flate
Lib.SetOptimizeContentStreams(1);
Lib.CompressContent;
Lib.SaveToFile('report-optimized.pdf');
// 走打包保存选项做同样的选择;False 表示退出
Options.CompressContent := True;
Options.CompressFonts := True;
Options.CompressImages := True;
Options.Linearize := False;
Options.KeepModDate := False;
Options.OptimizeContentStreams := False;
Options.GarbageCollect := False;
Options.PackObjectStreams := True;
Lib.SaveToFileOptions('report-plain.pdf', Options);
finally
Lib.Free;
end;
end;
旧回归测试到底保证了什么?
旧回归测试只保证一种形状:紧跟 BT 的单位 Tm 会被删除。Peephole_RemovesIdentityTextMatrix 把 BT 1 0 0 1 0 0 Tm (hello) Tj ET 喂给优化器,断言不再剩 Tm。更早的一个版本已经注明:Tlm 不是单位阵时删单位 Tm 不安全,但因为测试「锁死」了这个行为,就照旧保留了它。仔细重读,这个测试对 Td 之后或显示文本之后的单位 Tm 什么都没说;把一个样本的覆盖当成整条规则的契约,才是真正的错误。修复保住了原有用例通过,并新增六个用例钉住可删形状与保留形状,包括文本对象之外的 Tm 和跟在 ET 后面的一个
把取舍写下来就很容易接受。把每个文本对象都包成 BT 1 0 0 1 0 0 Tm ... 的生成器仍然能删掉那个冗余操作符,收益几乎全部来自这里。优化器让出的是文本对象中部偶尔出现的单位 Tm,Flate 还没看见之前每页也就几个字节,换来的是模块头里明明白白写着的保证:每个变换都输出等价、绝不改变可见页面。会挪动文字的体积优化器不是优化器,是压缩比好看的渲染 bug
这里讲的内容流解析器、peephole 优化器和保存期压缩选项都随 PDF Library for Delphi and C++Builder 发布,NormalizeContentStreams、CompressContent 和 TPDFlibSaveOptions 也一并暴露,方便你调校每份文档的写法