前言
做 PDF 翻译回填的时候,有一种 bug 特别难查:代码逻辑没问题,文字也提取出来了,但译文就是填在错误的位置,有时候甚至直接跑到页面外面看不见。
我排查下来,根因基本都落在两个很少被注意的页面属性上------/Rotate(页面旋转标记)和 /CropBox(裁剪框)。这两个属性和 PyMuPDF 里的 page.rect、mediabox、transformation_matrix 交互起来,坐标系会在你毫无察觉的情况下换掉。
本文用一组可复现的实测把这件事讲清楚:两个属性各自怎么改变坐标系、偏移量是多少、以及回填坐标的正确算法。
环境准备
- Python 3.13
- 依赖:
pip install pymupdf numpy - 实测版本:PyMuPDF 1.28.2
- 判定手段:把页面渲染成位图,取非白像素的包围盒(墨迹包围盒),用像素级 IoU 做客观判定
python
import numpy as np
import pymupdf
DPI = 100
SCALE = DPI / 72.0 # 渲染像素 / pt
def ink_bbox(page, dpi=DPI):
"""渲染页面,返回非白像素的包围盒(像素坐标)"""
pm = page.get_pixmap(dpi=dpi)
a = np.frombuffer(pm.samples, dtype=np.uint8).reshape(pm.height, pm.width, pm.n)
mask = a[:, :, :3].min(axis=2) < 200
if not mask.any():
return None
ys, xs = np.where(mask)
return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
一、/Rotate 只影响显示,不影响提取坐标系
先看最基础的一组:同一个 A4 竖版页面(595×842),只改 /Rotate,观察 page.rect 和文本提取结果。
| /Rotate | page.rect(可见区) | page.mediabox | 提取出的 word bbox |
|---|---|---|---|
| 0 | Rect(0, 0, 595, 842) | Rect(0, 0, 595, 842) | (100.0, 78.5, 233.4, 105.98) |
| 90 | Rect(0, 0, 842, 595) | Rect(0, 0, 595, 842) | (100.0, 78.5, 233.4, 105.98) |
| 180 | Rect(0, 0, 595, 842) | Rect(0, 0, 595, 842) | (100.0, 78.5, 233.4, 105.98) |
| 270 | Rect(0, 0, 842, 595) | Rect(0, 0, 595, 842) | (100.0, 78.5, 233.4, 105.98) |
两个结论:
page.rect会跟着旋转换宽高 (90°/270° 时宽高互换),mediabox/cropbox不会;- 文本提取坐标完全不受
/Rotate影响 ------四种旋转下 bbox 一模一样,说明提取出的坐标始终在未旋转的页面坐标系里。
第 2 点就是错位的根源:如果你把提取坐标当成"屏幕上的坐标"用(比如拿去做截图比对、画高亮框),在旋转页上必然错位。
二、旋转页上按原坐标用,会偏多少
在 /Rotate=90 的页面上实测:
python
doc = pymupdf.open()
page = doc.new_page(width=595, height=842)
page.insert_text((100, 100), "AAAAAAAAAA", fontsize=20)
page.set_rotation(90)
wb = page.get_text("words")[0]
pt = pymupdf.Point(wb[0], wb[1])
disp = pt * page.rotation_matrix # 未旋转坐标 -> 屏幕(旋转后)坐标
back = disp * page.derotation_matrix # 反算校验
输出:
文字真实页面坐标 : (100.0, 78.5)
page.rotation_matrix: Matrix(0.0, 1.0, -1.0, 0.0, 842.0, 0.0)
换算到屏幕坐标 : (763.5, 100.0)
用 derotation_matrix: (100.0, 78.5) -> 可逆
直接拿提取坐标当屏幕坐标用的偏差 : 663.85 pt
663.85 pt 的偏差,接近一张 A4 的短边长度。这就是为什么"看起来每行都对,但整块内容跑到另一边去了"。
rotation_matrix 和 derotation_matrix 是一对互逆矩阵,前者把未旋转页面坐标映射到旋转后的显示坐标,后者反过来。两者都可用,关键是别混用。
三、旋转页上回填文字,rotate 参数该给多少
更常见的坑是回填:在旋转页上插入文字,文字朝向会跟着页面一起转。我在 /Rotate=90 的页面上,用同一段文字(IIIIIWWWWW,前窄后宽便于判断阅读方向)试了四种 rotate:
判定方式不是靠肉眼,而是像素级 IoU:先把不旋转的页面正常渲染一张作为"正向参照",再把旋转页的渲染结果裁剪出文字区域,两者算交并比。IoU 越接近 1,说明读者看到的文字越接近正向。
| 页面 /Rotate | rotate=0 | rotate=90 | rotate=180 | rotate=270 |
|---|---|---|---|---|
| 90 | 尺寸不符(竖排) | 1.000 | 尺寸不符 | 0.221 |
| 180 | 0.225 | 尺寸不符 | 尺寸不符(被裁) | 尺寸不符 |
| 270 | 尺寸不符 | 0.225 | 尺寸不符 | 0.826 |
结论:rotate 要传成与页面 /Rotate 相同的值 。/Rotate=90 配 rotate=90 时 IoU 达到 1.000,即读者看到的文字与正常页面上的完全一致;而 rotate=0(最常见的写法)读者看到的墨迹区域是 21px 宽 × 167px 高 ,也就是竖排------同一段文字被"拧"了 90 度。
python
def insert_translated_text(page, point, text, **kw):
"""在任意旋转页上插入"读者看到是正的"文字"""
page.insert_text(point, text, rotate=page.rotation % 360, **kw)
另外提醒:rotate=90 时文字在页面坐标系里是"从插入点向上延伸"的,如果插入点太靠页面顶部,文字会被页面边界裁掉(实测中出现了墨迹宽度从 167px 被截到 137px 的情况)。回填前建议按文字长度和方向预留空间。
四、/CropBox 原点不为 0 时的坐标系
/CropBox 是"实际可见区域",用于裁掉页边多余的扫描黑边。它一旦不可忽略地偏离原点,坐标系就会整体平移。实测:mediabox 595×842,把 cropbox 设为 Rect(50, 80, 545, 762):
mediabox : Rect(0.0, 0.0, 595.0, 842.0)
cropbox : Rect(50.0, 80.0, 545.0, 762.0)
写进 PDF 的 /CropBox : [50 80 545 762]
page.rect(可见区) : Rect(0.0, 0.0, 495.0, 682.0)
transformation_matrix: Matrix(1.0, 0.0, 0.0, -1.0, -50.0, 762.0)
注意 transformation_matrix 的平移分量已经变成 (-50, 762),说明 PyMuPDF 的坐标原点是CropBox 的左上角,而不是 MediaBox 的。换算公式是:
pymupdf_x = pdf_x - cropbox.x0
pymupdf_y = cropbox.y1 - pdf_y
用同一段文字做对照,误差一目了然:
| 文字(PDF 空间) | 正确(含 CropBox 偏移) | 忽略偏移(错) | 偏差 |
|---|---|---|---|
| (200, 110) | (150.0, 652.0) | (200.0, 732.0) | 横向 50 pt / 纵向 80 pt |
| (30, 110) | (-20.0, 652.0) | (30.0, 732.0) | 同上 |
横向偏 50 pt、纵向偏 80 pt------正好等于 CropBox 相对 MediaBox 的偏移量。这类文件通常是"扫描时纸没放正、后来用工具裁掉黑边"产生的,肉眼完全看不出来,但程序一算就错。
五、CropBox 会把越界文字从提取结果里截掉
这是本次实测最意外的一条:/CropBox 不只是"渲染时裁掉",它会连文本提取一起裁。
不设 CropBox 提取 : ['LEFT_EDGE_TEXT_X30', 'KEPT_TEXT_X200']
设 CropBox 后提取 : ['FT_EDGE_TEXT_X30', 'KEPT_TEXT_X200']
'FT_EDGE_TEXT_X30' <-- 原始 'LEFT_EDGE_TEXT_X30',丢失 2 个字符
位于裁切线之外的文字,被直接截断 (LEFT_ 两个字符消失),而且不报任何错、不打任何警告。如果翻译流水线直接吃提取结果,就会译出半截单词,而日志里一切正常。
所以对带 CropBox 的文件,务必做一件事:比较 page.cropbox 与 page.mediabox,不一致就把 cropbox 重置为 mediabox 再提取,或者至少统计一下提取字符数是否异常偏少。
六、旋转 + CropBox 叠加:transformation_matrix 会丢偏移
单独用都还能对,两者叠加就出问题了。同一个页面同时设 cropbox = (50, 80, 545, 762) 和 rotation = 90:
python
target = pymupdf.Point(200, 700) # 目标:PDF 用户空间 (200,700)
got = target * page.transformation_matrix
good = pymupdf.Point(target.x - page.cropbox.x0, page.cropbox.y1 - target.y)
实测输出:
page.rect : Rect(0.0, 0.0, 682.0, 495.0)
transformation_matrix: Matrix(1.0, 0.0, 0.0, -1.0, 0.0, 682.0)
pdf(200,700) * transformation_matrix = (200.0, -18.0)
手工按 cropbox 换算的正确值 = (150.0, 62.0)
=> 横向偏 50.0 pt,纵向偏 80.0 pt
对比第四节可以看清楚问题所在:只要 /Rotate 非 0,transformation_matrix 的平移分量就从 (-50, 762) 变成了 (0, 682)------CropBox 那两个偏移被丢掉了。 它退化成只处理旋转和页面高度,不再计入 CropBox 原点。
这意味着在"旋转 + 非零 CropBox"的页面上,不能直接用 transformation_matrix 换算坐标。
两组验证实验:
验证 A:按手工换算的正确坐标 (150, 62) 插入文字。
换算到显示坐标 (620.0, 150.0) -> 预期墨迹左上角(px) ≈ (861, 208)
实测墨迹包围盒(px): (862, 193, 937, 208)
横坐标 862 与预期 861 吻合;纵坐标末端 208 与预期 208 完全一致(insert_text 的插入点是基线位置,所以字身向上延伸到 193,属于预期行为)。
验证 B:按 transformation_matrix 的输出 (200, -18) 插入文字。
实测墨迹包围盒(px): None 墨迹占比: 0.00000
-> 文字落在可见区之外,页面上什么都没有
页面上干干净净,一个字都没渲染出来。 这就是"回填完打开一看是空白"的完整成因------不报错,只是位置算错了。
七、工程结论
回填坐标的完整算法(同时处理旋转与 CropBox):
python
def pdf_point_to_page(page, pdf_x: float, pdf_y: float) -> pymupdf.Point:
"""PDF 用户空间坐标 -> PyMuPDF 页面坐标(同时修正 CropBox 与 /Rotate)
注意:不要用 page.transformation_matrix,
它在 /Rotate != 0 时会丢掉 CropBox 的原点偏移。
"""
cb = page.cropbox # 已含旋转无关的裁剪偏移
x = pdf_x - cb.x0
y = cb.y1 - pdf_y
# 这里得到的是"未旋转页面坐标",可直接用于 insert_text,
# 但文字朝向要按 page.rotation 补偿
return pymupdf.Point(x, y)
def insert_into_pdf_space(page, pdf_x, pdf_y, text, **kw):
"""把译文按 PDF 用户空间坐标回填,并保证读者看到是正的"""
pt = pdf_point_to_page(page, pdf_x, pdf_y)
page.insert_text(pt, text, rotate=page.rotation % 360, **kw)
处理流程上的建议:
- 先体检页面属性 。遍历所有页,打印
page.rotation、page.cropbox、page.mediabox,把rotation != 0或cropbox != mediabox的页单独标记出来。 - 提取文字时留意两件事 :一是
/CropBox会截断越界文字(不报错),二是有/CropBox时坐标原点会平移。 - 回填时不要用
transformation_matrix,按第七节的公式手工换算;旋转页上的文字朝向用rotate=page.rotation补偿。 - 回填后做一次自检 :把页面渲染出来,检查新增文字的墨迹包围盒是否落在
page.rect内。验证 B 那种"一个字都没有"的情况,靠这个自检一眼就能发现。 - 自检脚本要比对像素而不是坐标。坐标错了但没报错的情况,只有渲染出来才能发现------本文的判定全部基于渲染后的墨迹包围盒,这也是最省事可靠的做法。
参考资料
- PDF 规范 ISO 32000-1:7.7.3.3 节(Page Objects 之 /Rotate)、14.11.2 节(/CropBox 与页面边界)
- PyMuPDF 官方文档:Page.rect / mediabox / cropbox / rotation_matrix / derotation_matrix / transformation_matrix
- PyMuPDF 文档:Page.insert_text() 的 rotate 参数
标签:PDF、Python、PDF翻译、坐标系统、文档处理