PDF Library for Delphi v3.539.18 和 v3.539.20 修掉了一次什么都没改的 PDF 保存仍然能破坏文档元数据的两种方式:当 /CreationDate 和 /ModDate 引用同一个字符串对象时,自动的 ModDate 更新把两者一起重写了;而当 XMP 对象在原始 /Metadata 流被读出之前就创建时,一个默认包替换掉了原始包。两个修法分别是替换字典引用而不是改动共享对象,以及在惰性初始化 XMP 之前先把已有的包抓下来
这个操作是 PDF 库能执行的最无聊的一件事:加载文件,另存为一个新名字,中间什么都不动。页面在前后渲染结果一致。内容流哈希也对得上。文件通过了我们当时所有的检查,而它仍然在两个任何渲染器都不会展示给你的地方是错的。两个缺陷都落在每次真实编辑都要经过的 read-modify-write 路径上,所以随便一次保存都足以触发它们,而两者都是靠第二个独立解析器比较两份文件的非视觉语义才发现的
为什么保存一次 PDF 会改变它的 CreationDate?
因为文档信息字典允许两个键引用同一个间接字符串对象,而库改的是那个对象,不是那个键。ISO 32000-1 §7.3.10 允许任何字典值是间接引用,而 §14.3.3 Table 317 里没有任何一句要求 /CreationDate 下面的值与 /ModDate 下面的值必须是不同对象。一个在创建时把同一个时间戳写了两次的生成器完全可以合法地把两个键都指向同一个 2728 0 R,我们本地语料库里一份 CJK 设计文档正是如此
触发它的是自动修改日期。除非设置了 UserModDate,SaveToFile 会在写入之前用当前时间调用 SetInfo('ModDate', ...),进而走到 SetRawInfo。旧的 SetRawInfo 查出该键下面的对象,如果发现是个 TPDFString,就对它调用 SetTo。这是对键当前解析到的那个对象做原地写入,而当那个对象是共享的,/CreationDate 现在也报告保存时间了。文档照样能打开、能打印、渲染像素级一致,所以视觉回归套件连眼睛都不会眨一下
var
Lib: TPDFlib;
Before, After: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('design.pdf', '');
Before := Lib.GetInformation(7); // 7 = CreationDate,8 = ModDate
Lib.SaveToFile('design-resaved.pdf');
Lib.LoadFromFile('design-resaved.pdf', '');
After := Lib.GetInformation(7);
if Before <> After then
Log('a save that changed nothing rewrote CreationDate');
finally
Lib.Free;
end;
end;
TPDFDocument.SetRawInfo 里的修法很小,背后的原则却是通用的:更新一个字典项就是替换该项的引用,绝不是去改它碰巧解析到的对象。新代码先读出已有的 TPDFStringMode,好让十六进制字符串保持十六进制、字面字符串保持字面,然后通过 FStructure.NewString(Value, StringMode) 在键下面新增一个字符串。另外两个细节和这个头条改动一样重要。原来针对流值项的分支会先用 SetTo('') 清空流再替换,而那会把所有还指向该流的其他键的值一起清空,所以那段清空被删掉了。而被顶掉的对象不删除,因为结构拥有它,别的引用可能还需要它
// 修前:改动键当前解析到的那个对象
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// 修后:保留表示形式,只替换这个键自己的引用
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Tests\SharedInfoSemantics.inc 里的回归测试是刻意把别名关系搭出来的,而不是依赖语料库文件:一个十六进制字符串同时被两个日期键引用,一个字面字符串被 /Title 和 /Subject 共用,一个流被 /Author 和 /Keywords 共用。更新每一对里的一个键之后,另一个必须仍然读到原值,而被更新的字符串必须仍然是十六进制。SetInformation 的公开参考文档现在用一句话写明了这个保证:更新一个 Info 字段只替换该字段,即使其他字段引用同一个对象
为什么已有的 XMP 包会被默认值替换掉?
因为两行的顺序。TPDFDocument.GetMetadata 有一条快路径:当 XMP 字段已经被赋值时,它返回 XMP.SaveToString,而不是去解码 catalog 里的 /Metadata 流。好几处调用点都这么惰性初始化:XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);,读起来很自然,但它错了:等到 GetMetadata 执行时 XMP 已经赋值,于是被加载的这个「来源」其实是上一行刚创建那个对象的序列化默认包。原始包里的 dc:creator、自定义命名空间以及任何标准标识都到不了这个对象,保存时被覆盖掉。同一个自动修改日期就足以触发它,因为 SetInfo 在碰 Info 字典之前会初始化 XMP,好让 xmp:ModifyDate 和 /ModDate 保持一致。注意这个缺陷藏在什么后面:第一个 bug 的 Info 字典比较是通过的,因为 /Info 里的 /Author 和 /Title 没被动过。变的只有 XMP 树,而只有一项会解析并比较这棵树的检查才能发现
// 错的写法:此时 GetMetadata 序列化的是上一行刚创建出来的对象
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// 对的写法:先抓下 /Metadata 流,再创建并加载
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
修法做两件事。TPDFDocument.EnsureXMP 现在在 TPDFlibXMP.Create 之前先抓 Source := GetMetadata,文档里每一处惰性初始化都被换成对它的调用:SetInfo、SetXMPInformation、GetXMPInformation、PDF/A、PDF/X、PDF/E、PDF/VT、PDF/VCR 和 PDF/UA 的模式设置器,以及元数据修复路径。SetXMPProperty 这类公开入口本来就经过 EnsureXMP,而 GetXMPProperty 是通过 GetDocumentMetadata 读的,所以整个接口面共用同一个初始化顺序。一份正确的三行序列,价值高于十份今天恰好互相同的副本
同一条路径上还有两个更小的坑
Windows 上的 XMP 序列化器用的是平台 XML 写入器,它会输出一条 XML 声明,而包里不该带这条声明。旧代码删字符一直删到 <?xpacket 来剥掉它。ISO 16684-1 §7.3.2 规定 xpacket 包装是可选的,一个只写裸 <x:xmpmeta> 元素的生成器完全在标准之内,所以在这样的包上那个循环把整份合法文档都删掉了。序列化器现在定位声明的结束 ?>,只移除这一段。Tests\XMPRetentionSemantics.inc 把留存检查跑两遍,一遍带包装、一遍把包装切掉,断言自定义命名空间标记和原始作者在 SetInfo、GetMetadata、SaveToString 和一次重新加载之后都还在。第二个坑是一个预处理器符号:SetInfo 里的 Info 到 XMP 同步由 NOVCL 守着,这个符号在 Free Pascal 构建里是定义的,但 XMP 后端是由操作系统而不是由框架决定的,因为 PDFlibXMP.pas 只在缺少 OS_WINDOWS 时才定义 NO_XMP。于是 Windows 上的 Lazarus 构建有一个能用的 XMP 对象,而 SetInfo 却静默跳过对它的更新。现在的守卫是 NO_XMP,所以 Windows 上的 Free Pascal 应用得到的同步和 Delphi 一样
怎么在一次直通保存里保住原来的 ModDate?
在 TPDFlibSaveOptions 里设置 KeepModDate,然后通过 SaveToFileOptions 保存。这个选项会在该次调用期间设置 UserModDate,SaveToFile 随后就跳过自动时间戳,而这一步同时也是惰性初始化 XMP 对象的那一步。一份你从没动过元数据、也没有启用任何合规模式的文档,会把 Info 字典和 /Metadata 流都按加载时的样子保留下来。调用 SetInformation(8, ...) 有同样的永久效果,因为你亲自设置修改日期就把它标记成了用户控制的
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // 不做自动 /ModDate,也不惰性初始化 XMP
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
对你从中得到什么要诚实。KeepModDate 对一个输出应当描述与输入同一修订的直通步骤来说是正确的选择,而对任何真的编辑内容的操作来说都是错误的,因为 §14.3.3 期望 /ModDate 反映最近一次修改。它也不能回溯性地修好一个会改动共享对象的库;它只是避开了那次暴露缺陷的写入。上面两个修法才是让普通保存变安全的东西,而这个选项才是让一次刻意的空操作变得诚实的东西
怎么验证一次保存除了 ModDate 什么都没改?
不能靠像素,也不能靠内容流哈希,因为这两个缺陷让每一页、每一条内容流都逐字节相同。抓住它们的检查是由一个独立解析器——与被测库不共享任何代码——对源文件和保存后的文件分别取的非视觉语义快照,再做结构比较。快照覆盖排除 /ModDate 后的 Info 字典、每个书签都解析成页码而不是对象号的大纲树、以同样方式解析的命名目标和链接目标、表单字段值、以哈希表示的附件字节,以及按树解析而不是按文本比较的 XMP 包。对象号刻意不在其中,因为一次完整重写会给所有东西重新编号,以对象号为键的比较只会报一堆噪音
被排除的东西和纳入的东西一样重要。/ModDate、xmp:ModifyDate 和 xmp:MetadataDate 预期是会变的,比较前先丢掉;源文件完全没有 XMP 的文件不会因为多出一个包而被扣分。这项检查不声称什么也同样明确:保住一个已有的包,并不说明这个包符合 schema,也不说明文档满足 PDF/UA 或任何 PDF/A 部分。那些是另有工具去回答的另一些问题,而把「元数据保住了」和「元数据是合规的」混为一谈,正是第一个 bug 能藏那么久的原因。在库这一侧,这两个回归现在跑在 Delphi Win32 与 Win64 以及 Free Pascal Win32 与 Win64 的每一次目标平台上,而语义比较是真实文档语料基准的一个通过条件
如果你想往下钻到这些修法之下的层次,一次保存如何重写对象的机制写在增量更新与追加式保存里,那也是唯一一种会把共享对象原地留下的保存模式;另一个会因为日期陈旧或被重写而误导读者的地方写在修改层级与修订比较里。同一对 Info 和 XMP 在修复侧的视角——让两半互相对齐而不只是原样保留——写在转换为 PDF/A 与修复元数据里
PDF Library for Delphi 是面向 Delphi、C++Builder 和 Lazarus 的原生 Pascal PDF 库,这里描述的 read-modify-write 路径正是你自己进程里每一次编辑都要经过的同一条路径,所以上面的保证不论你一天保存一次还是一千次都成立——支持的编译器与平台见 PDF Library for Delphi 产品页