格式知识约 1 分钟阅读

改 PDF 里的文字是怎么做到的:内容流、字体编码,以及为什么只能一行一行改

PDF 里没有段落和文字框,只有“在某个位置用某个字体画一串字形”的指令。改一行文字就是删掉旧指令、按同样的位置和字体写入新指令,这也决定了它能改什么、改不了什么。

PDF123 · 更新于 2026-10-14
改 PDF 里的文字是怎么做到的:内容流、字体编码,以及为什么只能一行一行改

PDF 的页面里没有段落,也没有文字框,只有一串指令:在这个坐标,用这个字体、这个字号,画这几个字形。改字,就是把这一行的指令删掉,再在同一个位置用同样的字体、字号和颜色写入新的指令。编辑 PDF 能一行一行地改、改不了自动换行,都是由这个存法决定的。

一行文字在文件里长什么样

PDF页面的内容存在内容流(content stream)里,是一段由运算符组成的小程序。画文字的核心运算符有四个:BT/ET 圈出一段文字,Tf 选字体和字号,Td 移动到下一个位置,Tj 画一串字符。用标准字体的最简单情况是这样:

BT /F1 18 Tf 20 150 Td (Hello World) Tj ET

意思是:选 F1 号字体、18 点,移到横 20、纵 150,画 Hello World。Chrome 打印出来的 PDF 常见的是另一种写法,括号里不再是字符,而是字体里的字形(glyph)编号,位置由 Tm 一次设定。下面是 Chrome 导出的一页里 Hello World from Georgia 这一行,每四位十六进制是一个字形的编号:

BT /F4 24 Tf 1 0 0 -1 30 52 Tm <002b0048004f004f00520003003a00520055004f0047000300490055005200500003002a004800520055004a004c0044> Tj ET

开头的 002b 是 H,0048 是 e,004f 是 l,0003 是空格,003a 是 W,以此类推。这些编号只在这一个字体里有意义,不是 Unicode。复制这段文字时,阅读器靠文件里另存的一张对照表把编号翻回字符。

从这里能看出两件事。第一,内容流里没有“这是一个段落”的信息,换行只是后面又有一条 Td 或 Tm 把位置挪到了下一行;一段话是多少行,要靠位置推。第二,要往一行里写新字,必须知道新字在这个字体里的编号,而这个字体里往往只有写文件时用到的那些字形,这是字体子集化的结果。

可以用 qpdf 把流展开:

qpdf --qdf --object-streams=disable in.pdf out.pdf

用文本编辑器打开 out.pdf,搜索 Tj 就能看到页面上每一串文字。页面里还有什么,见PDF 里面有什么。

编辑器怎么把字形还原成“一行”

编辑器要先把一堆字形合成用户能点的一行。字体、字号和颜色都相同、相邻字形的间隔不超过一个字号的,算同一段;换行、换字体、换颜色就断开。文字方向是从右向左或竖排的,不参与合并,也就点不开。

这一步有两个能直接观察到的后果。一行里混了两种字体,比如中文行里夹着英文,会被拆成相邻的两段,各点各的。改过之后也一样:新字用了别的字体,这一行就变成两段,再改其中一段,另一段保持原样。

改一行:先删旧的,再写新的

删旧的用的是涂黑(redaction)里“只删文字”的那种:把这一行范围内的字形从内容流里去掉,图片和线条保持原样,也不在旧文字上盖一块颜色,所以复制和搜索都找不到旧文字。它只改页面内容,同一段文字如果还出现在批注或文档属性里,那些地方不受影响。

写新的是在页面内容流的末尾追加一段新的文字指令,起点取原来那一行的基线位置,字号和颜色沿用。把上面那一行改成 Hello World 2 后,页面末尾追加的内容是:

q 1 0 0 1 22.5 148.92 cm 0 0 0 rg
/Span <</ActualText <feff00480065006c006c006f00200057006f0072006c006400200032>>> BDC
BT /F4 18 Tf 0 0 Td <002b0048004f004f00520003003a00520055004f00470003> Tj ET
BT /F9 18 Tf 102.18 0 Td (2) Tj ET
EMC
Q

前一个 BT 用的是原来的 F4 字体和原来的字形编号,Hello World 一字不差地沿用;“2”在原来的字体子集里没有,所以后一个 BT 另起一个标准 Helvetica 字体(F9)来画。q 和 Q 把这段指令的坐标变换和颜色限定在自己里面;编辑器同时把原来的内容也包进一对 q/Q,防止原页面末尾没配平的状态影响新文字。这是一次运行的样子,数字随文件而变。

追加在末尾带来三个后果:

  • 新文字最后绘制,盖在页面上其他东西的上面。原来被图片或色块遮住的文字,改过之后会出现在它们上面。
  • 按内容流顺序提取文字的工具(比如 pdftotext -raw)读出来,被改过的行排到这一页的最后;按位置排版的提取(pdftotext -layout)看不出变化。页面看起来没变,读出来的顺序变了。
  • 编辑器给新文字附上一段 ActualText,告诉阅读器这段字形对应的真实字符是什么。同一个字形可能对应好几个字符,只靠字形编号反查,有时会得到兼容字符(比如把“文”读成康熙部首“⽂”),ActualText 保证复制出来的就是你输入的字。

新字用哪个字体画

用原来的字体最稳,因为字形、字重、字宽都和旁边一致。能不能复用,取决于能否确定“这个字符在这个字体里对应哪个编码”:

  • 上面那种组合字体(Type0,两字节字形编号,Identity-H 编码):直接去字体里查这个字符的字形编号,查得到就用。查不到,说明这个字不在子集里。
  • 单字节的简单字体(TrueType、Type1),编码是 WinAnsi 或 MacRoman:字符到字节的对应是固定的,宽度读文件里的宽度表。
  • 编码带自定义改动(Differences)或依赖字体内置编码的:没有可靠的对应关系,不去猜,直接走备用字体,否则会写出乱码。

要用的字在原字体里没有,就换备用字体。普通拉丁字母用 Helvetica、Times、Courier 里最接近的一种,这三类是每个阅读器都有的标准字体,不用嵌入。其他字符,比如汉字、假名、谚文、泰文、带变音符号的拉丁字母、西里尔和希腊字母,要用开源的 Noto 字体:服务器按请求里的字符,用开源的 HarfBuzz 字体子集化库从整套字体里切出这几个字形,通常只有几 KB,浏览器再把它嵌进你的文件。汉字用哪种字形,按页面的界面语言选简体、繁体、日文或韩文的版本。

嵌入字形而不是只写字体名,是因为只写名字的字体由每个查看器自己找后备字体来画,不同查看器里字重可能不同;嵌进去,所有查看器看到的都一样。

为什么只能一行一行改

编辑器没有排版引擎。每个字形的位置都是文件里写死的,新文字只是从起点开始按字体的宽度往右排,没有“到边界就换行”的逻辑,也不会去挪后面的内容。把一行改长,多出的字就向右伸出去,超出页面的部分看不见。整段重写、往表格里加行,需要的是重新排版,得回到生成这份 PDF 的源文件。

宽度也只用字体自带的字宽,没有字距调整和字形替换。不需要整形的文字(拉丁、西里尔、希腊字母和汉字)可以正常排;泰文的声调符号要靠字形定位才能叠在正确的位置,编辑器没做,所以位置可能偏。阿拉伯文要按上下文换字形再从右向左排,希伯来文要从右向左排,印度系文字要把辅音和元音符号拼成字形,这些都需要完整的文字整形,编辑器现在不做,输入会被拒绝,而不是写出错误的字。

另有两处边界来自文件本身。扫描页的内容是一张图片,没有文字运算符,也就没有东西可删可改。带数字签名的文件改完,签名所保护的原内容变了,原有签名就不再有效。

自己验证

改完下载的文件,可以用三条命令看到上面说的变化:

pdftotext -raw edited.pdf -      # 按内容流顺序提取:旧文字不在了,改过的行排在最后
pdffonts edited.pdf              # 字体列表:沿用的原字体,以及新嵌入的字体子集
qpdf --qdf --object-streams=disable edited.pdf edited.qdf   # 展开内容流,末尾能看到追加的那段 BT ... ET

接着读什么

打开工具
在浏览器里完成处理,不加水印,文件处理完就会删除。
打开工具