PDFium Component 通过一个编辑日志,为 PDF 编辑器提供真正的文档级撤销与重做,外加一个崩溃恢复包。你用 BeginEditTransaction 和 CommitEditTransaction 把改动包裹起来,每一次已提交的事务都会变成一个双向差量,UndoEdit 和 RedoEdit 可以沿任意方向重放它。SaveRecoverySnapshot 会把这份日志持久化,因此一个在会话中途被强制终止的应用,也能重建用户已完成的工作
这里填补的是一个具体的空白。原生的撤销能力只作用于当前拥有焦点的表单字段,因此一个文本框只记得它自己的编辑,别处什么都不记得。页面的插入、删除、重排,页面对象的改动,注释、附件和元数据,全都完全没有日志。仅靠这些构建出来的编辑器,只能在字段内部提供撤销,别处一概不行,而这并不是用户理解的"撤销"
为什么用快照而不是操作日志?
显而易见的设计是记录语义操作——"删除了第 4 页""移动了第 7 个注释"——然后把它们反转过来。这种做法在操作集合还小的时候管用,一旦操作集合变大,每一个新功能都需要一个对应的逆操作,而任何一个逆操作不够精确的功能,都会在一条很长的撤销链上悄悄地把文档搞坏
这份日志转而在事务边界处捕获文档状态,并存储其中的差异。每一个差量都保留最长公共前缀与后缀,并同时携带被删除和被插入的字节,因此既能正向应用,也能反向应用。新增的编辑功能完全不需要专门的撤销支持:它改动的任何内容都已经包含在快照里
这种做法依赖一个前提条件。一次普通的完整保存会产生不确定的标识符和时间字段,因此对一份未经修改的文档保存两次也可能产生不同的字节。所以快照首先要经过可复现输出路径的规范化处理。少了这一步,一个空事务看起来就会像一次修改,撤销也会在自我校验时失败,因为它在提交时计算的摘要,会与之后的文档对不上
事务的实际用法
每个事务都可以带一个可选的标签,这正是编辑器在其撤销菜单中展示的内容:
uses
PDFium;
var
Pdf: TPdf;
Options: TPdfEditJournalOptions;
Info: TPdfEditJournalInfo;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Options := TPdfEditJournalOptions.Default;
Options.MaxEntries := 50; // 撤销深度
Options.MaxStoredBytes := 256 * 1024 * 1024;
Pdf.ConfigureEditJournal(Options);
Pdf.BeginEditTransaction('Delete cover page');
try
Pdf.DeletePage(1);
if not Pdf.CommitEditTransaction then
raise Exception.Create('commit failed - document was reloaded');
except
Pdf.RollbackEditTransaction;
raise;
end;
Info := Pdf.GetEditJournalInfo;
Writeln(Format('entries=%d position=%d stored=%d bytes',
[Info.EntryCount, Info.Position, Info.StoredBytes]));
if Pdf.EditCanUndo then
Pdf.UndoEdit; // 页面恢复回来
finally
Pdf.Free;
end;
end;
一次提交失败时——无论是因为捕获失败、超出预算,还是差量无法构建——都会恢复到 BeginEditTransaction 时捕获的基线并重新加载,而不会留下一个悬而未决的事务,或者一份写了一半的日志。这是最要紧的失败模式,因为一份处于不一致状态的日志,会污染此后每一次撤销
每一步都做校验
每个差量都存储着文档前后两种状态各自的 SHA-256 摘要,以及前缀长度和两份负载。撤销、重做、导入的日志重放和反向重建,在动手之前都会先检查当前摘要以及即将替换的区域,事后再校验得到的摘要
在日志中移动同样非常谨慎。位置只有在目标 PDF 被一个独立的文档对象验证通过、并已成功替换当前文档之后,才会向前推进。如果这一步失败,就会把当前快照恢复为兜底方案,因此一次失败的撤销会让用户停留在原地,而不是陷入一种损坏的状态
序列化格式会被当作数据来校验,而不是被当作可信输入:幻数、版本号、条目数和位置值、摘要链、标签长度、补丁长度以及总存储预算都会被校验。一份导入进来的日志,可能是被截断过的、被编辑过的,或者出自不同版本,把它当作有敌意的输入,是唯一安全的态度
日志填满之后会发生什么
三项限制约束着增长:条目数量、累计差量字节数,以及单个快照的大小。达到容量上限时,最旧的条目会被淘汰,根指纹随之前移
最后这一点带来一个值得提前设计应对的后果。一份导出的日志,只能应用到与它所报告的根指纹相匹配的文档上,或者是它记录过的某个指纹。一旦发生过淘汰,这份日志就再也回不到最初的文件了。因此自动保存必须把匹配的根快照与日志当作一个原子的恢复单元来对待——只保留日志而不保留它的基准,等于保留了一份无法重放的东西
崩溃恢复包
恢复包刻意不内嵌源 PDF。它存储源文件的显示名称、源状态与恢复状态各自的 SHA-256 指纹,以及编辑日志本身。因此恢复过程需要调用方先加载原始文件;随后恢复包会通过正向和反向重放日志来校验这两个指纹:
// 每次成功提交后,刷新恢复文件
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
Pdf.SaveRecoverySnapshot('contract.recovery');
// 下一次会话,崩溃之后:先加载原始文件,再恢复
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');
不内嵌源文件让恢复文件保持很小,这正是让每次提交都做自动保存在任意大小的文档上都负担得起的原因。一个外层摘要覆盖了文件头、源文件名和日志本身,因此一份被截断或被篡改的恢复包,会在任何差量被解析之前就被拒绝,总读取量也有上限
文件写入会先写到目标目录下的一个临时文件,再原子性地替换目标文件,而把恢复路径设为源 PDF 本身会被明确拒绝。自动保存只在一次成功的、产生改动的提交、撤销或重做之后才运行。如果磁盘写入失败,内存中的文档和日志状态本身依然有效,因此抛出的异常报告的是一次持久化失败,而不是数据丢失,调用方可以修正路径后重试
已签名的文档默认会被拒绝
一次事务快照是一个新的规范化修订版本,而不是原始的已签名字节,因此在一份已签名文档上提交事务,会使签名失效。除非调用方传入 AllowSignedDocument,否则在这样的文档上开始事务会被拒绝,日志导入和恢复还原默认也是同样的拒绝行为
这是一个刻意设置的减速带,而不是一道禁令。有些工作流确实需要合理地编辑一份已签名文档并在之后重新签名,这个标志就是为它们准备的。它要防止的是意外情形:一个编辑器悄悄剥夺了一份用户以为仍然有效签名的文档的有效性。这个故事在校验一侧的对应内容见 检查 PDF 数字签名与 PAdES 级别
对一个编辑器来说,有三个习惯能让这份日志物有所值。把每一个用户可见的动作包裹进一个带标签的事务,这样撤销菜单读起来才像用户的心理模型,而不是像你的调用栈。根据文档规模设定条目上限,因为对一份 200 MB 的扫描件保留五十份快照可不是什么保守的设置。并且要在每次提交时都写恢复快照,而不是靠定时器,因为定时器保证丢失的工作量恰好就是上一次触发之后发生的一切。能从中受益的文档级编辑操作见 加密 PDF 的增量更新
编辑日志、恢复包及其背后的编辑原语,都打包在 Delphi、C++Builder 和 Lazarus 使用的同一个组件中;完整 API 说明见 PDFium Component for Delphi 页面