PDF页面旋转与CropBox陷阱:译文回填位置总是错位的根因实测

前言

做 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)

两个结论:

  1. page.rect 会跟着旋转换宽高 (90°/270° 时宽高互换),mediabox / cropbox 不会;
  2. 文本提取坐标完全不受 /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)

处理流程上的建议:

  1. 先体检页面属性 。遍历所有页,打印 page.rotation、page.cropbox、page.mediabox,把 rotation != 0 或 cropbox != mediabox 的页单独标记出来。
  2. 提取文字时留意两件事 :一是 /CropBox 会截断越界文字(不报错),二是有 /CropBox 时坐标原点会平移。
  3. 回填时不要用 transformation_matrix ,按第七节的公式手工换算;旋转页上的文字朝向用 rotate=page.rotation 补偿。
  4. 回填后做一次自检 :把页面渲染出来,检查新增文字的墨迹包围盒是否落在 page.rect 内。验证 B 那种"一个字都没有"的情况,靠这个自检一眼就能发现。
  5. 自检脚本要比对像素而不是坐标。坐标错了但没报错的情况,只有渲染出来才能发现------本文的判定全部基于渲染后的墨迹包围盒,这也是最省事可靠的做法。

参考资料

  • 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翻译、坐标系统、文档处理

相关推荐
程序员清风1 小时前
Java 智能体开发:从对话接口到任务执行
java·人工智能·python
2601_966949651 小时前
每日自动更新股票行情:如何设计可靠的数据任务,避免重复写入和脏数据
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
ai小陈1 小时前
GPU服务器租用容器实战:Docker数据卷持久化与安全重建
服务器·人工智能·安全·docker·ai·gpu算力
打工仔折腾 AI1 小时前
从零写一个CAD 02:实体容器、Esc取消与键盘失灵的排查
人工智能·后端·python·性能优化
用户8356290780512 小时前
使用 Python 拆分和提取 PDF 页面
后端·python
用户8356290780512 小时前
使用 Python 操作 PowerPoint 中的图片
后端·python
梅雅达编程笔记2 小时前
02_BeautifulSoup网页数据提取
开发语言·爬虫·python·beautifulsoup·数据采集
ai小陈2 小时前
深度学习CUDA OOM排查:显存占用与碎片问题实战
服务器·人工智能·python·深度学习·ai·gpu算力
程序员无隅2 小时前
Pi Agent 系统提示词实战:替换默认编程助手人设,构建 DataAgent
ai