技术文章

PDF 逻辑对象模型:类型、引用与结构

扒开 PDF 文件的外衣,其最核心的本质不过是一堆相互指来指去的对象的集合。剥去那些数据压缩、交叉引用记录簿以及字节偏移量等外围包装,剩下呈现在眼前的便是一幅对象图(graph):少数几种带有类型标识的值,被各种引用纽带串联在一起,并共同扎根于一个阅读器闭着眼也能摸到的根对象之上。PDF 所能表达的森罗万象,上至一整段文本,下至内嵌的一款字体,乃至于一个数字签名,无一不是由那八种最原始的对象类型,外加一条允许对象之间互相引用的法则拼装而成的。摸透了这些,你会发现这套文件格式余下的一切就都不过是一场顺理成章的搭积木游戏了,再无半点玄机可言

这便是 PDF 的逻辑层(logical layer),它由 ISO 32000-1 规范的第 7.3 条款予以定义,它凌驾于物理文件布局(包括文件头、主体、交叉引用表以及预告标头等,这部分内容本身也是一个值得探究的独立课题,详见 PDF 文件结构的全面技术概览)之上。物理字节被解析后所承载的意义,正是由这套逻辑模型赋予的。阅读器采用的是从后往前倒着读的策略,它先揪出文件末尾的预告标头(trailer),顺藤摸瓜摸到根节点,接着整份文档便如同蛛网般以对象引用对象的形式层层铺展开来。当你面对一个渲染畸形的页面一筹莫展试图调试时,或是着手去编写一个解析器时,亦或是放手让一个代码库去替你组装文档时,你脑子里真正在进行推理推演的,正是这一层面的逻辑架构

仅有八种对象类型,不多也不少

PDF 极为克制地将基本对象类型的数量死死卡在了八种。文档中出现的每一个值都逃不出这八种类型的五指山,正因如此,这套格式即便手伸得再长、包罗再广,也始终没有脱缰失控

布尔值(Booleans) 就是 truefalse 这俩关键字。它们就像开关一样掌控着各种标志位的启停,比如用来决定一条批注到底印还是不印

数字(Numbers) 在表现形式上有两种,但规范硬是把它们当成同一种类型来对待:像 42 这样的整数(integers)和像 3.14 或者 -0.002 这样的实数(reals)。PDF 里根本不存在指数记数法这种东西,所以你在一份符合规范的文件里是绝对碰不到 1e6 这种写法的。坐标位置、字体字号,外加旋转角度,这些统统都是数字

字符串(Strings) 就是把一连串的字节装载起来的容器,要么用圆括号括起来写成 (Hello) 的形式,要么是用尖括号括起来的十六进制编码格式 <48656C6C6F>。这两种记法所编码的内容其实是如出一辙的;只不过十六进制给了那些待在圆括号里会显得十分扎眼的字节一条体面的退路。虽说字符串常常被用来承载文本,但它的本源面目首先是字节,当你需要去对付任何超出了 ASCII 码范围的玩意儿时,认清这一点可是至关重要的

名称(Names) 是一种由正斜杠开头的原子型词法单元(tokens):比如 /Type/Pages/MediaBox 等。名称绝不是什么字符串;它就是一个标识符,专门拿来当字典里的键名(keys)或者是枚举值(enumerated values)用的,只有当两个名称连每一个字节都严丝合缝地完全对应上时,它们俩才算画等号。前面那个斜杠纯粹只是个语法规矩,并不算在名字的本体里面。这就很容易把那些菜鸟给坑了,他们老是觉得 /Times-Roman 和字符串 (Times-Roman) 这俩能互换着随便用;但在格式规范眼里这完全是两码事

数组(Arrays) 就是被方括号框起来的、讲究先后顺序且容得下五花八门类型大杂烩的列表:[0 0 612 792] 代表的是一个页面的矩形边框,数组内部可以随心所欲地混搭各种类型,甚至连指向其他对象的引用也能往里塞。字典(Dictionaries) 堪称是这里的苦力担当。它被包裹在 <<>> 之间,负责把作为键名的名称(name keys)映射到随便什么类型的值上去,在 PDF 里,几乎所有能叫得上名号的实质性结构——页面也好、文档目录也罢,不管是字体还是批注——骨子里全都是一个字典,只不过带上了一个用来亮明身份的 /Type 键名而已

流(Streams) 说白了就是字典屁股后面拖着一条用 streamendstream 这俩关键字夹着的原始字节尾巴。字典的部分负责交代这些字节的底细(比如有多长,以及是不是套了像 FlateDecode 这样用来压缩的滤镜),而那段字节尾巴才是真正扛着重型载荷的主力军:画页面的指令、内嵌的字体程序、图像数据全靠它装。但凡 PDF 觉得体积太庞大或者太偏向于二进制而没法直接硬塞进正文里的东西,都会被扔进流里面

这第八种类型就是 空对象(null object),对应的关键字就是 null。这可是个实打实存在的值,跟缺席不在场完全是两个概念。如果一个字典里某个条目被设置成了 null,系统就会把它当成压根没这回事来处理,若是顺着一条引用摸过去结果发现对象根本不存在,它也会甩给你一个 null 而非直接拉响报错警报。这种宽宏大量是规范刻意为之的:它就是要让一份千疮百孔的文件哪怕缺胳膊少腿也能勉强凑合着对付用,绝不至于直接撂挑子罢工。根本就没有什么第九种类型;PDF 里那令人眼花缭乱的万千世界,全靠这八种基本类型拼装组合变化而来

直接值、间接对象与引用机制

上述那八种类型里的任何一种,出场方式都可以分为两种。一种叫作直接(direct) 对象,也就是直接就地把值写死在里面,比如 MediaBox 数组里面那个干巴巴的 612。另一种则是 间接(indirect) 对象,这属于是挂上了户口的,方便别处的对象能指名道姓地引用到它:户口本上有俩整数,一个对象编号(object number)外加一个生成编号(generation number),并且外面还得套上 objendobj 这套包装盒:

12 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj

这就是那个挂着 12 号牌照、生成编号为 0 的字体字典。文件里的任何其他地方,不管是谁想要指代它,只需用上一个间接引用(indirect reference) 即可:把那俩编号抄过来,后面再跟个字母 R,就成了 12 0 R。这个引用就是个路标指针。当某个页面的资源字典里写着 /Font << /F1 12 0 R >> 时,它就是在宣示 12 号对象就是资源名称 /F1 背后的正牌字体,而用不着把字体的详细资料在那一页上再啰嗦地抄上一遍

至于那个生成编号,纯粹是给对象的删除和槽位回收准备的后路。当某个对象被释放掉,其占用的坑位又迎来了新租客时,生成编号就会往上跳一格,这样一来,那个早就过了期的老古董引用 12 0 R 就绝不可能稀里糊涂地把这 12 号槽位里的新租客给当成自己人了。刚热乎出炉的新文件里,这编号几乎清一色全是 0,可一份经过了反复涂抹修改的文件,这编号就能窜得老高了,要是有哪个瞎了眼的解析器敢无视这个生成编号,那它早晚有一天会摸错门把别人家的对象给揪出来

正是这种间接引用的机制,让 PDF 兼具了高效运转和灵活编辑的本领。定义一次字体、图像或是色彩空间,就能让成百上千个页面不费吹灰之力地蹭来用。哪怕只是一丁点儿的小修小补,也只需以后缀的方式追加一条新的修订版本,直接顶掉那一个出变故的对象即可,哪用得着大张旗鼓地把整个文件重写一遍。交叉引用表就像一本通讯录,它能把对象编号直接变现为字节偏移量,阅读器循着地址一步就能空降到 12 0 obj 的门前,压根不用一页一页地翻,当然了,这属于是物理层面的提速小跑门道了。在逻辑层面,你唯一需要装进脑子里的就是,12 0 R 翻译过来就是“那个户口本上写着 12 0 的家伙。”

文档目录:万物起源之地

顺藤摸瓜总得有个由头,而这个源头就是预告标头里那个名为 /Root 的条目,它的箭头直指 文档目录(document catalog):这玩意儿就是整个对象图里的定海神针兼总树根,它本身也是个字典,里面揣着个亮明身份的 /Type /Catalog。阅读器之所以最先敲开它的门,纯粹是因为预告标头最先被翻出来罢了,而从它这里往下一路摸过去,循着引用的线索就能跑遍文档的每一个角落

这个目录字典其实抠门得很,它身上背着的板上钉钉的硬性指标就只有两个:一个是它的 /Type,另一个就是 /Pages,那是一个通往页面树(page tree)树根的间接引用。剩下的那些零碎全都是可有可无的选装件,它们干的活儿是交代这份文档在全局层面上该摆出个什么做派,而不是直接去管页面里到底画了些啥:/Outlines 指向了书签树,/Names 掌管着以字符串为键名的名称树(name trees),/Metadata 牵着一条 XMP 元数据流(metadata stream),而 /PageMode/PageLayout 则在一旁支招,指导阅读器刚开门迎客时该以什么姿势把文档摊开。渲染一个页面压根就用不上这里头任何一件东西;它们的存在,全都是为了在这堆页面外围搭台子唱戏搞用户体验的。至于那些挂靠在文档目录名下的书签、元数据还有批注等配套设施的底细,在另一篇专讲 PDF 元数据、书签与批注 的文章里有连篇累牍的深扒

下面这张结构图能让你一眼看穿对象主体是如何被裹挟在整个文件的躯壳之中的。文档目录和页面树就像普普通通的间接对象一样,老老实实地挤在那个对象主体的车厢里;而包裹在它们外头的那些个文件头、交叉引用表以及预告标头,全都是为了给阅读器指路定位而搭设的物理骨架脚手架而已

展示了 PDF 文件四个物理分区的示意图:一个带有版本号的文件头,一个装载着包括文档目录和页面树在内的所有文档对象的对象主体区,一张记录着对象偏移量的交叉引用表,以及一个指向根节点的预告标头

页面树:一套讲究平衡的页面层级架构

顺着 /Pages 摸下来,文档就此分岔舒展开来化作了一棵页面树,到了这儿,PDF 当初舍弃扁平的流水账清单而执意要选用对象图架构的好处,终于是拨云见日了。页面并不是被当作一根光溜溜的绳子串在一起的;它们是作为叶子挂在了一棵树上的,这棵树中间的那些枝丫被称为 页面树节点(page tree nodes)(其标志是 /Type /Pages),而末端的叶子自然就是 页面对象(page objects)(其标志是 /Type /Page)。一个中间节点会把它名下的子节点一股脑儿地塞进一个 /Kids 数组里,并用一个 /Count 来记账,精确到个位地标明它底下到底挂了多少片叶子页面。除了树根之外,每一个节点身上都背着一个指回它上级长辈的 /Parent 引用,这就好比是在树上搭了部双向电梯,上下攀爬自如

2 0 obj                                  % root of the page tree
<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>
endobj

3 0 obj                                  % a leaf page
<< /Type /Page /Parent 2 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F1 12 0 R >> >>
   /Contents 5 0 R >>
endobj

4 0 obj                                  % an interior node grouping two more pages
<< /Type /Pages /Parent 2 0 R /Kids [6 0 R 7 0 R] /Count 2 >>
endobj

在这里,2 号对象稳坐树根宝座,它的荫庇之下总共藏了三个页面:一个是单蹦儿的 3 号叶子页面,另外俩则被雪藏在 4 号这个中间节点的身后,得通过它才能摸得到。树根上的那个记着 3 的 /Count 值,必须跟它底下实际能扒拉出来的叶子总数严丝合缝地对上账,要是这账本跟实际结构对不上,这往往是一份被瞎改过的文件搞出乱子的重灾区。弄出这么棵树来,图的就是个“近水楼台先得月”的局部访问效率。试想一下,当一个阅读器要在一本厚达千页的文档里翻开第 900 页时,它犯得着去把 900 个对象挨个检阅一遍吗?压根不需要,它只需顺着树干溜达过屈指可数的几个节点就稳稳降落了,因为一棵搭得规规矩矩的树,肯定是又扁平又平衡的。想要亲手把这么一棵树给徒手捏出来,那繁琐程度绝对值得你从头到尾去见识一番,这事儿在那篇 从零开始构建一个 PDF 文档 的手把手教程里有着原汁原味的实况转播

这棵树靠着 继承机制(inheritance) 挣到了它的第二份工钱。有那么一小撮页面属性,比如 /Resources/MediaBox/CropBox 以及 /Rotate 等,完全可以大大咧咧地只在某个中间节点上挂一次号,然后让底下的那些单页去吃现成的,它们只需顺手把离自己最近的长辈身上的值顺过来用就行了。只要在树根上交代一句 /MediaBox,底下的每一片叶子就都穿上了同一套尺寸的制服,再也用不着去重复废话;只有那个想要搞特殊穿奇装异服的页面,才需要自己站出来扯着嗓子单报一个尺寸。纵观整个对象模型,这是唯一一处——一个值到底是个什么名堂,居然不仅取决于它自己肚子里装了啥,还得看它在这棵树的族谱里排在哪个辈分上

一片叶子页面里究竟装了些啥门道

页面对象就是一个将结构模型与肉眼可见的内容这两端死死铆接在一起的连接枢纽。它肚子里的那个 /Contents 条目指向了一段或多段内容流,正是那些负责把文本和图形挥洒到页面上的绘制指令(drawing operators)。而它的 /Resources 字典则就像是一份进货单,点名道姓地列出了那些指令需要仰仗的字体、图像以及色彩空间,这上头的每一笔账,其实都是一个指向某个跨页面共享对象的间接引用。那个 /MediaBox 则以磅(1/72 英寸)为单位圈定出了页面的矩形边框,而像 /Rotate 以及 /CropBox 这些条目,则在一旁负责给这个页面做最终的整形和摆放微调

这番各司其职的明确分工,活脱脱就是把整个大模型的骨架微缩到一页纸上的一场预演。页面字典撑起的是结构骨架:通过带有各种类型标签的条目以及引用,来大声宣告这个页面是个啥玩意儿以及它手里握着哪些画画的家什。而内容流则是那一套纯粹的指令集:它是一个完全独立的、能被压缩打包的软体团子,专职负责指导该怎么去画画。至于躲在 /F1 背后的那套字体,则是一件公用资源,一处立牌坊,处处只需拿指头指一下就能用。字典、数据流外加引用机制,这三个伙计通力合作就能把一个页面给渲染出来,而同样的套路要是放大来看,那就足以撑起整整一本厚重的文档。至于那个软体团子里头装的到底都是些啥画画的把式,在关于 文本与字体 以及关于 图形与视觉元素 的文章里,都已经把它们扒得干干净净、分门别类地讲解透彻了

看透这套模型到底图个啥

绝大多数开发者跟这个对象模型的初次照面,往往都是在出了乱子的时候:页面变成了一张白纸,因为它的 /Contents 引用断线风筝般地指到半空去了;文本变成了一排排方块框,因为一份字体资源从头到尾就没被嵌入进去;工具软件跳出来弹窗报错,说它查到的 /Count 值跟它实际能翻找出来的页面数对不上账。所有这些,说到底全都是这张对象图(graph)在那儿喊疼的症状表现,去直接给这张图把脉问诊,绝对比你在那儿瞎猜管用得多。那区区八种类型外加一条引用法则,这点词汇量对于你的脑容量来说绝对是绰绰有余的,而一旦你能把 PDF 堪透成一堆指来指去的对象的把戏时,那些动不动就抽风罢工的文件在你的眼里也就跟脱光了没啥两样了

话虽如此,要是抛开为了弄懂原理这个目的不谈,自己亲手去徒手捏这么一套模型,那绝对是自讨苦吃的下下之策。像什么去死抠那些交叉引用表里的偏移量对不对得上、生成编号是不是跟乱了套、页面树上的总账是不是算错了,以及在修改之后还得去盯紧那些数据流的长度有没有对齐,这种纯粹折磨人的做账差事,原本就该是打发给专门的开发库去当苦力的。在实打实的生产线环境里,一套 久经沙场考验的成熟 PDF 开发库 自会把那繁杂的对象图(object graph)打理得井井有条,好让你能腾出手来,舒舒服服地把心思全扑在排版页面和规划内容上。但这绝不意味着懂这套模型就毫无用武之地了:因为你门儿清那套库背地里到底在给你捣鼓些啥名堂,以及它为什么要这么捣鼓