PDF格式入门

3,178 阅读11分钟

相信大家对于PDF格式的文件应该不陌生,但是可能大部分人都只是知道。最近因为一些需求涉及到了PDF,所以做了一些相对来说深入的了解,这个过程中也发现现在网上相关的资源比较少,官方文档是很全,但是很长且全英文不易于解读,所以这里把一些经验分享给大家,希望帮助到有需要的同学。

一、如何查看PDF文件

工欲善其事,必先利其器。PDF文件本质上算是二进制文件,所以直接使用vscode打开,会有部分是可读的文本,部分看起来像是乱码。就像下图:

image.png

但是如果你使用二进制文件查看工具(比如我用的是Hex Fiend),大概就长下面这样:

image.png

很多同学看到这张截图,是不是顿时就会产生放弃的想法,在不知道PDF结构的时候,的确会产生这样的感觉,但是相信我,读完接下来的内容,你再回头看的话,真的不难~

回到正题,个人在查看工具上是这样建议的:

  1. 如果你只是简单的查看的话,使用vscode是完全没有问题的
  2. 如果你想要修改或者复制的话,那还是建议使用二进制文件查看文件,使用vscode修改二进制文件还有复制都会有问题

二、PDF文件结构解析

解下来我从一个真实的文件出发,带大家一起了解一下PDF的格式。这个文件是我使用pdfkit这个工具生成的文件,一共有两页,第一页就有几个文字,第二页是空白的,内容如下:

image.png

为了方便大家阅读,我直接使用vscode打开。

image.png

参考PDF官方文档,我们可以从以下四个方面去了解PDF文件:

  • Objects 对象
  • File structure 物理文件结构
  • Document structure 文档结构
  • Content streams 内容流

2.1 你必须要知道的obj

image.png

这是第一屏的数据,可以看到很多都是可读文本。obj我认为是PDF文件的基本构成,一个PDF文件就是由很多obj组成的。 最开头的%PDF-1.3是PDF版本号,接下来跳过几个乱码后,就是重点了:obj。

7 0 obj
<<
/Type /Page
/Parent 1 0 R
/MediaBox [0 0 612 792]
/Contents 5 0 R
/Resources 6 0 R
>>
endobj

obj的格式是以【数字1 数字1 obj】内容【endobj】,其中数字1表示obj的序号,每个obj都不一样。数字2是叫生成号,按照PDF规范,如果一个PDF文件被修改,那这个数字是累加的,实际上这个生成号一般看到的都是0。中间的是这个obj的内容,最后endobj表示这个obj的结束。

这个东西为了简单理解,你可以把它看成js中的object,我把上面的内容转成js的obj你再看看:

{
    "type": "Page",
    "Parent": "1 0 R",
    "MediaBox": "[0 0 612 792]",
    "Contents": "5 0 R",
    "Resources": "6 0 R"
}

是不是看起来熟悉了很多,其实这也就是obj的大致语法了。那上面这段表示什么意思呢,我给你们翻译一下:

    我是一个Page,我的父对象是序号为1的obj,我的页面大小是左上角:0,0,宽度612,高度792。
    我的内容是序号为5的obj,我的资源是序号为6的obj。

我们接下来看文件,是序号为6的obj:

6 0 obj
<<
/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
/Font <<
/F2 8 0 R
>>
>>
endobj

这里需要注意一个嵌套,我翻译成object你们就懂了:

{
    "ProcSet": "[/PDF /Text /ImageB /ImageC /ImageI]",
    "Font": {
        "F2": "8 0 R"
    }
}

6 0 obj是上面的page的资源,有点像是css 样式,比如这里说的就是字体信息,其实F2这个字体的内容在8 0 R。

接下来就是page的内容5 0 R

5 0 obj
<<
/Length 113
>>
stream
1 0 0 -1 0 792 cm
1 0 0 -1 0 792 cm
BT
1 0 0 1 100 691 Tm
/F2 20 Tf
[<00010002000300040004000400040004> 0] TJ
ET

endstream
endobj

这里稍微有一点不一样的是,这里除了有一个Length外,还有stream。stream的格式是:【stream 内容 endstream】,这里stream里面的具体内容就是我们看到的文字的绘制命令,我们在接下来的章节详细介绍,这里先跳过。

image.png

接下来就是第二页的内容了,这里我们就不展开了,其实和第一页差不多,大家对比阅读一下就可以了。

2.2 物理文件结构

大家会读obj后,接下来讲物理文件结构就会简单很多。

image.png

按照官方文档,一个PDF文件从物理结构上来说可以分为四个部分:

  • Header 头部,也就是上面说过的PDF版本号,就那么多
  • Body 内容,Body是由很多obj组成的,上面讲obj的时候那些例子都是Body的一部分
  • Cross-reference table 交叉引用表
  • File Trailer 文件尾

image.png

可以看看我们这个测试文件的交叉引用表和文件尾。

交叉引用表

交叉引用表的作用是什么呢,它的设计初衷是为了快速访问obj,里面标记了它的起始位置。

xref表示是交叉引用表,每个交叉引用表又可以分为若干个子段,我们这个例子只有一个子段。接下来的0 20表示这个子段开始于0,一共有20个对象。解析来的每行有三个部分,格式为【相对头文件偏移地址 生成号 对象是否使用】,生成号和obj的生成号类似,也是修改的时候使用,n表示对象有被使用,这个值还可能是f,表示被删除或没有用。

但是在实际过程中,你的对象偏移地址即使不对,PDF也能正常解析,所以这一块你基本上可以不用关注,知道就行。

文件尾

文件尾的作用主要是快速找到交叉引用表的位置,还有一些文件信息,像加密,入口对象(type为Catalog的obj)。这个文件一般关注也不多,因为我们不是解析PDF,生成的工作这些库都帮你做了。

2.3 文档结构

有了上面这些,你知道怎么读懂PDF文件,也能从物理上把每个obj解析出来。但是不同obj之间如何配合以及如何影响PDF的这些你就不知道了,这也是这一节的主要内容。接下来看官方文档:

image.png

实际上官方文档很全也很详细,但是我们这里只关注主要功能,所以主要关注Page tree这一部分。

Document catlog

这个在文件尾也提到了,其实就是表示文档的入口,看我们的测试文件:

3 0 obj
<<
/Type /Catalog
/Pages 1 0 R
/Names 2 0 R
>>
endobj

内容很简单,就是先声明type是catalog,然后pages在1 0 R,names在2 0 R

Pages

Pages字段是所有页面的描述集合,看我们的测试文件:

1 0 obj
<<
/Type /Pages
/Count 2
/Kids [7 0 R 11 0 R]
>>
endobj

这里的意思是我们一共有两页,第一页在7 0 R,第二页在11 0 R。7 0 obj和11 0 obj我们在介绍obj的时候都介绍过了。

到这里,我们对整个文件的结构应该有了一些清晰了解,至少知道每一页在哪,内容大概是些啥,归纳一下如下:

Catalog 3 0 R
    Pages 1 0 R
        Kids1: 7 0 R
            Parent: 1 0 R
            Contents: 5 0 R
            Resources 6 0 R
        Kids2: 11 0 R
            Parent: 1 0 R
            Contents: 9 0 R

2.4 Stream

还记得我们在obj一节中讲到了Stream,但是没有详细展开讲,这一节稍微展开描述一些,因为Stream有一个编码的概念,很多文件的Stream编码后看起来都是乱码,基本上没有办法阅读。

Stream主要是存储大量数据的,例如内嵌的字体、图片还有绘制命令。

image.png 像我们文件的16 0 obj,如果你去找每个obj的关系的话,会发现它最后表示的内嵌的字体。

image.png 这个7 0 obj是从另外一个文件中截取的,它表示的也是绘制命令,和我们的5 0 obj是差不多的,但是你看起来却是乱码,主要是因为多了Filter设置,也就是编码,目前有的编码如下:

image.png 可以看到,这个例子中用到是FlatDecode,也就是zlib压缩算法。

这样的内容很影响我们分析PDF文件,所以下面提供一段Python脚本解压缩(因为是从github上抄的,感兴趣可以写js版本)

import re
import zlib

pdf = open("test.pdf", "rb").read()
stream = re.compile(b".*?FlateDecode.*?stream(.*?)endstream", re.S)

allStream = stream.findall(pdf)
for index in range(len(allStream)):
    s = allStream[index].strip(b'\r\n')
    try:
      if index == 0:
        print(zlib.decompress(s))
    except:
        pass
    

PS:这段脚步目前比较简陋,需要手动置顶那个Stream,主要是因为像字体这种Stream解压出来也看不懂,所以一般找到了绘制命令的Stream,再解压分析。

2.5 绘制

到这里,已经扫清了一切障碍,我们可以开始认真分析绘制相关的内容了,也就是你看到的文字、图片、形状啥的是怎么描述的。

在PDF文档里面,这一章节叫Graphics,也就是图形。具体长啥样呢,看下面:

5 0 obj
<<
/Length 113
>>
stream
1 0 0 -1 0 792 cm
1 0 0 -1 0 792 cm
BT
1 0 0 1 100 691 Tm
/F2 20 Tf
[<00010002000300040004000400040004> 0] TJ
ET

endstream
endobj

就是我们的stream中的内容。这一段内容啥意思呢,我人工翻译一下:

做一次transform,参数是1 0 0 -1 0 792,再做一次
开始绘制文字了,设置文字位置 1 0 0 1 100 691
设置字体为F2,字号为20
绘制文字,内容是<00010002000300040004000400040004>
绘制文字结束

其中文字内容我们看到的是【你好 wwwww】,那很多人就懵了,这里绘制的是一串啥玩意,其实主要是因为我们用了自定义字体,所以这里的内容不是直接就是你绘制的文字内容的编码,这里设计到自定义字体相关的,比较复杂,有机会再单独一篇文章讲。如果我换成默认字体,你应该就能看懂了,例如绘制一个文字1:

BT
1 0 0 1 125.090185 758.640014 Tm
/F1 9.749988 Tf
[<31> 0] TJ
ET

什么,不是说好绘制1的嘛,怎么是31?其实1的charCode是49,那49的16进制是不是就是31啦。

好了,例子看完了,其实PDF的绘制和Canvas很类似,或者说很多操作可以一一对应,这也就是PDF.js这个库为什么能展示PDF,因为它把PDF的绘制指令转成了Canvas的绘制命令。

image.png 这是官方文档列出来的一些指令,其实还有一些没有列出来,详细信息可以去官方文档仔细阅读。可以简单说几个Path相关的,例如q表示save、Q表示restore、re表示rect,c表示贝塞尔曲线、w表示ineWidth、m表示moveTo、l表示lineTo、f表示fill、S表示stroke、n表示clip、cm表示transform,更多更详细的指令还是建议去官方文档了解,很多都是用到了再用。

就是通过这些指令,PDF预览程序进行绘制才形成了我们最后看到的样子。

三、如何生成PDF

了解了PDF的格式后,我们可以顺便了解一下如何生成PDF。

3.1 前端生成

如果前端生成,其实相关的库很多,这里推荐两个:

  1. pdfkit
  2. jspdf

像很多人都需要的诉求是将html转成pdf,那就可以使用html2canvas,在使用jspdf转成PDF,html2pdf就是这么干的。但是我自己的使用以及对比来说,pdfkit虽然star少一点,但是兼容性做的比jspdf好,例如rgba颜色jspdf就不支持,但是pdfkit支持,还有自定义字体这块,jspdf目前的实现比较消耗内存。

前端生成最大的问题其实是字体问题,因为PDF对于使用到的字体,除了标准的14种字体外,其他字体需要进行内嵌,而这标准的14种字体都不支持中文。如果要把字体内嵌,一方面你需要注意字体的法律风险问题,另一方面,字体文件普遍比较大,你还需要对字体进行裁剪,这就需要你对字体文件例如ttf格式有了解。

3.2 服务端生成

如果是服务端生成的话,最常见的使用puppeteer无头浏览器。对于一些使用canvas的页面,可以考虑使用node-canvas,其实底层就是使用了skia或者cario这些绘图引擎来实现了CanvasRenderContext。

后端生成pdf的话,字体文件可以通过服务端安装来解决。

四、总结

本篇文章介绍的也只是PDF的冰上一角,主要是帮助大家从整体上理解PDF,其实到很多细节的话,还是有很多内容的。如果读到这里,再去读开始的二进制文件,大家已经不像一开始那么懵了,那本篇文章的作用也就达到了,最后对于不对的地方欢迎斧正~

参考: