将多個 PDF 串接起來聽起來应该是很低成本的。页面内容已經配置好,字体已經嵌入,影像也已經压缩。原则上,合併只是一种记账工作:对对象重新編号以使两个文件的編号空間不再衝突,将页面树状結构拼接在一起,修正交叉参照表,然后写入。实际上,大多数合併程式码都丟棄了这种低成本優勢。对於每个输入文件中的每个对象,它会执行一次完整的剖析以产生标記化的对象树,改變几个間接参照,然后将该树状結构重新序列化回字节。剖析和重新序列化是昂貴的两个半部,而且对於絕大多数的对象來说,它们产生出的字节序列几乎与输入的内容完全相同
PDFlibPas 是适用於 Delphi 和 C++Builder 的原生 Object Pascal PDF 引擎,其快速合併路径的存在就是为了在任何可证明安全的情況下跳过这个來回过程。这个想法虽然狹窄,但卻在整個文件集中獲得了回报:对於未經修改的非流对象,原封不动地採用原始來源字节,并对其所包含的間接参照進行單次的字节級重写,将每个 N G R 變成 (N+Offset) G R。没有标記器,没有对象树,也没有序列化器。本文将逐步探討在哪些情況下这个捷径是合法的、在不損壞任何东西的情況下执行字节重写的剖析器状態機、为什么书签合併需要完全不同的機制,以及普通的合併路径是如何同时从二次方 (quadratic) 被重建为线性 (linear) 的
为什么对象重新編号是合併的真正成本
每个 PDF 都带有自己的对象編号空間。文件 A 有对象 1、对象 2,依此类推;文件 B 有自己的对象 1、对象 2,依此类推。你不能将 B 的对象原封不动地放入 A 的文件中,因为数字会发生衝突,而且 B 内部的每个間接参照现在都会解析为错误的对象。解決方案是一个偏移量 (offset):如果 A 在对象計数 Offset 处結束,那麼 B 的对象 N 就会變成输出中的对象 N+Offset,而任何出现在 B 的对象内部的参照 N G R 都必须被移位为 (N+Offset) G R 以進行匹配
这个移位是合併主体的全部語意工作。页面树状結构的修復和 AcroForm 的合併都是对少数几个对象進行的小型且有限的編輯。大量的工作是重写跨越数千個对象的参照,而执行此操作的天真方法是剖析每个对象,这样你就能从結构上找到参照。PDFlibPas 的 MergeFileListFast 採取了相反的觀点:参照在原始字节中也是找得到的,只要你小心那些数字-空格-数字-空格-R 序列不是参照的情境。跳过剖析,就地進行移位,每个对象的成本就会崩潰为对你本來就要复制的字节進行一次线性扫描
何时重复使用來源字节是可证明安全的
只有当从后續文件中复制出來的对象同时满足三個條件时,才会採用字节路径。只要其中任何一个失敗,该对象就会被送回完整的解码并重新序列化路径,因此正確性始终勝过速度:
Doc2.IsChangedObject(X)为 False。如果合併引擎已經在内存中改變了该对象(例如,一个其/Parent被重新指向的页面对象),则内存中的树状結构就是事实來源,而原始字节已經过时。只有未觸碰过的对象才符合资格- 來源字节不包含
stream关鍵字。流对象的主体是由stream/endstream包围的不透明二進位数据,对压缩或加密的流数据進行天真的参照扫描将会愉快地“找到”并破壞看起來像参照的字节模式。流对象保持原始的支援流路径 - 來源字节既不包含
/StructTreeRoot也不包含/StructElem。在快速设置档中,标签化 PDF 的结构树状結构会被丟棄而不是被合併,因此这些对象必须經过解码路径,引擎才能刻意地将它们清空
这个決定存在於逐对象的复制迴圈中。当所有三项检查都通过时,该对象的字节会直接進入 ShiftIndRefsInSource,然后传送給写入器;否则会捨棄这些字节,并使用 GetObject 重建该对象,用 ShiftIndRef 進行移位,然后再序列化。这个分支的結构值得一看,因为检查的順序正是保持其安全的原因:
ObjectData := '';
if not Doc2.IsChangedObject(X) then
begin
ObjectData := FastMergeObjectSource(Reader2, X);
if (PLPos('stream', ObjectData) > 0) or
((not PreserveStructTree) and (PLPos('/StructTreeRoot', ObjectData) > 0)) or
((not PreserveStructTree) and (PLPos('/StructElem', ObjectData) > 0)) then
ObjectData := '' // fall back to decode
else
ObjectData := ShiftIndRefsInSource(ObjectData, Offset);
end;
if ObjectData <> '' then
Writer.AddObject(X + Offset, Doc2.GetGenNum(X), ObjectData)
else
begin
Obj := Doc2.GetObject(X, TempStruct); // full parse path
// ... null out struct-tree objects, ShiftIndRef, Obj.Output ...
end;
空白的 ObjectData 是表示字节路径拒絕该对象的信号。这个單一的标記可防止快速路径和慢速路径产生分歧:完全只有一个地方做決定,也完全只有一个后備方案
参照移位状態機及其边緣情況
对間接参照進行字节重写很容易出错而具有欺騙性,因为 R 和一連串的数字在不是参照的上下文中出现在整個 PDF 对象的各处。ShiftIndRefsInSource 是一个小型的手写扫描器,它只遍历字节一次,并且只有在一个数字后面跟著(标記之間有 PDF 空白)另一个数字,然后是一个 R 分隔符号时,才会重写该数字。最便宜的出口排在第一位:如果偏移量为零或來源为空,字节将会被原封不动地传回,根本不会進入扫描器
扫描器的正確性建立在辨識出必须不更动状似参照之序列的情境上。这些是最容易被忽略的边界,而每一项都有被明確处理:
- 由
(和)所分隔的字面字串会被逐字复制,同时追蹤巢状深度并遵守反斜线跳脫字符,如此一來被跳脫的括号就不会擾亂深度的計数。像(see object 3 0 R for details)这样的字串包含了一个教科書式的参照模式,但那其实只是散文,它必须一个字节一个字节地保留下來 - 由
<和>所分隔的十六進位字串会未經解释地传递过去。十六進位字串内部的52字节是R的 ASCII 码,而将十六進位有效負载視为文字的扫描器可能会製造出一个幽靈参照。字典开頭的<<会先被偵測到,这样字典才不会被误認为十六進位字串 - 以
/开頭的名稱对象会从斜线开始一直到下一个空白或分隔符号被完整消耗。如果没有这个,像/R这样的名稱(常見的资源鍵)可能会被解读为参照的R - 由
%所引入的注释会执行到行尾,并被当作不透明文字跳过 - 数字后接 R 的測試是严格的。 一个参照只有在
N由空白、分隔符号或输入結尾终止时,才会被辨識为G空白R空白R。如果缺少产生編号 (generation number),或者R后面跟著一个字母,数字将会原封不动地被发出。正是这一点保护了/Length 1234中的整数和MediaBox的四個数字,使其不会被靜默地递增
这个严格測試的核心部分读起來几乎就像规格说明句子所描述的那样:
if (P <= N) and (Source[P] = 'R') and
((P = N) or PLIsPdfWhite(Source[P + 1]) or PLIsPdfDelimiter(Source[P + 1])) then
Obj1 := PLStrToIntDef(PLCopy(Source, I, E1 - I), -1);
if Obj1 >= 0 then
begin
AppendStr(PLIntToStr(Obj1 + Offset)); // shifted object number
AppendBytes(E1, P - E1); // original whitespace + generation
AppendBytes(P, 1); // the 'R'
end;
只有对象編号会被重写;产生編号和标記之間精確的原始空白会被直接复制过去,因此输出与输入除了那一个必须改變的整数之外,在字节层級是完全相同的。这种精確度就是整個重点——这使得重复使用來源字节等同於完整的重新序列化,而不僅僅是接近它。这种行为被一組专注的單元測試所涵蓋,这些測試運行了裸参照、数组内部的参照、非参照数字、字面字串、十六進位字串,以及套用偏移量且不为零的产生編号
为什么书签不能重复使用 AppendOutline
将多個文件的书签合併到一个大綱树状結构中,看起來像是现有 AppendOutline 輔助程式的工作,它已經知道如何将一个文件的頂层书签嫁接到另一个文件上。这里这是错误的工具,原因在於微妙的分层不匹配。AppendOutline 透过让读取器走訪原始文件字节來寻找目前最后一个頂层书签。但是快速合併会透过 ChangeObject 将其編輯階段放在一个新对象緩衝区中;读取器永遠看不到这些編輯。将三個或更多文件串連起來,每次附加都会将第一个文件的原始最后一个书签重新指向最新的文件,因此所有中間文件的书签都会从串連中掉出來——只有累積的 /Count 保持正確,这使得这个错误在有人打开书签面板之前很容易被忽略
快速路径使用一个不会重新走訪读取器的两階段、元数据驅动之注入方式來解決这个問题。第一次遍历所有输入时,会針对每个文件收集大綱根对象和产生編号、第一个和最后一个頂层书签編号,以及根目录的 /Count。根据这个摘要,程式码可以計算出它需要偽造的每个連結的整体对象編号——每个文件的頂层 /Parent 指向共用的根目录,第一个书签的 /Prev 指向先前的文件的最后一个书签,最后一个书签的 /Next 指向下一份文件的第一个书签——使用純粹的对象編号算術。这背后有一个写入順序的限制:第一个文件的对象会在后續的任何文件被打开之前写出,因此第一个文件的所有大綱編輯(根 /Count 和 /Last,以及旧的最后一个书签的 /Next)都必须能夠表達为算術,且不需要手頭上有后續的文件。后續每个文件的編輯都是在它被打开之后、写入之前就地套用,因此它们会經过相同的更改对象路径输出
将其綁在一起的偏移量对齊不變量
参照移位和书签注入都取決於一个算術不變量,这是整個设計中最脆弱的假设。注入到后續文件中的参照被写为目标整体对象編号减去该文件的偏移量 (Offset),如此一來当该对象稍后被 ShiftIndRef(Offset) 移位时,该值就会落在预期的整体編号上。第一个文件的 Offset = 0 并且直接使用整体編号。为了使该减法正確,在注入期間所使用的運行偏移序列必须与最终写出对象时所使用的偏移序列相符
它確实如此,这是因为页面和表單合併運作方式的一项属性:AddPages、AddFields 和 AddFieldFonts 僅修改第一个文件的现有对象——它们从不新增对象。因此,第一个文件的对象計数在整個页面合併階段保持不變,而每个后續文件的偏移量(所有先前文件的对象計数总和)从注入到写出都保持稳定。如果打破这一点——引入一个在合併中途建立新对象的階段——下游的每一个页面和书签参照都会出现偏差,偏差的数量正是你所新增的对象数量。这个不變量是靜默的,但它承擔著重任
三個入口点共用一个引擎
快速路径并不是合併程式码的硬分叉。在同一工作流中,字节級引擎被重构为單一的内部常式 MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode),而公共 API 则變成了选择两个旗标的薄型包裝器:
MergeFileListFast呼叫引擎时关闭结构树保留——这是最精簡的路径,丟棄标签化 PDF 的树状結构,因此字节路由适用於最多的对象MergeFileList呼叫它时打开保留,因此结构树得以保留,且結果仍然是一个可用的标签化 PDF。这个一般路径也繼承了多文件书签和表單的合併MergeFileListStrict打开严格模式:第一次中繼数据传递会在第一个未报告乾淨合併的输入处停止,因此只有在不良文件之前收集到的文件才会被包含在内,而不是跳过不良文件并繼續
将这些路径折疊在一起,也让普通的合併能夠被重建,从成对的 O(N²) 迴圈——合併文件一和二,将結果与三合併,依此类推,每个步驟都重新剖析不斷成長的累加器——變成一个單一的线性传递,每个输入只打开一次。两个長久存在的雙文件和雙流入口点,MergeFiles 和 MergeStreams,都没有被觸动,仍然可供真正想要成对合併的呼叫者使用
关於结构树行为,要说一句实在話,因为它曾让測試套件吃盡苦頭。快速路径的“丟棄”并非完全捨棄:它移除了第一个文件的型录 (catalog) 对 /StructTreeRoot 的参照,但结构树对象本身仍会被写出为孤立对象。因此,快速输出的字节中仍然包含 /StructTreeRoot 字串,你无法透过搜寻该字串來区分快速输出与普通输出——真正的差異在於型录是否仍能抵達结构树,这決定了该文件是否仍是可导览的标签化 PDF
何时应该使用哪條路径
字节路径是一种吞吐量最佳化,适用於当你不需要保留标签化 PDF 结构树状結构,而要組裝大量文件时——例如报告綑綁、对账单批次执行、批次串接等。在对中大型输入集進行重复合併的測量中,重复使用字节缩减了大約 4% 到 13% 的掛鐘时間 (wall-clock time)(取決於对象組合),且对小型或格式错误的输入没有产生新的失敗,因为任何扫描器无法证明安全的对象都会退回到完整的剖析。如果你確实为了无障礙功能而需要保持结构树完整,请使用保留了它的普通 标签化 PDF 合併路径;如果你正在处理的是非常大的單一文件,而不是許多個输入,在关於 透过直接文件存取進行大型 PDF 合併和分割 的伴隨文章中所描述的字节复制技術,在文件层級上应用了相同的“复制字节,避免完整的对象树”哲學
这些合併常式及其快速和严格的變体是 PDFlibPas Delphi PDF 函式庫 的一部分,其文件载有这里所描述的文件清單 API 和合併选项的完整参考数据