PDF标准详解(一)——PDF文档结构

已经很久没有写博客记录自己学到的一些东西了。但是在过去一年的时间中自己确实又学到了一些东西。一直攒着没有系统化成一篇篇的文章,所以今年的博客打算也是以去年学到的一系列内容为主。通过之前Vim系列教程的启发,我发现还是写一些系列文章对自己的帮助最大。它能最大化自己的学习成果,并强迫自己深入了解一些内容。所以今年我想还是以系列文章为主,如果中间有需要穿插一些bug处理或者语言特性相关的,可能也会有这方面的内容吧。

好了,废话就到这里,下面开始正式介绍PDF相关的内容

PDF简介

PDF的全称是 Portable document format(可移植文档格式),是描述打印页面的世界领先语言。最早于1990年代由Adobe Systems创造。早期是Adobe专有格式,直到2008年作为开放标准发布。后续经过一系列的发展,目前已经发展到了2.0版本,由于PDF完全向后兼容,并且大部分都是向前兼容的,因此,这里不打算固定在某个具体的版本,而是介绍一些PDF通用的标准和规则。

PDF的文档结构

PDF主要由四个部分构成,文件头、文件体、交叉引用表以及文件尾

文件头将文件标识为PDF并给出它的版本号,例如

%PDF-1.0    % PDF 版本号为 1.0 的文件头

文件体是PDF文档的主体内容,主要由对象组成,它规定了页面信息和页面内容元素等信息

交叉引用表给出了每个对象距离文件首部的地址偏移,这样在解析PDF的时候就不用从头到尾解析每个对象,而是根据需要通过交叉引用表来寻址到具体的对象地址,只单独解析某个对象,提高了解析效率

文件尾给出交叉引用表的位置并且以

%%EOF

作为结尾

PDF文件的逻辑结构

一个标准的PDF文档需要在文件体中包含下列元素对象:

  1. 根节点元素,类似于xml的根节点,它是整个文档的根节点对象
  2. Pages对象,它包含了PDF文档的页面信息,一般通过它来定义整个PDF文档有多少页
  3. Page 页面对象,它用来描述每个具体的页
  4. Page Content 对象,它来描述每个具体页中都有哪些对象,一般是一个字节流用来表示将在页面中显示哪些内容
  5. Page Resource 对象,它是内容的资源字典,供Content对象引用,资源包括字体、画刷、画笔等等
  6. trailer 字典,可以将它看作pdf文档对象的入口,通过它我们可以知道当前PDF文档的一些具体信息,例如根节点的位置,交叉引用表的大小

它们之间的关系如下图:

PDF版的Hello World

说了这么多,我们来试试来自己编辑一个hello world文档,首先建立一个文本文件,将后缀改为.PDF

我们先写上文件头:

%PDF-1.0    % PDF 版本号为 1.0 的文件头

主要对象

我们按照之前的分析的PDF文档中需要包含的对象,来逐一定义

首先给出Pages节点的定义

1 0 obj % 对象1
<< /Type /Pages     % 这是一个页面列表
   /Count 1         % 只有一页
   /Kids [2 0 R]    % 页面对象编号列表。这里只是对象2
>>
endobj  % 对象1结束

对象的内容我们在后续会专门介绍,所以这里不需要额外关注它的语法,这里只需要知道

1 0 obj

定义了一个对象1,后续通过1 这个编号可以找到这个对象。这个对象中定义了他的类型是 Pages表示它是一个pages对象,/Count表示整个PDF文档只有一页,Kids是一个数组,表示每一页的页面对象,这里它只有一个页面对象,就是对象2

接着我们定义页面对象

2 0 obj
<< /Type /Page              % 这是一个页面
   /MediaBox [0 0 612 792]  % 纸张尺寸为美国信肖像(612点x792点)
   /Resources 3 0 R         % 对象3的资源引用
   /Contents [4 0 R]        % 图形内容在对象4中
>>
endobj

页面对象中我们定义了页面纸张的大小,单位是磅。因为PDF是可移植文档,它需要在不同设备上显示同样的内容,这里不能使用像素,如果使用像素,在同样尺寸的显示器上如果显示器的像素分辨率不同,那么显示的结果将会不同。所以这里一般使用磅作为单位。

同时在页面对象中定义了页面中将要使用的资源以及将要显示的内容

接着我们来定义资源对象

3 0 obj
<< /Font  % 字体字典
     << /F0  % 只有一种字体,称为/F0
          << /Type /Font  % 这三行引用了内置字体Times Italic
            /BaseFont /Times-Italic
            /Subtype /Type1 >>
     >>
>>
endobj

资源对象中,我们定义了一个字体资源,字体为 Times Italic,并且定义了这种字体资源的名称为 F0, 后面可以通过F0 这个名称来直接引用这个字体

然后我们来定义页面内容对象

4 0 obj     % 页面内容流
<< >>
stream      % 流的开始
1. 0. 0. 1. 50. 700. cm % 位置在(50,700)
BT  % 开始文本块
 /F0 36. Tf         % 在36pt选择/F0字体
 (Hello, World!) Tj % 放置文本字符串
ET  % 结束文本块
endstream   % 流结束
endobj

通过stream来定义一个流对象,在这个流对象中,我们定义它在页面的 (50, 700) 坐标位置显示字符,显示字符内容通过后面的 (Hello, World!) Tj来定义,并且定义了字符采用F0 字体,也就是上面定义的Times-Italic字体

页面相关的内容我们已经定义完了,接着我们需要定义一些结构相关的对象,方便PDF解析器找到并解析页面内容。

我们来定义根节点

5 0 obj
<< /Type /Catalog %文件目录
   /Pages 1 0 R   %参考页面列表
>>
endobj

根节点包含了一个Pages定义,通过根节点就可以找到Pages节点

接着我们来定义交叉引用表

xref %这里我们跳过了交叉引用表的开始
0 6

交叉引用表包含一些偏移地址信息,我们单纯的通过文本文档很难计算各个对象的偏移,所以这里我们只给出文档中对象数量为6,具体的地址我们先不给出,这样PDF解析器也能解析出各个对象

之前我们给出了5个对象的定义,但是交叉引用表的条目却是6,这是因为交叉引用表的第一条一般是一个没有什么用处的,有效的对象从第二条定义开始。

下面给出 Trailer 字典的定义

trailer
<< /Size 6 %交叉引用表的行数
   /Root 5 0 R % 参考文档目录
>>

Trailer 字典以 trailer关键字开始。条目下面包括了交叉引用表的行数以及根节点的对象

最后我们给出交叉引用表在PDF文档中的偏移,由于交叉引用表的内容为空,所以这里我们直接给0

startxref
0			%xref表开始的字节偏移量,这里设置成0

最后我们以

%%EOF

结尾来表示整个PDF文档结束

到这里我们已经得到了一个PDF阅读器可以打开的PDF文档。我们使用PDF阅读器可以得到如下的页面

PDF文档一般的读取过程

不知道各位小伙伴们是否能看懂上面 Hello World 文档的定义。下面我们通过一个完整的 PDF文档来将上面所有定义的对象串起来,希望各位能对PDF文档有一个完整的认识。我们不用纠结各个部分的写法,以及为什么要这么写,只需要明白各个对象的功能即可。具体对象定义相关的语法和每个对象的详细解释将会在后面一系列文章中给出,相信那个时候再来看这个 Hello Word 文档一定会有一个更清晰的认识。

再说明文档读取的过程前,我们先使用一些工具来补全这个文档,这里使用 pdftk 工具。可以在这里 进行下载,完成之后,使用如下命令进行补全

pdftk hello.pdf output hello-full.pdf

成功后会得到如下内容

%PDF-1.0
%忏嫌
1 0 obj 
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj 
2 0 obj 
<<
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj 
3 0 obj 
<<
/Font 
<<
/F0 
<<
/BaseFont /Times-Italic
/Subtype /Type1
/Type /Font
>>
>>
>>
endobj 
4 0 obj 
<<
/Length 202
>>
stream
% 娴佺殑寮€濮?
1. 0. 0. 1. 50. 700. cm % 浣嶇疆鍦紙50,700锛?
BT  % 寮€濮嬫枃鏈潡
 /F0 36. Tf         % 鍦?6pt閫夋嫨/F0瀛椾綋
 (Hello, World!) Tj % 鏀剧疆鏂囨湰瀛楃涓?
ET  % 缁撴潫鏂囨湰鍧?

endstream 
endobj 
5 0 obj 
<<
/Pages 1 0 R
/Type /Catalog
>>
endobj xref
0 6
0000000000 65535 f 
0000000015 00000 n 
0000000074 00000 n 
0000000168 00000 n 
0000000267 00000 n 
0000000523 00000 n 
trailer

<<
/Root 5 0 R
/Size 6
>>
startxref
573
%%EOF

这个我将整个PDF文档都粘贴了出来,从这里我们可以看到,它已经为我们补全了交叉引用表。下面通过整个文档来说明一般读取过程

  1. PDF解析程序,先通过文件头来确定是否是PDF文件,并且得到PDF文件的版本
  2. 在文件末尾找到%%EOF 关键子,确定文件尾。接着向上查找到 startxref 关键字,该关键字后面将会给出交叉引用表的偏移,通过这个偏移地址可以找到交叉引用表
  3. 接着查找trailer关键字,通过trailer关键字可以得到文档的一些信息,这里关键的是得到 Root 节点的对象。
  4. 根据交叉引用表可以很块定位到Root 节点对象,也就是对象5
  5. 根据Root 对象中的 Pages属性可以找到Pages对象,也就是PDF页面信息对象
  6. 根据Pages对象中的Kids 数组,可以找到PDF包含的所有页面对象,这个文档只有一个页面对象
  7. 找到Page 对象后可以根据 Resources 和Contents属性可以找到页面内容和页面引用的资源。例如该文档就可以使用Times-Italic字体显示 hello world字符串