一次什么都没改的 PDF 保存仍可能从两个方向破坏文档元数据:当 /CreationDate 和 /ModDate 指向同一个字符串对象时,自动更新 ModDate 会写进这个对象,把两个日期一起改掉;当 XMP 对象在读取原始 /Metadata 流之前就创建时,一个默认包会顶掉原来的内容。PDF Library for Delphi 用替换字典引用的方式代替就地改写共享对象,并在惰性 XMP 初始化之前先把已有的包捕获下来
保存是 PDF 库所做的操作里最没看点的一个:加载文件,换个名字存回去,中间什么都不碰。前后页面渲染完全一致,内容流哈希也吻合。一个文件可以通过所有这类检查,却在两个任何渲染器都不会给你看的地方出错。这两个坑都坐在每次真实编辑都要走的读改写路径上,所以随便一次保存就足以触发,而且只有用一个独立解析器去比较两个文件的非视觉语义才能把它们揪出来
为什么保存一次 PDF 会改掉它的 CreationDate?
因为文档信息字典允许两个键引用同一个间接字符串对象,而写入器如果更新的是对象而不是键,两个日期就一起变。ISO 32000-1 §7.3.10 允许任何字典值是间接引用,§14.3.3 的表 317 里也没有任何一条说 /CreationDate 下的值必须与 /ModDate 下的值是不同对象。生成器在创建时把同一个时间戳写两遍,然后让两个键都指向同一个 2728 0 R,完全合法,现实中的文件也确实这么干
触发点是自动修改日期。只要没设 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 里的规则很小,背后的原则却很通用:更新字典条目时,替换的是该条目自身的引用(the entry's reference),而不是它恰好解析到的那个对象。代码会先读出已有的 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));
针对这个问题的测试应该有意构造别名,而不是碰运气靠一个恰好共享了对象的文件:一条十六进制字符串被两个日期键引用,一条直接字符串被 /Title 和 /Subject 共享,一条流被 /Author 和 /Keywords 共享。更新每对里的一个键之后,另一个键必须仍读到原值,被更新的字符串必须仍是十六进制。SetInformation 的公开参考文档用一句话写明了这个保证:更新一个 Info 字段只替换那个字段,即使其他字段引用同一个对象
为什么已有的 XMP 包会被默认值顶掉?
因为两行代码的先后顺序。TPDFDocument.GetMetadata 有一条快速路径:XMP 字段已赋值时,它直接返回 XMP.SaveToString,而不是从目录解码 /Metadata 流。写成 XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); 的惰性初始化读起来很自然,却是错的:等 GetMetadata 执行时 XMP 已经赋值,于是被加载的“源”是上一行刚创建的对象序列化出来的默认包。原始包连同它的 dc:creator、自定义命名空间和任何标准标识根本到不了对象里,保存时还被覆盖。同一个自动修改日期就足以触发这一切,因为 SetInfo 在碰 Info 字典之前会先初始化 XMP,好让 xmp:ModifyDate 与 /ModDate 保持同步。注意这个坑藏在哪:第一关的 Info 字典比对能通过,因为 /Info 里的 /Author 和 /Title 没被碰过;变的只有 XMP 树,只有解析并比较这棵树的检查才能察觉
// 错误:GetMetadata 此时序列化的是上一行创建的对象
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// 正确:先捕获 /Metadata 流,再创建并加载
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
PDF Library for Delphi 把这个顺序收拢在一处。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> 元素也完全符合标准,遇到这种包,那个循环会把整份合法文档删光。序列化器的做法是定位声明的收尾 ?>,只删那一段。值得抄走的回归检查要跑两遍,一遍带包装、一遍剪掉包装,断言自定义命名空间标记和原作者在 SetInfo、GetMetadata、SaveToString 和一次重新加载之后仍然活着。第二个坑是预处理器符号。NOVCL 面向 Free Pascal 构建,但 XMP 后端按操作系统开关,不按框架开关,因为 PDFlibXMP.pas 只在缺少 OS_WINDOWS 时才定义 NO_XMP。如果用 NOVCL 去守 SetInfo 里的 Info 到 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 什么都没改?
别拿像素,也别拿内容流哈希,因为两个坑都不动任何页面、任何内容流的哪怕一个字节。能抓住它们的检查,是由独立解析器——与被测库不共享任何代码——对源文件和保存后的文件各拍一份非视觉语义快照,再做结构化比较。快照覆盖:Info 字典(剔除 /ModDate)、书签树(每个书签解析成页码而不是对象号)、命名目的地和链接目标(同样解析成页码)、表单字段值、附件字节(以哈希计)、以及按树解析而非按文本比较的 XMP 包。对象号被刻意排除在外,因为整体重写会重新编号一切,以对象号为键的比较只会满屏噪声
排除项与收录项同样重要。/ModDate、xmp:ModifyDate 和 xmp:MetadataDate 属于预期变化,比较前剔除;源文件本来就没有 XMP 的,不因多出一个包而受罚。这项检查不主张什么同样写得明白:保住一个已有的包,不代表这个包符合模式,也不代表文档满足 PDF/UA 或任何 PDF/A 子集。那些是另一些问题、另一些工具;把“元数据活下来了”混同于“元数据合规”,正是共享日期改写能长期潜伏的原因
如果你在比这些规则更底层的层面上工作,保存如何改写对象的机制在 增量更新与只追加保存一文中有覆盖——那是共享对象原地不动的唯一保存模式;修改级别与版本差异比较则是过期或被改写的日期误导阅读者的另一处场景。同一对 Info 与 XMP 的修复视角——让两半达成一致而不只是原样保留——见 转换 PDF/A 并修复元数据一文
PDF Library for Delphi 是面向 Delphi、C++Builder 和 Lazarus 的原生 Pascal PDF 库,这里描述的读改写路径与你进程里的每一次编辑走的是同一条,所以无论你一天保存一次还是一千次,上面的保证都成立——支持的编译器与平台见 PDF Library for Delphi 产品页