不是文字,是画:PDF 的渲染模型
3.1 一页 PDF = 一张画布
3.1.1 从 Hello PDF 到渲染模型
上一章我们看到了对象 #4 里的这行内容:
BT /F1 24 Tf 100 700 Td (Hello PDF!) Tj ET
这行代码的意思是"用 24 磅的 F1 字体,在坐标 (100, 700) 处画出 Hello PDF!"。
关键词是画。对 PDF 渲染引擎来说,画一个字母 'H' 和画一条线段没有本质区别——都是在画布上的某个坐标放一个图形。PDF 页面不是"文档",而是一张白纸加一串绘图指令。
这和 HTML 完全不同。HTML 说的是"这是一个段落,里面有这些文字"——它描述内容的结构。PDF 说的是"在 (72, 720) 处画一个 12 号的 glyph #48"——它描述画面的样子。
3.1.2 坐标系
PDF 使用数学课上的笛卡尔坐标系,原点在页面左下角,y 轴向上:
(0, 842) (595, 842)
┌─────────────────────────────┐
│ ↑ y │
│ │ │
│ └───→ x │
└─────────────────────────────┘
(0, 0) (595, 0)
单位是点(point),1 点 = 1/72 英寸 ≈ 0.353mm。A4 纸是 595 × 842 点。
这和屏幕坐标系正好相反——屏幕的原点在左上角,y 轴向下。所以 PDF 里坐标 (100, 700) 意味着"距左边 100 点、距底部 700 点",也就是页面左上方的位置。
3.1.3 状态机:画笔的"记忆"
PDF 渲染器像一个有记忆的画笔——它始终记着"当前用什么字体、什么颜色、什么线宽"。你可以随时切换这些参数,后续的绘图指令就用新参数。
更重要的是 q(保存当前状态)和 Q(恢复)这对操作:
q % 记住现在的状态
0.5 0 0 rg % 切换到绿色
0 0 100 100 re f % 画一个绿色矩形
Q % 状态恢复(颜色回到 q 之前的值)
这就像 Photoshop 里的图层——你可以在一个临时环境里随意改参数,完事后一键恢复,不影响后面的内容。
3.2 文字渲染:本章的核心
3.2.1 "画字"而不是"存字"
这是理解 PDF 最关键的认知转变。
当你在 Word 里打一个"Hello",Word 内部存储的是:一个段落对象,里面有一个文本运行(text run),内容是 Unicode 字符串 "Hello"。它知道这是一个词。
当这份文档导出为 PDF 后,变成了什么?
BT
/F1 12 Tf % 选字体
72 720 Td % 移到坐标 (72, 720)
<0048 0065 006C 006C 006F> Tj % 画 5 个 glyph
ET
每个 00XX 是字体内部的 glyph ID(字形编号)——不是 Unicode 码点,不是 ASCII 码,是字体自己的内部编号。阅读器必须查一张叫 CMap 的映射表,才能知道 glyph 0048 对应 Unicode 字符 'H'。
类比:想象一幅油画。画家画了一个"H"形状的笔画。观众能认出来这是字母 H,但油画本身不"知道"这是一个字母——它只是颜料在画布上的形状。PDF 就是这样:它画出了字母的形状,但不一定"知道"自己画的是什么字。
3.2.2 字距调整(Kerning)
真实的 PDF 内容流通常不是简单地逐字画,而是带着精确的间距微调:
[(H) -10 (ello) 20 ( W) -15 (orld)] TJ
数字部分(-10、20、-15)是水平位移微调,单位是千分之一字号。-10 表示往左挤 10/1000 个字号的距离。
这就是字距调整(kerning):比如 "AV" 两个字母要靠近一点(否则中间有个难看的空隙),"LT" 之间要稍微拉开。生成器在导出 PDF 时就预先算好了所有字母对之间的精确间距,直接写死在内容流里。tips
这意味着:PDF 中的"Hello World"不是一个完整的字符串,而是一堆碎片加微调参数的混合物。
3.2.3 复制粘贴乱码的根源
当你从 PDF 里 Ctrl+C 复制文字时,阅读器做的事是逆向工程:
glyph ID → 查 CMap 映射表 → Unicode 字符 → 放进剪贴板
先解释几个概念:
- 生成器:就是"把你的文档变成 PDF"的那个软件——Word 的"导出为 PDF"、LaTeX 的 pdflatex、Chrome 的"打印为 PDF",这些都是生成器。
- CMap / ToUnicode:一张"翻译表",告诉阅读器"字体内部编号 48 = Unicode 字符 H"。没有这张表,阅读器就像看到一串密码但没有密码本。
这个逆向链条在三种情况下会断:
情况一:生成器没嵌入映射表
有些生成器(尤其是老旧的或配置不当的)在创建 PDF 时根本没有把 ToUnicode 表写进去。它只管"画面对不对",不管"以后能不能复制"。毕竟 PDF 的设计初衷是"看",不是"读"。
典型场景:一些老版本的排版软件(如早期的方正排版系统)导出的中文 PDF,显示完美但一个字都复制不出来。
情况二:字体使用自定义编码
标准字体的编码是约定俗成的——编号 65 = A,编号 66 = B。但有些特殊字体(尤其是数学符号字体、装饰字体)完全自己定义了一套编码:编号 1 可能是积分号 ∫,编号 2 可能是 α,和任何标准都无关。
这种情况不只出现在"特殊字体"——LaTeX 生成的 PDF 几乎必然有这个问题(Typst就没有👿),因为 Computer Modern 系列数学字体用的是 TeX 自己的编码体系,不是 Unicode。如果 pdflatex 没有正确生成 ToUnicode 映射(这取决于使用的宏包和配置),复制出来的公式就是乱码。
情况三:子集化后重编码
子集化就是"只嵌入用到的字"以缩小文件体积。比如一份文档只用了"你好世界"四个汉字,子集化后只打包这四个字形。但打包时 glyph ID 可能被重新编号——原来编号 20000 的"你"可能变成了编号 1。
如果生成器在重编号的同时正确更新了 ToUnicode 表(编号 1 → U+4F60 "你"),一切正常。但如果这一步有 bug(确实有些生成器会犯这个错),映射就断了——阅读器知道要画编号 1 的字形(画出来是对的),但不知道编号 1 在新的体系里代表什么 Unicode 字符。
那我能怎么办?
遇到复制乱码的 PDF,几个应对方案:
- 换一个阅读器试试:不同阅读器的文字提取算法不一样。有时 Chrome 复制出乱码但 Adobe Acrobat 能正确识别(反之亦然)。
- 用专业工具提取:命令行工具
pdftotext(poppler-utils)或 Python 的 PyMuPDF 有时比阅读器的 Ctrl+C 更聪明。 - OCR 大法:如果文字层彻底不可用,把 PDF 页面渲染成图片再用 OCR 识别。
ocrmypdf可以一键完成(第 8 章会详细讲)。 - 截图 + 多模态 AI:2026 年最暴力但最有效的方案——截图发给 Gemini 或 Claude,让它直接"看"图片里的文字。对于数学公式尤其好用。
从源头避免:如果你自己在生成 PDF,确保使用现代工具。Typst 是最省心的选择——它从设计之初就原生使用 Unicode + OpenType,生成 PDF 时自动嵌入完整的 ToUnicode 映射,复制公式能直接得到正确的 Unicode 数学符号。新版 Word 和 XeLaTeX + unicode-math 宏包也能正确生成映射。老旧的 pdflatex + Computer Modern 组合是乱码重灾区——CM 字体用的是 TeX 自己的编码体系,不是 Unicode。
3.2.4 数学公式:一个极端案例
一份 LaTeX 论文里的积分公式 ∫₀∞ e−x² dx,在 PDF 内部大概是这样的:
- 积分号 ∫ 来自 CMR 数学字体,glyph ID 可能是
02 - 下标 0 来自另一个字体,通过坐标向下偏移 4 点实现
- 上标 ∞ 通过坐标向上偏移 16 点实现
- 字母 e 来自第三种字体
- 上标中的 −x² 又是一层坐标偏移
如果你试图复制这段公式,得到的大概率是一堆碎片——因为:
- 每个符号来自不同字体,各有各的编码
- 上下标纯靠坐标偏移,不是语义上的"上标"
- 没有任何信息说"这些东西合起来是一个积分"
3.2.5 绘制顺序 ≠ 阅读顺序
还有一个隐蔽问题:内容流中文字的绘制顺序不一定是阅读顺序。
两栏排版的论文,生成器可能先画完左栏所有文字,再画右栏。甚至有些生成器为了减少字体切换开销,会把同一字体的所有文字集中画完(不管它们在页面哪个位置),再换下一种字体。
人眼不受影响——最终画面是对的。但机器尝试提取文字时,就要用复杂的重排算法猜测正确的阅读顺序。这个问题在多栏、文本框、浮动图片等复杂排版中尤为严重。
3.3 图形和色彩:简要了解
3.3.1 矢量图形
PDF 中的线条、矩形、曲线都用路径描述。核心操作就几个:
m——移动画笔到某坐标(不画线)l——从当前位置画直线到某坐标c——画一段三次贝塞尔曲线(4 个控制点)re——画矩形S——描边(画线框),f——填充(涂满)
所有曲线——圆形、椭圆、弧线——都是贝塞尔曲线的近似拼接。字体中每个字母的轮廓也是贝塞尔曲线。当渲染器"画"一个 'S' 字母时,它其实是在渲染一组曲线围成的封闭区域并填充。
3.3.2 贝塞尔曲线:计算机画曲线的通用方式
为什么 PDF(以及几乎所有图形系统)要用"贝塞尔曲线"来画曲线?它到底是什么?
核心思想:用几个控制点定义一条平滑曲线。给定控制点后,曲线上的每个点由一个参数 t(从 0 到 1)算出——t=0 时在起点,t=1 时在终点,中间的值描述曲线上从头到尾的位置。
PDF 用的是三次贝塞尔曲线,由 4 个点定义:P₀(起点)、P₁、P₂(两个控制点)、P₃(终点)。方程是:
B(t) = (1-t)³·P₀ + 3(1-t)²t·P₁ + 3(1-t)t²·P₂ + t³·P₃ t ∈ [0, 1]
看起来复杂,但本质是加权平均——四个点各自有一个权重,权重随 t 变化。t 接近 0 时 P₀ 权重最大(曲线靠近起点),t 接近 1 时 P₃ 权重最大(曲线靠近终点),中间过渡时 P₁ 和 P₂ 把曲线"拉"向它们的方向。
为什么随便给 4 个点都能画出曲线?
因为这个方程对任意四个点都有定义——不管你把控制点放在哪里,代入公式都能算出一条连续平滑的曲线。不存在"无解"的情况。而且曲线保证经过起点和终点(把 t=0 和 t=1 代入就能验证),两个控制点则"拉"着曲线但曲线不一定经过它们。
为什么选贝塞尔而不是圆弧或其他曲线?
几个关键数学性质让它特别适合计算机图形:
- 端点必过:曲线一定从 P₀ 开始到 P₃ 结束,方便多段拼接
- 凸包性:曲线永远在四个控制点围成的凸包内,不会"跑飞"——这让碰撞检测和裁剪很高效
- 仿射不变:对控制点做平移/旋转/缩放,等价于对整条曲线做同样变换。PDF 的变换矩阵可以直接作用于控制点
- 计算高效:只有加法和乘法,没有三角函数,对 GPU 友好
这些性质组合在一起,让贝塞尔曲线成了计算机图形学的通用语言——不只是 PDF,Photoshop 的钢笔工具、SVG 路径、CSS animation 的 timing function、游戏引擎的路径规划,全部用的贝塞尔曲线。
顺便,如果你用过 Illustrator 或 Figma 的钢笔工具画路径——你拖出来的"手柄"就是控制点 P₁ 和 P₂。你拖得越远,曲线被"拉"得越狠。
3.3.3 颜色:为什么同一个"红"印出来不一样
你可能遇到过这种情况:屏幕上设计好的红色 logo,印刷出来变成了暗沉的砖红色。或者你在 MacBook 上调好的颜色,到了同事的 Windows 显示器上明显偏绿。
这不是 PDF 的 bug——这是物理现实:屏幕用光发色(RGB,三种光叠加越亮),印刷用墨吸色(CMYK,四种墨叠加越暗)。同一组数字在不同设备上产生的视觉效果不同。
PDF 为了解决这个问题,支持多种颜色空间:
- DeviceRGB:三个数字(红绿蓝,0-1),屏幕显示时直接用。简单但不精确——"你的红"和"我的红"可能不一样。
- DeviceCMYK:四个数字(青品黄黑),印刷时直接用。设计师给印刷厂的 PDF 一定是 CMYK 的。
- ICCBased:附带一个 ICC 颜色配置文件,精确定义"这个数字在标准光源下对应什么物理色彩"。这是最准确的方式——不管你用什么设备,只要支持色彩管理,就能还原创作者看到的颜色。
为什么 PDF 要同时支持这么多颜色模型?因为一份 PDF 可能被屏幕阅读、被办公打印机打印、被专业印刷厂输出——三种场景需要的颜色描述方式完全不同。PDF 允许在同一份文件中混用多种颜色空间,让每种输出都能拿到最合适的数据。
3.3.4 透明度:为什么有些 PDF 打开特别慢
你可能有过这样的经历:一份只有 3 页的设计稿 PDF,文件才 2MB,但打开时阅读器卡了好几秒,翻页也明显迟钝。而一份 500 页的纯文字论文,文件 50MB,却秒开。
罪魁祸首通常是透明度。
PDF 1.4(2001 年)加入了透明度和混合模式——和 Photoshop 的图层混合一个道理:Normal、Multiply、Screen、Overlay…你可以让一个元素半透明地叠在另一个元素上面。
为什么透明度这么消耗性能?
没有透明度的 PDF,渲染器可以一个一个画:先画背景,再画文字,后画的直接覆盖前画的。每个像素只算一次。
有透明度时,每个像素的最终颜色取决于它下面所有图层的颜色叠加。渲染器必须:
- 确定每个像素被哪些元素覆盖(可能几十层)
- 从最底层开始,逐层向上混合颜色
- 对页面上每一个像素都重复这个过程
一个 A4 页面在 150 DPI 下有约 150 万个像素。如果每个像素都要做多层混合,计算量是巨大的。
哪些元素会带透明度?
- 带阴影的文字或图形(阴影就是一个半透明黑色模糊层)
- 渐变叠加效果
- 半透明的水印
- 设计软件导出时的图层合成(Illustrator、Figma 导出的 PDF 经常带大量透明层)
所以一份设计稿 PDF 打开慢,不是因为"文件大",而是因为渲染器在逐像素做图层合成。而纯文字论文秒开,是因为每个像素只画了一次——没有透明度,没有混合。
具体在算什么?
"叠加"听起来简单,但每个像素的计算不只是"两个颜色取平均":
最终颜色 = 前景色 × 前景透明度 + 背景色 × (1 - 前景透明度) ← 这是最简单的 Normal 模式
如果是 Multiply 模式,公式变成把两个颜色的 RGB 分量相乘;Screen 模式是另一个公式;Overlay 更复杂——要根据背景亮度决定用 Multiply 还是 Screen。每种混合模式都是不同的数学运算。
而且这还只是两层的情况。如果一个像素上叠了 5 个半透明元素(背景色 + 渐变 + 图片 + 阴影 + 半透明文字),渲染器要从底到顶逐层计算:先把第 1、2 层混合得到中间结果,再把中间结果和第 3 层混合,再和第 4 层……每多一层就多一次浮点运算。
一个 A4 页面在 150 DPI 下有 ~150 万像素。如果页面上有一大块区域被多层透明元素覆盖,那这些像素每个都要做多次浮点乘法和加法。150 万 × 5 层 × 每层 3 个颜色通道 = 两千多万次浮点运算——光这一页就要消耗可观的 CPU 时间。
相比之下,没有透明度的页面:每个像素只取最上面那个元素的颜色,画完就完。一次赋值,没有混合计算。
3.5 一个真实页面长什么样
用第二章学的语法知识,我们来读一段真实的页面内容流——包含标题、正文和一条分隔线:
q % 保存状态
0.95 0.95 0.98 rg % 浅灰蓝色(RGB 填充色)
0 792 612 -80 re f % 顶部画一个 80 点高的色条
BT
/F2 28 Tf % 粗体,28 点
0.1 0.1 0.15 rg % 深色
72 740 Td % 移到 (72, 740)
(Chapter 3: Rendering) Tj % 画标题文字
ET
BT
/F1 11 Tf % 正文字体,11 点
72 680 Td % 正文起始位置
12 TL % 行距 12 点
(PDF uses a painting model.) Tj T*
(Each page is a blank canvas.) Tj T*
(Content is drawn in order.) Tj
ET
0.8 0.8 0.85 RG % 线条颜色(灰色)
0.5 w % 线宽 0.5 点
72 660 m 540 660 l S % 画一条水平线
Q % 恢复状态
T* 表示"换行"——按 TL 设定的行距向下移动。注意这个"换行"只是坐标偏移,不存在语义上的"新段落"概念。
这段代码画出来的效果就是:顶部有个浅色色条,色条下面是大标题,再下面是三行正文,正文下面一条细灰线。和你日常看到的 PDF 页面没什么两样。
3.6 核心洞察:为什么 PDF "不适合被机器理解"
从本章的内容中可以总结出三个根本特征——也正是这三个特征让 PDF 成为 AI 和文字提取的噩梦:
视觉驱动,非语义驱动:PDF 关心的是"在哪里画什么",不关心"这段文字的含义是什么"。没有段落、没有标题层级、没有列表——只有坐标和字形。
绘制顺序 ≠ 阅读顺序:内容流中对象的出现顺序是为了优化渲染效率,不是为了反映人类的阅读路径。
字符身份模糊:一个 glyph 不一定能唯一映射回一个 Unicode 字符。连字(ligature)、自定义编码、缺失映射表都可能导致失败。
PDF 被设计用来给人看,不是用来给机器读的。 这个设计哲学贯穿整个格式的三十年历史。到了 2026 年,从 PDF 中准确提取结构化信息仍然是一个未解决的难题——第 9 章会详细讨论。