最近更新于 2025-06-07 22:09
前言
2025.6.3
上个月在某个网站下载了某盗版国标PDF文件,文件中添加了很多链接,随便点击一处都会跳转,很烦人。我就较劲研究了一下,找到了链接的存在形式,写了一个小工具来自动移除链接,并把这个项目开源了。只是这个项目适用性有点窄,不久就有网友联系说不适用他遇到的情况。
上周末,我简单的研究了下,Acrobat 都识别不到里面嵌入的链接对象,后面对方告知用 WPS PDF 时可见,手动删除链接可以。我反复折腾还是没有有效的方法通过编程识别到链接并实现移除,我对 PDF 文件结构也不了解,搞得很懵逼了。现在准备熟悉一下 PDF 文件结构,这样或许有望解决遇到的问题。
参考:
测试环境
Python 3.13.1
pikepdf 9.8.1
PikePDF 是一个底层 PDF 操作库,基于 C++ 的 QPDF 库。
基本理解
PDF 本身是一个结构化的文本文件,就像 json、xml、yaml 这些一样,除了文本部分,还可以存储一些二进制数据在里面,只是一般阅读 PDF,是看的阅读器渲染好的图像,而不是去看一团“代码”一样的原始文档。
下面写一段代码创建一个简单的 PDF,只显示 hello world
Python
from pikepdf import Pdf, Dictionary, Name, Stream
# 创建一个新的 PDF 文档
pdf = Pdf.new()
# 添加一个空白页面
page = pdf.add_blank_page(page_size=(595, 842)) # A4 纸大小
# 设置页面的内容
# BT 开始文本对象
# Tf 设置字体和字号,字体用 F1,字号 18
# Td 将文本矩阵的当前位置移动到指定的水平和垂直偏移量。240 水平偏移量,700 垂直偏移量
# Tj 显示文本。文本内容:Hello World
# ET 结束文本对象
page.Contents = Stream(pdf, b"""
BT
/F1 18 Tf
240 700 Td
(Hello World) Tj
ET
""")
# 添加字体资源
# 字体定义为 F1
# 字体子类型为 Type1
# 字体基础名称 Helvetica
page.Resources = Dictionary(
Font=Dictionary(
F1=Dictionary(
Type=Name("/Font"),
Subtype=Name("/Type1"),
BaseFont=Name("/Helvetica")
)
)
)
# 保存 PDF 文件
pdf.save('hello_world.pdf')
创建的文件用 Edge 浏览
以纯文本的方式打开 PDF 文件就能看到原貌,有部分是明码,还有部分乱码,其实就是二进制数据
%PDF-1.3
%亏
1 0 obj
<< /Pages 2 0 R /Type /Catalog >>
endobj
2 0 obj
<< /Count 1 /Kids [ 3 0 R ] /Type /Pages >>
endobj
3 0 obj
<< /Contents 4 0 R /MediaBox [ 0 0 595 842 ] /Parent 2 0 R /Resources << /Font << /F1 << /BaseFont /Helvetica /Subtype /Type1 /Type /Font >> >> >> /Type /Page >>
endobj
4 0 obj
<< /Length 53 /Filter /FlateDecode >>
stream
x溿r
嵋w3T0 I?21P070I嵋餒蜕 ?蔍 赦r
?滠
?endstream
endobj
xref
0 5
0000000000 65535 f
0000000015 00000 n
0000000064 00000 n
0000000123 00000 n
0000000300 00000 n
trailer << /Root 1 0 R /Size 5 /ID [<ee5d509761ba5ef60d74840dbaff2a03><ee5d509761ba5ef60d74840dbaff2a03>] >>
startxref
424
%%EOF
这个 PDF 文件由文件头、对象定义、交叉引用表、文件尾组成
文件头
%PDF-1.3
%亏
文件头表明了 PDF 遵循的版本,比如这里是 1.3 版。第 2 行以 % 开头的是注释行,创建 PDF 的软件添加的信息,不影响 PDF 显示内容。
对象定义
每一个对象定义都是由下面格式组成
对象编号 世代编号 obj
对象内容
endobj
每个对象有一个编号,世代编号相当于这个对象的版本,对象编号和世代编号可以确定文件中唯一的对象
这个 PDF 有 4 个对象:
①第一个:
- << >> 表面这是一个字典,用于存储键值对
- /Pages 2 0 R:表示这个对象引用了对象编号为 2 ,世代编号为 0 的对象,R表示引用
- /Type /Catalog:表示这是一个目录对象,目录对象是PDF文件的顶级对象,它包含了对页面树的引用以及其他一些文档级别的信息
1 0 obj
<< /Pages 2 0 R /Type /Catalog >>
endobj
②第二个:
- /Type /Pages:页面树节点对象
- /Kids [ 3 0 R ]:页面树节点的子节点对象。[ ] 是一个列表,包含一个对象引用,引用了对象编号为 3,世代编号为 0 的对象。
- /Count 1:从这个页面树节点开始,所有子节点包含的页面总数为 1
2 0 obj
<< /Count 1 /Kids [ 3 0 R ] /Type /Pages >>
endobj
③第三个:
- /Contents 4 0 R:表示这个页面的内容流存储在编号为 4,世代号为 0 的对象中
- /MediaBox [ 0 0 595 842 ]:页面尺寸。左下角坐标(0,0),右上角坐标(595,842)。单位通常是点(1点=1/72英寸)
- /Parent 2 0 R:表示这个页面对象的父节点是编号为2的页面树节点
- /Resources:定义了页面使用的资源。/Font:定义了页面使用的字体。/F1:定义了一个字体对象,命名为F1。/BaseFont /Helvetica:表示这个字体是Helvetica字体。/Subtype /Type1:表示字体的子类型是Type1字体。/Type /Font:表示这是一个字体对象。
- /Type /Page:表示这是一个页面对象。
3 0 obj
<< /Contents 4 0 R /MediaBox [ 0 0 595 842 ] /Parent 2 0 R /Resources << /Font << /F1 << /BaseFont /Helvetica /Subtype /Type1 /Type /Font >> >> >> /Type /Page >>
endobj
④第四个:
这是一个流对象
- /Filter /FlateDecode:使用了 FlateDecode 过滤器进行压缩
- /Length 53:未压缩的长度为 53
- stream 和 endstream 之间的就是压缩后的流数据(二进制数据)
4 0 obj
<< /Length 53 /Filter /FlateDecode >>
stream
x溿r
嵋w3T0 I?21P070I嵋餒蜕 ?蔍 赦r
?滠
?endstream
endobj
交叉引用表
xref 开头就是交叉引用表,可以用来快速定位对象
- 0 5:表示对象从编号 0 开始共 5 个对象
- 0000000000 65535 f:0000000000 表示对象编号 0 对象偏移量为 0,65535 是一个特殊标记,f 表示为自由对象(已经被删除或不再使用的对象),对象 0 应该是早期的历史遗留,为了保持兼容还是从 0 开始。
- 0000000015 00000 n:0000000015 表示对象编号 1 对象偏移量为 15,n 表示已使用对象,00000 是对象的世代号
……
xref
0 5
0000000000 65535 f
0000000015 00000 n
0000000064 00000 n
0000000123 00000 n
0000000300 00000 n
文件尾
trailer 表示文件尾开始
- /Root 1 0 R:表示文件的目录对象是编号为1的对象
- /Size 5:表示文件中共有5个对象
- /ID:表示文件的唯一标识符,这里有两个相同的标识符,用于标识文件的版本
- startxref:表示交叉引用表的起始位置
- 424:交叉引用表的起始位置是文件的第424字节
- %%EOF:表示文件的结束
trailer << /Root 1 0 R /Size 5 /ID [<ee5d509761ba5ef60d74840dbaff2a03><ee5d509761ba5ef60d74840dbaff2a03>] >>
startxref
424
%%EOF
PDF 中对象类型
整数和整数
整数写为一个或多个十进制数字,符号可选
0 +5 -5 5
实数被写为一个或多个十进制数字,可选地前面带有加号或减号, 并且可选的有一个小数点
0.0 0. .0 -0.002 123.4
字符串
字符串用小括号(英文符号,下同)括起来
(我是一个字符串)
转义字符使用反斜杠,要表示反斜杠自身或小括号,前面需要加反斜杠转义。
一些转移字符:
字符序列 | 含义 |
---|---|
\n | 换行 |
\r | 回车 |
\t | 水平制表符 |
\b | 退格 |
\f | 换页符 |
\ddd | 三个八进制数字的字符代码 |
十六进制字符串
使用<>括十六进制数字序列,如 < 0A2B34FF> 表示 0x0A、0x2B、 0x34、0xFF 四个字节,即俩俩算一个字节
名称
名称以斜杠开头,作为字典的键和定义各种多值对象
/Age
/Name
可以使用#加两位十六进制数字,引入 ASCII 字符
/My#20/Name
十六进制数 0x20 对应的字符是空格,即实际这个名称是
/My Name
布尔值
值可以为 true 和 false,常用于字典中作为标志
数组
数组用中括号括起来,有序集合,数组中子元素可以不是同一类型
比如下面的数组包含了三个元素:/red、/blue 、[123 4.5],第三个元素又是一个数组,这个数组包含 123 和 4.5 两个元素。
[/red /blue [123 4.5]]
字典
表示键值对的无序集合,用两层尖括号括起来<<>>
下面的例子将 /one 映射到 1,/two 映射到 2,/three 映射到 3
<</one 1 /two 2 /three 3>>
引用
引用格式为
对象编号 世代编号 R
流和过滤器
流用于存储二进制数据。它们由字典和一大块二进制数据组成。 字典根据流所放置的特定用途列出数据的长度,以及可选的其他参数。
从语法上讲,流由字典组成,后跟stream关键字, 换行符(
压缩方法:
方法名称 | 描述 |
---|---|
/ASCIIHexDecode | 为压缩数据中的每对十六进制数字生成一个字节的未压缩数据。> 表示数据结束。空格被忽略。这个过滤器和/ASCII85Decode旨在将数据减少到7位——/ASCII85Decode更复杂,但更紧凑 |
/ASCII85Decode | 这种7位编码格式使用可打印的字符从 ! 到 u 和 Z 。(译者注:即通过五个ASCII字符来表示四个字节的二进制数据。)~> 标识数据结束 |
/LZWDecode | 实现Lempel-Ziv-Welch压缩,如TIFF图像格式所使用 |
/FlateDecode | Flate压缩,由开源zlib库使用。在RFC 1950中定义。/LZWDecode和/FlateDecode都可以在流字典中具有预测变量,它们定义数据的后处理以反转在压缩时完成的预处理 |
/RunLengthDecode | 一个简单的基于字节的游程压缩器 |
/CCITTFaxDecode | 实现传真机使用的第3组和第4组编码。适用于单色(位深度为1)图像,不适用于一般数据 |
/JBIG2Decode | 一种更现代,更好的压缩机制,适用于与/CCITTFaxDecode一起使用的各种数据,但也适用于灰度和彩色图像和一般数据。实现JBIG2压缩方法 |
/DCTDecode | JPEG有损压缩。整个JPEG文件可以放在这里,包括JPEG文件头 |
/JPXDecode | JPEG2000有损和无损压缩。仅限于JPX基准功能集,但有一些例外 |