PDF 在本质上是一个纯文本容器。在十六进制编辑器中打开大多数文件,其顶部是可读的:一个版本注释,然后是一系列编号的对象,接着是一个小索引,最底部还有一个告诉阅读器从哪里开始的指针。去掉压缩后,该格式非常容易理解,以至于你可以在文本编辑器中输入一份能工作的文档并让查看器打开它。这样做一次,比起你阅读再多的规范,都能让你学到更多关于 PDF 是如何组合在一起的知识,因为你必须手动将各个对象连接起来,而且如果连接不正确,文件就无法打开
本演练将构建一个切实能渲染内容的最小 PDF:一页,使用内置字体的 "Hello, World!" 字样,打印在美式信纸(US Letter)尺寸上。完成的文件只需要五个对象以及围绕它们的一些簿记行。我们将先编写对象,然后组装头部、交叉引用表和尾部,它们将这些对象绑定为一个阅读器能够接受的文件
查看器所坚持要求的五个对象
阅读器并不会自上而下扫描 PDF 来寻找内容。它从尾部开始,跟随对文档目录的引用,并从那里遍历一条对象链。该链上的每个对象都必须存在,否则打开就会失败。对于单页文档,该链很短,且每个链接都只有一个工作:
- Catalog 是根节点。它是尾部所指向的对象,且在这里它唯一必需的条目是对页面树的引用
- Pages 是页面树节点。它列出文档中的页面并报告有多少页
- Page 描述一个物理页面:它的大小、它用来绘制的资源,以及由哪个内容流来绘制它
- Content stream(内容流)包含绘图运算符,即那些将文本和图形放置在页面上的后缀命令
- Font 声明了内容流所引用的字体。使用 14 种标准字体之一,你就不必嵌入任何东西
每个对象都被编号并可寻址。间接对象的书写方式为 N 0 obj ... endobj,其中 N 是对象编号,0 是它的代数号(在你新写的文件中总是 0)。在文件中的其他任何地方,你都可以用引用来指向该对象:5 0 R 表示“对象 5”。这些引用就是连线。在我们的编号中,Catalog 持有 2 0 R 以到达页面树,页面树持有返回指向页面的引用,依此类推。如果编号弄错了,阅读器就会跟随一个悬空指针,什么也找不到
名称、字典和流
这三种语法几乎承载了一切。名称(name)以斜杠开头:/Type,/Page,/F0。名称是区分大小写的标识符,而不是字符串,PDF 将它们用于字典键和标记对象是什么。字典(dictionary)是一组包裹在双尖括号中的键值对,其中每个键都是一个名称:<< /Type /Page /MediaBox [0 0 612 792] >>。值可以是数字、名称、方括号中的数组、引用,或是嵌套的字典。大多数 PDF 对象都是字典
流(stream)是一个字典,其后跟着介于关键字 stream 和 endstream 之间的一块字节。那是页面绘制运算符所在的地方,在真实文件中,也是压缩图像和嵌入字体所在的地方。流字典描述了这些字节;在生产文件中,它必须带有一个给出确切字节数的 /Length 条目,如果数据经过压缩,通常还会有一个诸如 /FlateDecode 的 /Filter。我们将依靠工具来填写 /Length,因为在这项练习中手动计算字节数没有任何教育回报,反而极有可能因为差一错误而导致文件损坏
编写对象
以下是按顺序排列的五个对象。在阅读内容流之前需要记住的坐标细节是:PDF 以点为单位从页面的左下角开始测量,其中 1 点等于 1/72 英寸,且 Y 轴向上增加。一张 US Letter 页面的大小为 612 乘以 792 点,因此 50 700 位于左上角附近,而不是底部
1 0 obj
<< /Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<< /Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<< /Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Resources << /Font << /F0 4 0 R >> >>
/Contents 5 0 R
>>
endobj
4 0 obj
<< /Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
endobj
5 0 obj
<< /Length 44 >>
stream
BT
/F0 36 Tf
50 700 Td
(Hello, World!) Tj
ET
endstream
endobj
阅读这些引用,结构便一目了然。对象 1(Catalog)将其 /Pages 条目指向对象 2。对象 2(页面树)在 /Kids 中列出对象 3,并声明 /Count 1。对象 3(Page)将 /Parent 指向回对象 2(树和页面相互引用,这是必须的),用 /MediaBox 设置自己的大小,在其 /Resources 中以本地名称 /F0 暴露该字体,并将对象 5 命名为其内容。对象 4 是字体:/BaseFont /Helvetica 选取了每个符合标准的阅读器都已经拥有的 14 种标准字体之一,因此不需要嵌入任何内容。对象 5 是内容流
内容流到底说了什么
流主体是 PDF 页面描述语言中的一个小程序,它是后缀式的:操作数在前,消耗它们的运算符在后。五行代码就完成了所有工作。BT 和 ET 打开和关闭一个文本对象;所有定位或显示文本的内容都必须放在它们之间。/F0 36 Tf 将当前字体设置为名为 /F0 的资源,大小为 36 点(Tf 是“设置文本字体和大小”)。50 700 Td 将文本位置移动到页面坐标 (50, 700)。(Hello, World!) Tj 显示字符串,PDF 将该字符串写在括号中作为字面文本,使用 Tj 将其绘制在当前位置。如果省去 BT/ET,严格的阅读器就会拒绝文本运算符;如果在 Tj 之前忘记设置字体,就没有用来绘制的当前字体
流字典中的 /Length 44 是 stream 和 endstream 之间的字节数,并且它必须是准确的。这是一个值得交给工具来处理而不是手动计算换行符的值,尤其因为你的编辑器将行尾写成 LF 还是 CRLF 会改变总数
头部、交叉引用表与尾部
对象是内容。而另外三个结构部分将它们转换为一个文件。第一个是头部,也就是第一行,它命名了格式和版本:
%PDF-1.7
% 在 PDF 语法中开始一行注释,但阅读器将这个特定的注释视为格式签名并从中读取版本。真正的写入器会紧跟其后加上第二行包含高位字节的注释,这是向文件传输工具发出的暗示,表明该文件是二进制的,绝不能将其作为文本进行破坏
在文件的末尾是交叉引用表,正是这个索引使得随机访问成为可能。它记录了从文件开头算起的每个对象的字节偏移量,以便阅读器可以直接寻址到对象 3,而无需先解析对象 1 和 2。该表格式非常严格:条目是固定宽度的,包括行尾在内每个条目 20 字节,格式为一个 10 位的偏移量、一个 5 位的代数号、一个关键字(n 表示使用中,f 表示空闲),以及一个双字节的终止符。对于我们的六个条目(对象 0 始终是空闲列表的头部),正确的表格如下所示:
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000235 00000 n
0000000308 00000 n
trailer
<< /Size 6
/Root 1 0 R
>>
startxref
408
%%EOF
这些偏移量是手工编写 PDF 时最脆弱的部分。每一个都是对应的 N 0 obj 开始的确切字节位置,一旦你在它上面任何地方添加了一个字符,所有的偏移量都会发生移动。尾部是阅读器最后读取但最先使用的入口点:/Root 1 0 R 命名了 Catalog,/Size 6 声明了对象计数,而 startxref 408 给出了 xref 这个单词本身的字节偏移量。阅读器打开文件,跳转到末尾,读取 startxref,寻址到交叉引用表,然后从那里到达 Catalog 及其下面的一切。%%EOF 标志着最后一个字节
让工具来修正字节数
上面的偏移量只是用于说明的;实际上,当您输入完毕时,它们将不再准确,因为它们取决于文件的确切字节布局。与其重新计算它们,不如使用占位值编写结构,然后让实用程序重建交叉引用表和流长度。免费的跨平台工具 pdftk 可以一次性完成此操作:
pdftk hello-draft.pdf output hello.pdf
它解析你的对象,重新计算每个字节偏移量,填写正确的 /Length 值,编写有效的 xref 表和尾部,并输出 hello.pdf。在任何查看器中打开它,你会得到带有 36 点 Helvetica 字体的“Hello, World!”位于顶部附近的一页。Qpdf 也执行相同的工作,并且许多查看器还会实时修复轻微损坏的文件。在这里依赖工具并不是因为懒惰;而是因为偏移算术是格式中概念内容为零且错误率最高的部分,因此将其自动化能让你专心学习它真正的结构
为什么这能扩展到真实文档
关于一百页的报告,没有任何东西改变你刚刚构建的形状。Catalog 仍然位于根,页面树仍然收集各个页面,每个页面仍然指向它的资源和一个内容流。不断增长的是广度,而不是主干:页面树会分支,这样阅读器就可以跳过整个子树,内容流带有几百个运算符而不是五个,字体会带有宽度表和编码并作为自己的流对象被嵌入,图像也会带上图像特定过滤器作为流出现。现代文件还倾向于将许多对象打包成压缩对象流,并用交叉引用流来代替普通的 xref 表,这就是为什么在文本编辑器中打开真实的 PDF 通常会看到一面由二进制乱码组成的墙。底层的模型与你手工制作文件中的模型是完全相同的。有关更广泛的对象图谱,以及 Catalog、页面树和资源字典在较大型文档中的相互关系,“PDF 文档结构深度解析”一文接着讲述了本文留下来的内容,而“文件结构概述”则涵盖了增量更新以及尾部如何跨越版本链接的内容
从手工编写到库
手工输入对象是一项学习练习,而不是一种生产技术。当您需要真正的字体、换行文本、图像,或超过单个简单页面的内容时,pdftk 为您修补的字节簿记就会变成全部的工作,这时您就会需要一个库来接管它。相同的五个对象仍然会被写入,但是库会计算每个偏移量、管理字体和资源字典,并压缩内容流,而无需您跟踪任何一个字节。在 Delphi 和 C++Builder 中,HotPDF Component 会将整个这个文件减少到几个简单的调用:设置文档,调用 BeginDoc,使用 SetFont 和 TextOut 放置相同的问候语,然后调用 EndDoc 以写入正确的 Catalog、页面树、xref 和尾部。对底层对象的理解,才是让您在文档没有按照预期方式呈现时,能够对输出进行推理的基础