要弄清楚 PDF 文件体积到底花在了哪里,losLab PDF Library 提供了 AuditDocumentSpace,它会把每一个间接对象归入十二个类别——图像、字体程序、字体字典、内容流、form XObject、对象流、嵌入文件、元数据、结构树、注释、页面树、其他——并报告每个类别的对象数量、存储字节数和占比
这个功能针对的场景很常见。一份 40 页的报告从你的生成器出来是 80 MB,客户问为什么,你能给出的只是猜测。可能是图像。也可能是字体。于是你打开降采样,发出去,结果文件变成了 74 MB,因为真正占分量的东西完全在别处。我们的姊妹篇文章讲了字体子集化和图像降采样,介绍了如何缩小 PDF;这一篇讲的是本该排在第一步的事情——测量你正打算缩小的到底是什么
为什么要先测量再压缩
因为三种标准优化手段在任意给定文件上的收益天差地别,而在你数清楚之前,文件本身不会告诉你该用哪一种。对一份字体已经只占 2% 字节的文档做子集化,等于花一下午时间去挪一个舍入误差。在一份体积主要来自未压缩内容流的文件上做图像降采样,得到的失望也是一样的。优化器本身不难——每个库都有。难的是知道该把哪个优化器对准这份文件,而这是一个记账问题,不是压缩问题。审计还能揪出那些根本不需要优化器的情况:一份文件如果发现有 60% 是嵌入附件,它需要的不是更好的压缩,而是一次关于这些附件是否该留在文档里的对话;一份文件如果有 30% 是结构树,那是在为可访问性标记付费,这通常是一项有意为之的成本,不该被悄悄剥掉。字节一旦被归因清楚,你做出的就是一个有数字支撑的产品决策,而不是随手够到哪个开关就扳哪个
十二类报告里都有什么
AuditDocumentSpace 返回的是字符串列表句柄而不是记录,这样报告能原样穿过扁平化的 DLL 和 COM 外观层。列表里有一行 Total,Objects,Bytes,100.0 汇总行,后面紧跟固定顺序的十二行 Category,Objects,Bytes,Percent,这个顺序是契约的一部分:图像、字体程序、字体字典、内容流、form XObject、对象流、嵌入文件、元数据、结构树、注释、页面树、其他。永远是十三行,即便某个类别为空也一样
var
Lib: TPDFlib;
ListID, I: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') <> 1 then
Exit;
ListID := Lib.AuditDocumentSpace; // 0 when no document is selected
if ListID = 0 then
Exit;
try
// GetStringListItem is 1-based: items run 1..GetStringListCount
for I := 1 to Lib.GetStringListCount(ListID) do
Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
finally
Lib.ReleaseStringList(ListID);
end;
finally
Lib.Free;
end;
end;
这段循环里有一个 Delphi 细节会让你正好栽一次跟头。GetStringListItem 用的是从 1 开始的索引,和 GetStringListCount 保持一致,越界索引会返回空字符串而不是抛异常。要是习惯性地写成 for I := 0 to Count - 1,你会得到一个空白的第一行、一个悄悄丢失的最后一行,而且没有任何异常来告诉你索引方式错了。报告本身看起来几乎是对的,而这正是诊断工具能出现的最糟糕的失败模式
为什么审计用的是存储长度而不是解码后的大小
因为存储长度既是你想要的数字,又是获取成本低廉的数字。每个间接对象都带有 TPDFIndObj.FLength,也就是该对象在文件中解析出来时占用的原始字节长度。用它意味着一张 900 KB 的 DCTDecode 图像会被报告为 900 KB——也就是它在磁盘上花掉你的字节数——而不是它解码出来的 40 MB RGB 采样数据。这也意味着审计过程永远不需要解码任何东西:惰性加载的对象继续保持惰性,过滤器不会被执行,审计一份 500 MB 的文件只是扫一遍对象头,而不是一整轮解压过程
第二条规则是一道防止重复计数的保险。当一个对象存活在某个压缩对象流内部——由非零的 FObjStrNum 标识时——它的字节数会被记为零。它的存储成本已经由容器流付过一次了,而 ISO 32000-1 §7.5.7 把容器流定义为一个 /Type /ObjStm 流,在一个经 Flate 压缩的负载里装着许多对象。如果既给每个成员单独计一份份额,又给容器再计一次,总数就会虚高到超过文件的真实大小。这一点对你该如何解读输出有直接影响,下文会讲到,我们关于对象流与交叉引用流的文章里也有更深入的说明
为什么字体程序不能自己给自己分类
因为嵌入在 PDF 里的 TrueType 字体文件没有任何标记能说明自己是什么。ISO 32000-1 §9.8.1 把嵌入字体程序定义为字体描述符里 /FontFile、/FontFile2 或 /FontFile3 的值,而这个引用另一端的流字典带有 /Length1 和过滤器键,却没有 /Type,也没有能表明它是字体的 /Subtype。单独去看,它就是一段匿名的二进制流。只有指向它的那个描述符知道它到底是什么。同样的不对称也出现在注释上:§12.5.2 把注释字典里的 /Type /Annot 定为可选,所以可靠的信号是"是否出现在某个页面的 /Annots 数组里",而不是字典本身
所以分类要跑两遍。第一遍读取每个对象自己的 /Type 和 /Subtype,先拿下容易判断的那些:/ObjStm、/Subtype /Image、/Subtype /Form、/Type /Font 和 /Type /FontDescriptor、/Metadata、/EmbeddedFile 和 /Filespec、/StructTreeRoot 和 /StructElem、/Annot、/Page 和 /Pages。其余的暂时都归进"其他"。第二遍则沿着引用一方走一遍并做覆盖:每个页面字典把自己的 /Contents 重新归类到内容流,把 /Annots 里的条目归类到注释,把 /Thumb 归类到图像,而每个字体字典则沿着自己的描述符链走一遍
// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
MarkRef(Descriptor.FindValueByKeyName('FontFile'), catFontPrograms);
MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));
解读报告,决定下一步怎么走
先看占比,再看对象数量,把两者之间的巨大落差当作一个信号。现代 PDF 会把大部分小字典放进对象流里,所以"页面树"和"结构树"经常会显示出几十个对象,对应的字节数却接近于零——它们的真实成本已经被折叠进了"对象流"那一行。如果"对象流"这一行本身就很大,说明这份文件里堆满的是类似元数据的结构,而不是内容,该拉的杠杆是精简对象,而不是压缩它们。注释外观流的表现类似:它们带有 /Subtype /Form,所以一份盖满图章的文档,其分量会体现在"form XObject"那一行,而"注释"那一行始终很小
function CategoryShare(Lib: TPDFlib; ListID: Integer;
const Category: string): Double;
var
I: Integer;
Parts: TArray<string>;
Inv: TFormatSettings;
begin
Result := 0;
Inv := FormatSettings;
Inv.DecimalSeparator := '.'; // the report is locale-independent
for I := 2 to Lib.GetStringListCount(ListID) do // line 1 is Total
begin
Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
if (Length(Parts) = 4) and SameText(Parts[0], Category) then
Exit(StrToFloatDef(Parts[3], 0, Inv));
end;
end;
如果你要自己解析百分比而不是直接显示它,有两个格式细节很重要。小数分隔符永远是字面上的英文句点,不受机器区域设置影响,所以在德语或法语工作站上用当前环境的 FormatSettings 去解析,要么会失败,要么更糟,会解析出错误的值。而且尾部的零会被裁掉,所以一个恰好占 40% 字节的类别打印出来是 40,而不是 40.0——不要假设它有固定的小数位数。拿到占比之后,后续路由就是机械化的了:图像占比压倒性高就指向 DownsampleImages,字体程序占比压倒性高就指向 SubsetEmbeddedFonts,内容流体积庞大就指向 CompressContent
审计有意不告诉你的东西
总数是对所有间接对象求和,而一个 PDF 文件比它的对象总和还要略多一点。文件头、trailer、对象之间的空白字符,以及经典的交叉引用表,都不是间接对象,所以这些字节没有被归到任何类别,审计总数会比磁盘上的实际大小略小一点。交叉引用流则不一样——它是一个真正的对象,带有 /Type /XRef,所以在一份现代文件里,这些字节确实会出现,归在"其他"类别里。这两种行为都不是缺陷,但如果你要把审计结果和文件系统里的字节数对账,差距就是从这里来的
还有两个边界值得明说。第一,这些数字描述的是一份已加载的文件,而不是正在编写中的文件:对于内存中构建、尚无存储长度的对象,大小会退化为对最终写出结果的估算,外加流字典的一个名义余量,这是对最终写入结果的估计,而不是实测值。如果想要精确数字,请在保存并重新加载之后再做审计。第二,一行很胖的"其他"是一个发现,不是一份 bug 报告——它通常意味着存在已经没有任何东西引用的孤立对象,这该交给标记-清除垃圾回收去处理,而不是任何压缩流程
这样用起来,审计就改变了对话的形态。你不再对着那份 80 MB 的报告瞎猜,而是打开它、跑一次调用,读到图像占 8%,字体程序占 61%,而这份文档为了一套只用三种字形的公司样式,嵌入了九套完整的字体程序。这是一个可以修复、而且带着具体数字的答案。AuditDocumentSpace,连同它为你指出的优化流程,都包含在 losLab PDF Library for Delphi and C++Builder 之中,参考页面记录了完整的类别列表以及围绕它的字符串列表 API