Agent 读 PDF 前,先保住页码、双栏顺序和表格位置

把 PDF 交给 Agent 之前,先确认它看到的是哪一页、哪一栏和哪一格。

1. 先别把整份 PDF 拼成一条字符串

你让 Agent 回答“公开导出保留几天”,资料是一份带双栏和表格的 PDF。它摘出了数字 30,还附上“第 1 页”。这句话看起来已经可以交付,但还有三个具体问题:第 1 页指文件中的第一张,还是阅读器显示的标签 1?数字属于左栏的公开导出,还是右栏的私有预览?表格里同一列下面还有一个 7,它有没有拿错行?

本文只处理把 PDF 交给 Agent 之前的资料入口。我们生成四张可控页面,实际提取文字、词框、页标签和表格单元格,再故意输入错误位置,检查入口是否能指出问题。没有调用模型,也不测试回答准确率;所有保留天数都是原创合成数据,不代表任何真实服务政策。

最终产物 evidence.json 保留页面边界与空间位置。后续调用方可以据此回到原页,而不是仅拿到一串已经丢失列关系的文字。这里不展开检索排名、知识库分块或通用引用校验,重点是 PDF 的页面身份、阅读顺序和表格几何。

2. 四页实验怎样安排

第 1 张是普通文字页,包含 Public exports: 30 days.;第 2 张把两个独立说明放成左右两栏,并故意按 L1、R1、L2、R2 的顺序写入内容流;第 3 张是带完整规则线的表格,3 行、2 列,第一行为表头;第 4 张把第 1 张渲染成图片后嵌入新页,故意不附文字层。

最后一张是纯图合成页,并非真实扫描件。它让“看得见文字”和“抽得到文字”这两件事分开:图像上确实有说明和页脚,三个文字提取结果却为空。本文没有做 OCR,也没有测中文识别。

实测环境为 Python 3.12.14、pypdf 6.10.0、PyMuPDF 1.26.6、pdfplumber 0.11.9、reportlab 4.4.9。环境中这些包已经存在,全程无需安装。读者应先检查自己的环境;缺少依赖时这里的命令不会代为安装。官方 stable/latest 文档会更新,下面的行为结论以这组版本和夹具为准。

python -c "from importlib.metadata import version; print({n: version(n) for n in ['pypdf', 'PyMuPDF', 'pdfplumber', 'reportlab']})"

PyMuPDF 采用 GNU AGPL 与 Artifex 商业许可的双许可方式。把它接入自己的产品前应查阅官方许可说明并评估适用义务;本文不提供法律判断,也不把“包可以安装”解释成“任意分发都没有条件”。教程只包含原创调用代码,没有复制第三方 SDK 实现。

3. 物理页、PDF 标签和印刷页脚分别保存

数组下标从 0 开始,面向人的物理页从 1 开始。本例第二张页面的 index0 是 1,physical_page1 是 2,PDF 标签却是字符串 "1"。若拿标签当物理页,程序会打开普通文字页,而非双栏页。resolve 同时校验物理页与预期标签,因此这个错位能立即出现为 page_label_mismatch。

get_label()读取 PDF 页标签;未定义时可返回空字符串。印刷页脚则是画在页面上的内容,不由该接口识别。本例第四张的标签是 "3",图片内却仍然写着 Printed page i。这是生成器有意留下的差异,不能自动修正成同一个号码。

实际引用应先绑定文件内容和物理页,再展示标签辅助人核对。页脚识别若来自 OCR 或人工记录,需要另外说明来源;本例不把图中页脚伪装成已提取字段。

物理页按文件位置计数;标签来自 PDF;印刷页脚只是页面内容
01 / 同一张页面,三个不同身份。物理页按文件位置计数;标签来自 PDF;印刷页脚只是页面内容

4. 完整生成器:在空目录造出这份 PDF

创建一个空目录,将下面的 make_fixture.py、intake.py 和“完整测试”一节的 test_intake.py 原样保存。先运行 make_fixture.py,再运行 intake.py,最后运行测试。除了已列出的依赖,不需要下载资料、使用密钥或提供私人文件。生成器对 PDF 绘制使用固定参数;文件哈希仍应每次从实际字节计算,不要把本文的某次哈希当成所有运行的常量。

ReportLab 在此使用默认的左下角坐标绘制文字和线条;后面的提取结果使用左上角方向。表格底部在绘制坐标 y=560,转换到这张 792 point 高的页面上,对应提取坐标 bottom=232。字体边界和文字基线也不是同一件事。

保存为 make_fixture.py:

"""Four original, unrotated synthetic pages; no downloads or OCR."""
from pathlib import Path
import io
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from pypdf import PdfReader, PdfWriter
import pymupdf


def make_fixture(path):
    b = io.BytesIO()
    c = canvas.Canvas(b, pagesize=(612, 792), invariant=1)
    c.drawString(60, 720, 'SYNTHETIC MANUAL: RETENTION')
    c.drawString(60, 680, 'Public exports: 30 days.')
    c.drawString(60, 40, 'Printed page i')
    c.showPage()
    c.drawString(60, 730, 'SYNTHETIC TWO-COLUMN PAGE')
    for y, left, right in [
        (680, 'L1 Public exports', 'R1 Private previews'),
        (650, 'L2 retain 30 days.', 'R2 retain 7 days.'),
    ]:
        c.drawString(60, y, left)
        c.drawString(330, y, right)
    c.drawString(60, 40, 'Printed page 1')
    c.showPage()
    c.drawString(60, 730, 'SYNTHETIC TABLE')
    for x in [60, 240, 420]:
        c.line(x, 560, x, 680)
    for y in [560, 600, 640, 680]:
        c.line(60, y, 420, y)
    for y, name, days in [
        (655, 'Artifact', 'Days'),
        (615, 'Public export', '30'),
        (575, 'Private preview', '7'),
    ]:
        c.drawString(70, y, name)
        c.drawString(250, y, days)
    c.drawString(60, 40, 'Printed page 2')
    c.showPage()
    c.save()
    with pymupdf.open(stream=b.getvalue(), filetype='pdf') as doc:
        image = doc[0].get_pixmap(matrix=pymupdf.Matrix(2, 2)).tobytes('png')
    s = io.BytesIO()
    c = canvas.Canvas(s, pagesize=(612, 792), invariant=1)
    c.drawImage(ImageReader(io.BytesIO(image)), 0, 0, width=612, height=792)
    c.save()
    writer = PdfWriter()
    writer.append(PdfReader(io.BytesIO(b.getvalue())))
    writer.append(PdfReader(io.BytesIO(s.getvalue())))
    writer.set_page_label(0, 0, style='/r', start=1)
    writer.set_page_label(1, 3, style='/D', start=1)
    writer.write(str(path))


if __name__ == '__main__':
    make_fixture(Path('synthetic-manual.pdf'))

5. 提取器:让文字带着页面和位置一起出来

程序只读一次源文件字节,再把同一快照交给三个解析器。source_sha256 绑定的是这份字节内容,不是文件名,也不是文档真实性证明。versions 记录执行环境,避免升级库后仍把旧结果当作相同行为。再次使用结果前,对当前源字节重新计算哈希;变化时应重新提取并核对版面。

每个词保留 text 与 bbox;每张页面保存尺寸、旋转值、原始文字、排序文字、页标签和表格结果。此处 bbox 的顺序是 x0、top、x1、bottom,使用页面左上角原点,x 向右、y 向下,单位是 point。PyMuPDF 坐标说明解释了坐标系和页面变换。不要把图像像素直接填进这些字段;200% 渲染图片上的位置还需按渲染变换换算。

find_tables 找到表格后,代码通过 table.rows 逐行绑定单元格与提取文本,而不是猜测扁平 cells 列表的遍历顺序。词框由 pdfplumber 提供,裁剪文字由 PyMuPDF 提供;两者可能因字体边界算法存在差异。本例仅在标准、未旋转、未裁切的合成页面上核对坐标,没有宣称跨库几何在任意文件上完全等价。

下面的几个 checked 函数只是演示调用方如何发现选错页面、跨出已知栏区和读错单元格。它们接受本地受信的 evidence 对象,不是任意输入验证器;没有实现未知表索引、损坏 JSON 或恶意 PDF 的完整错误处理。

保存为 intake.py:

"""Fixture-oriented PDF intake. Coordinates are top-left points."""
from pathlib import Path
from importlib.metadata import version
import hashlib
import io
import json
import platform
import pdfplumber
import pymupdf
from pypdf import PdfReader

# Explicit fixture layout supplied by a human, not detected by this program.
REGIONS = {'left': [50, 90, 300, 155], 'right': [320, 90, 570, 155]}


def sha256(data):
    return hashlib.sha256(data).hexdigest()


def extract(path):
    data = Path(path).read_bytes()  # one byte snapshot for every parser
    result = {
        'source_sha256': sha256(data), 'python': platform.python_version(),
        'versions': {n: version(n) for n in ['pypdf', 'PyMuPDF', 'pdfplumber', 'reportlab']},
        'coordinate_system': 'top-left points; x right, y down; fixture unrotated',
        'pages': [],
    }
    reader = PdfReader(io.BytesIO(data))
    with pymupdf.open(stream=data, filetype='pdf') as doc, pdfplumber.open(io.BytesIO(data)) as pdf:
        for i, page in enumerate(doc):
            pp = pdf.pages[i]
            words = [{'text': w['text'], 'bbox': [w['x0'], w['top'], w['x1'], w['bottom']]}
                     for w in pp.extract_words()]
            raw = page.get_text()
            tables = []
            for table in pp.find_tables():
                matrix = table.extract()
                cells = []
                for r, row in enumerate(table.rows):
                    for col, box in enumerate(row.cells):
                        cells.append({'row0': r, 'col0': col,
                                      'bbox': list(box) if box else None,
                                      'text': matrix[r][col]})
                tables.append({'bbox': list(table.bbox), 'matrix': matrix, 'cells': cells})
            record = {
                'index0': i, 'physical_page1': i + 1, 'pdf_label': page.get_label(),
                'size_points': [page.rect.width, page.rect.height],
                'rotation': page.rotation, 'raw': raw,
                'sorted': page.get_text(sort=True),
                'pypdf': reader.pages[i].extract_text(),
                'words': words, 'tables': tables,
                'status': 'extracted' if raw.strip() else 'needs_review',
                'reason': None if raw.strip() else 'empty_text_extraction',
            }
            if i == 1:
                record['regions'] = [
                    {'name': name, 'bbox': box, 'method': 'explicit_fixture_clip',
                     'text': page.get_text(clip=pymupdf.Rect(box))}
                    for name, box in REGIONS.items()
                ]
            result['pages'].append(record)
    return result


def resolve(evidence, source_bytes, physical_page1, pdf_label):
    if sha256(source_bytes) != evidence['source_sha256']:
        raise ValueError('source_changed')
    if type(physical_page1) is not int or not 1 <= physical_page1 <= len(evidence['pages']):
        raise ValueError('bad_physical_page')
    page = evidence['pages'][physical_page1 - 1]
    if page['pdf_label'] != pdf_label:
        raise ValueError('page_label_mismatch')
    if page['status'] == 'needs_review':
        raise ValueError('needs_review')
    return page


def in_region(box, region):
    return (len(box) == 4 and box[0] < box[2] and box[1] < box[3]
            and region[0] <= box[0] and region[1] <= box[1]
            and box[2] <= region[2] and box[3] <= region[3])


def checked_region(page, name, box):
    region = next(r for r in page['regions'] if r['name'] == name)
    if not in_region(box, region['bbox']):
        raise ValueError('outside_named_column')
    return region


def checked_cell(page, table0, row0, col0, expected_text):
    cell = next(c for c in page['tables'][table0]['cells']
                if c['row0'] == row0 and c['col0'] == col0)
    if cell['text'] != expected_text:
        raise ValueError('cell_text_mismatch')
    return cell


if __name__ == '__main__':
    result = extract('synthetic-manual.pdf')
    Path('evidence.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
    print(json.dumps({'pages': [
        {'physical': p['physical_page1'], 'label': p['pdf_label'],
         'words': len(p['words']), 'status': p['status']}
        for p in result['pages']]}, indent=2))

6. 双栏实验:sort=True 之后仍然要看行列关系

本次 PyMuPDF 原始文字顺序为 L1 Public exports、R1 Private previews、L2 retain 30 days.、R2 retain 7 days.。pypdf 的普通提取也得到这个交错顺序。sort=True 在此夹具上把同一高度的左右文字排列在一起,仍然让 R1 出现在 L2 前面;它没有替你确定“先读完左栏,再读右栏”的语义。

已知版面后,我们为第二张页面明确提供 left=[50,90,300,155] 和 right=[320,90,570,155] 两个区域。裁剪结果分别是 L1+L2 和 R1+R2。这里的成功来自人为给出的正确区域,不是程序自动发现了两栏。换成不同栏宽、跨栏标题或侧边注释,必须重新确认范围。

checked_region 只回答某个框是否完全落在指定区域内,不证明框内任何文字必然支持结论。测试将 [60,99,500,145] 交给 left,程序以 outside_named_column 拒绝它,因为它延伸到了右栏。实际工作中应同时查看区域文字与页面渲染,避免把几何包含关系误当成语义验收。

真实夹具对象顺序 L1 → R1 → L2 → R2;已知裁剪区域单独提取
02 / 排序没有替你决定先读哪一栏。真实夹具对象顺序 L1 → R1 → L2 → R2;已知裁剪区域单独提取

7. 表格实验:30 必须属于公开导出的那一格

第三张页面中的表格矩阵实测为 [["Artifact","Days"],["Public export","30"],["Private preview","7"]],整体框是 [60,112,420,232]。Public export 对应的数字单元格为 row0=1、col0=1,框是 [240,152,420,192]。表头占第 0 行;说“第一行数据”时,要避免再次混淆。

如果只把页面文字串起来,30 与 7 都会出现,但行列绑定不再清楚。代码因此同时保存矩阵和逐格位置。独立测试故意请求 row0=2、col0=1,却仍期望 "30";该格实际是 "7",得到 cell_text_mismatch。测试不是根据提取结果动态生成期望值,预期矩阵和坐标都直接写在测试文件里。

pdfplumber 官方说明介绍了词、表格与单元格位置接口。本次验证的是完整规则线形成的简单网格。它不覆盖合并单元格、缺线表、背景色分区或跨页续表;对于这些资料,不能因为本例通过就省略人工核对。

规则线表格:3 行 × 2 列(含表头);下方坐标来自 pdfplumber 实测
03 / 表格答案必须回到对应单元格。规则线表格:3 行 × 2 列(含表头);下方坐标来自 pdfplumber 实测

8. 空提取只说明需要再看一眼

第四张页面的 raw 和 pypdf 字段均为空字符串,words 为空列表。程序给它 needs_review,并记录 empty_text_extraction。这个状态刻意没有命名为 blank_page:提取为空无法判定视觉页面是否空白。

pypdf 的文字提取说明区分文字提取与 OCR。对于本例,打开渲染图就能看到第一张的文字被保留为图片;要让系统读出图片内容,需要另外的识别或人工环节。本文没有调用识别引擎,也不使用模型补全所谓“遗漏文字”。

需要继续检查识别结果时,可阅读OCR 证据与复核实验,学习保留原始识别、词框和待确认项。该实验使用英文合成 PNG,词框单位是图像像素;接回 PDF 前还需建立页面与坐标映射。两篇尚未完成 PDF→OCR 集成实测,也未验证中文识别效果。

反过来,extracted 仅表示 raw 中出现了非空文字,不能理解成“整页已经正确理解”。混合图文页可能只有页眉被提取,正文仍在图片里。生产入口需要更细的覆盖检查,本教程的四页实验没有验证那一层。

9. 完整测试:用独立预期制造五种位置错误

把下列文件保存后运行 unittest。10 项测试覆盖正常页身份、错误页号、原始交错顺序、明确区域恢复、跨栏框、表格几何、错误单元格、纯图页、源文件变化与词框。测试用临时目录重新生成 PDF,因此不会依赖末节离线 ZIP 内的样例文件,也不会让已有 evidence.json 帮忙掩盖错误。

这组测试的预期来自生成器版面设计。例如数字 30 的词框约为 [142.032,102.484,155.376,114.484],而表格数字所在的“单元格框”是 [240,152,420,192]。前者围住文字,后者围住网格空间,两种框不应混为一谈。

保存为 test_intake.py:

import tempfile
import unittest
from pathlib import Path
from make_fixture import make_fixture
from intake import extract, resolve, checked_region, checked_cell


class IntakeTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.tmp = tempfile.TemporaryDirectory()
        cls.path = Path(cls.tmp.name) / 'sample.pdf'
        make_fixture(cls.path)
        cls.data = cls.path.read_bytes()
        cls.e = extract(cls.path)

    @classmethod
    def tearDownClass(cls):
        cls.tmp.cleanup()

    def test_page_identities(self):
        self.assertEqual([(p['physical_page1'], p['pdf_label']) for p in self.e['pages']],
                         [(1, 'i'), (2, '1'), (3, '2'), (4, '3')])
        self.assertIn('Printed page i', self.e['pages'][0]['raw'])

    def test_wrong_page_is_rejected(self):
        with self.assertRaisesRegex(ValueError, 'page_label_mismatch'):
            resolve(self.e, self.data, 1, '1')
        for n in [0, 5, True]:
            with self.assertRaisesRegex(ValueError, 'bad_physical_page'):
                resolve(self.e, self.data, n, '1')

    def test_actual_raw_interleaving(self):
        raw = self.e['pages'][1]['raw']
        self.assertLess(raw.index('L1'), raw.index('R1'))
        self.assertLess(raw.index('R1'), raw.index('L2'))
        self.assertLess(raw.index('L2'), raw.index('R2'))
        # sort=True still places the right first row before left second row here.
        sorted_text = self.e['pages'][1]['sorted']
        self.assertLess(sorted_text.index('R1'), sorted_text.index('L2'))

    def test_explicit_columns(self):
        regions = self.e['pages'][1]['regions']
        self.assertEqual(regions[0]['text'], 'L1 Public exports\nL2 retain 30 days.\n')
        self.assertEqual(regions[1]['text'], 'R1 Private previews\nR2 retain 7 days.\n')

    def test_cross_column_box_rejected(self):
        page = resolve(self.e, self.data, 2, '1')
        self.assertEqual(checked_region(page, 'left', [60, 99, 200, 145])['name'], 'left')
        with self.assertRaisesRegex(ValueError, 'outside_named_column'):
            checked_region(page, 'left', [60, 99, 500, 145])

    def test_table_geometry(self):
        table = self.e['pages'][2]['tables'][0]
        self.assertEqual(table['matrix'], [['Artifact', 'Days'], ['Public export', '30'], ['Private preview', '7']])
        self.assertEqual(table['bbox'], [60, 112, 420, 232])
        cell = checked_cell(self.e['pages'][2], 0, 1, 1, '30')
        self.assertEqual(cell['bbox'], [240, 152, 420, 192])

    def test_wrong_cell_rejected(self):
        with self.assertRaisesRegex(ValueError, 'cell_text_mismatch'):
            checked_cell(self.e['pages'][2], 0, 2, 1, '30')

    def test_image_only_needs_review(self):
        page = self.e['pages'][3]
        self.assertEqual(page['raw'], '')
        self.assertEqual(page['pypdf'], '')
        self.assertEqual(page['words'], [])
        self.assertEqual(page['reason'], 'empty_text_extraction')
        with self.assertRaisesRegex(ValueError, 'needs_review'):
            resolve(self.e, self.data, 4, '3')

    def test_source_change_rejected(self):
        with self.assertRaisesRegex(ValueError, 'source_changed'):
            resolve(self.e, self.data + b'\n% changed\n', 2, '1')

    def test_word_bbox_survives(self):
        words = self.e['pages'][0]['words']
        word = next(w for w in words if w['text'] == '30')
        for actual, expected in zip(word['bbox'], [142.032, 102.484, 155.376, 114.484]):
            self.assertAlmostEqual(actual, expected, places=3)


if __name__ == '__main__':
    unittest.main()
python make_fixture.py
python intake.py
python -m unittest -v test_intake.py

本次运行结果为 Ran 10 tests,OK;四页词数依次为 10、20、13、0,状态依次为 extracted、extracted、extracted、needs_review。这个词数由 pdfplumber.extract_words() 定义,只用于回归夹具,不是语言学词数或文档质量评分。

10. 把结果交给 Agent 时怎样表达边界

给下游的任务可以很具体:“仅使用物理第 2 页、PDF 标签 1 的 left 区域说明公开导出保留期限;保留文件 SHA-256、区域坐标和原文。若目标页为 needs_review,停止给出期限并请求查看原页。”这是一份推荐的任务写法,本文没有验证任何具体 Agent 会遵循它。

真正上线前,再加入自己的代表性文件集:先逐页确认页序,再挑最容易混淆的双栏段落与表格行做人工标注,最后检查空提取是否被正确转交。不要直接把本文固定的第二页和两组裁剪框用于别人的文件。示例甚至不会声称可以自动判别版面类型。

我们没有测试页面旋转与 CropBox 偏移、合并或跨页表、中文真实扫描、OCR、损坏或恶意 PDF、资源耗尽防护,以及真实模型回答。这里最有用的结论比较朴素:先保留文件身份、物理页面和可回查的位置,再决定怎样组织文字给 Agent 阅读。遇到提取不到的页,留下明确状态比编造一句完整答案更可靠。

11. 补测:把旋转与裁剪后的词框画回 PNG

前文四页实验留下了“未测试页面旋转与 CropBox 偏移”的边界。这组补测单独生成另一份五页 PDF,检查词框怎样落到渲染图像上;原来的页码、双栏和表格实验保持不变。它不读取前文的 evidence.json,也没有给旧提取器增加跨库坐标转换。

补测的输入框只来自 PyMuPDF page.get_text('words') 返回的未旋转页面坐标。前文 evidence.json 的词框来自 pdfplumber,不能直接传给这里的函数。即使两个库都使用 point、都用四个数字表示矩形,也不能据此判定它们的旋转、裁剪原点和字体边界相同。

五页的 MediaBox 都是 600 × 450 point:一页保持未旋转、未裁剪;其余四页用 PyMuPDF 的 set_cropbox(Rect(80,100,480,380)) 设置可见区域,再分别旋转 0°、90°、180°、270°。这里的数值属于 PyMuPDF API 坐标,不是直接照抄 PDF 文件里的原始 /CropBox 数组。每页有一个蓝色矩形和黑色 TARGET;整页按 2 倍、局部按 1.5 倍各渲染一次,共十个案例。

实测环境为 Python 3.12.14、PyMuPDF 1.26.6、MuPDF 1.26.11。九项 unittest 方法全部通过;另用 Poppler 26.05.0 核对其中一个 90° 整页案例。所有页面都是原创合成数据,没有用户 PDF、OCR 或模型调用。

蓝色标记经过四个坐标空间。图为示意,数值对应 cropped-90-clip;文字词框由同一条变换链处理。
蓝色标记经过四个坐标空间。图为示意,数值对应 cropped-90-clip;文字词框由同一条变换链处理。

12. 坐标要走完三步,裁剪偏移只处理一次

PyMuPDF Page 文档区分了未旋转页面坐标和反映旋转后的 page.rect,并提供 rotation_matrix 在两者之间转换。本例先将提取框乘以页面旋转矩阵,再乘以实际渲染使用的等比缩放矩阵,最后减去 Pixmap 的 x、y 原点,得到保存后 PNG 内部的像素坐标。

device = rect * page.rotation_matrix * fitz.Matrix(scale, scale)
local_png = shift(device, -pix.x, -pix.y)

不要对已经提取的词框再减一次 CropBox。代码里对 MARKER 做的减法有另一层含义:MARKER 是生成器在设置 CropBox 之前画下的已知位置,需要先换算到裁剪后的未旋转页面空间。来自 get_text('words') 的 word 已经在该空间,直接进入 pixel_box。把这两条路径混在一起,就会产生下面的“重复减裁剪偏移”反例。

局部渲染的 clip 在本实验中使用旋转后页面空间,尚未乘缩放比例;不要把这个约定外推给同名的文字提取 clip 参数。cropped-90-clip 的 clip 为 [154.75,52.75,218.25,189.25],乘 1.5 后左上角是 [232.125,79.125],实际 pixmap 原点却是整数 [232,79]。因此应减实际 pix.x、pix.y,不能拿 clip 左上角乘缩放值替代。

这一例的蓝色标记在未旋转页面内是 [60,70,180,120],经过旋转成为 [160,60,210,180],缩放成为 [240,90,315,270],最后在 96 × 205 的 PNG 内落到 [8,11,83,191]。保存 PNG 时也应另存 pixmap_origin、渲染比例和页面旋转信息,后续查看图片的人才知道框对应哪个坐标空间。

13. 完整补测代码:生成页面并从像素反查位置

将以下文件保存到一个空目录。probe.py 生成固定输入,提取 TARGET 的词框,渲染十张 PNG,并写出 results.json。蓝色标记的实际边界来自逐像素颜色检测,没有复用页面变换计算;黑色文字也由像素检测得到墨迹范围。这样可以比较“按坐标预测的位置”和“图像里实际出现的位置”。

蓝色矩形是几何校验用的标记,不代表词框边缘。词框的验收条件是变换后的框向外放宽 1 像素后包含黑色墨迹,不要求字体框与可见墨迹完全相等。四条边的误差定义为预测框与蓝色像素外接框相应边之差的最大绝对值;像素框的右、下边采用末尾像素下标加 1。

保存为 probe.py:

"""Original synthetic PDF-to-raster coordinate probe. No real inputs or networks."""
from pathlib import Path
import hashlib
import json
import platform
import pymupdf as fitz

ROOT = Path(__file__).resolve().parent
ART = ROOT / 'artifacts'
MARKER = fitz.Rect(140, 170, 260, 220)
CROP = fitz.Rect(80, 100, 480, 380)
CASES = [('uncropped-0', 0, None)] + [(f'cropped-{a}', a, CROP) for a in (0, 90, 180, 270)]

def rounded(obj):
    return [round(x, 5) for x in obj]

def fixture_bytes():
    doc = fitz.open()
    for label, angle, crop in CASES:
        p = doc.new_page(width=600, height=450)
        p.draw_rect(MARKER, color=None, fill=(0, 0, 1), width=0)
        p.insert_text((150, 202), 'TARGET', fontsize=18, color=(0, 0, 0))
        if crop is not None:
            p.set_cropbox(crop)
        p.set_rotation(angle)
    data = doc.tobytes(no_new_id=True, garbage=4, deflate=True)
    doc.close()
    return data

def shift(rect, x, y):
    return fitz.Rect(rect.x0+x, rect.y0+y, rect.x1+x, rect.y1+y)

def pixel_box(rect, page, scale, pix):
    # Input: PyMuPDF unrotated page coordinates, NOT raw PDF coordinates.
    device = rect * page.rotation_matrix * fitz.Matrix(scale, scale)
    return shift(device, -pix.x, -pix.y)

def blue_bounds(pix):
    # Independent raster measurement; no page transform is used here.
    data = pix.samples
    xs, ys = [], []
    for y in range(pix.height):
        for x in range(pix.width):
            i = y*pix.stride + x*pix.n
            if data[i] < 10 and data[i+1] < 10 and data[i+2] > 240:
                xs.append(x); ys.append(y)
    if not xs:
        raise AssertionError('synthetic marker is absent')
    return fitz.Rect(min(xs), min(ys), max(xs)+1, max(ys)+1)

def black_bounds(pix):
    data = pix.samples
    xs, ys = [], []
    for y in range(pix.height):
        for x in range(pix.width):
            i = y*pix.stride + x*pix.n
            if max(data[i:i+3]) < 30:
                xs.append(x); ys.append(y)
    if not xs:
        raise AssertionError('synthetic text ink is absent')
    return fitz.Rect(min(xs), min(ys), max(xs)+1, max(ys)+1)

def max_error(a, b):
    return round(max(abs(x-y) for x,y in zip(a,b)), 5)

def report(write_artifacts=True):
    data = fixture_bytes()
    assert data == fixture_bytes(), 'fixture bytes must be reproducible in this runtime'
    if write_artifacts:
        ART.mkdir(exist_ok=True)
        (ART / 'coordinate-fixture.pdf').write_bytes(data)
    doc = fitz.open(stream=data, filetype='pdf')
    rows = []
    for n, (label, angle, crop) in enumerate(CASES):
        page = doc[n]
        # Fixture generator's known marker is shifted once by the CropBox change.
        # The word box below comes directly from extraction and needs no such shift.
        marker = shift(MARKER, -page.cropbox.x0, -page.cropbox.y0)
        words = page.get_text('words')
        assert len(words) == 1 and words[0][4] == 'TARGET'
        word = fitz.Rect(words[0][:4])
        rotated_marker = marker * page.rotation_matrix
        for mode, scale in [('full', 2.0), ('clip', 1.5)]:
            clip = None if mode == 'full' else fitz.Rect(
                rotated_marker.x0-5.25, rotated_marker.y0-7.25,
                rotated_marker.x1+8.25, rotated_marker.y1+9.25)
            pix = page.get_pixmap(matrix=fitz.Matrix(scale,scale), clip=clip,
                                  colorspace=fitz.csRGB, alpha=False, annots=False)
            actual = blue_bounds(pix)
            correct = pixel_box(marker, page, scale, pix)
            word_pixels = pixel_box(word, page, scale, pix)
            ink = black_bounds(pix)
            no_rotation = shift(marker*fitz.Matrix(scale,scale), -pix.x, -pix.y)
            double_crop = pixel_box(shift(marker, -page.cropbox.x0, -page.cropbox.y0),page,scale,pix)
            no_origin = marker*page.rotation_matrix*fitz.Matrix(scale,scale)
            row = {
                'case':label+'-'+mode, 'page_index0':n, 'rotation':angle,
                'cropbox':rounded(page.cropbox), 'page_rect':rounded(page.rect),
                'word_text':words[0][4], 'word_unrotated':rounded(word),
                'marker_unrotated':rounded(marker), 'rotation_matrix':rounded(page.rotation_matrix),
                'scale':scale, 'clip_rotated_page':None if clip is None else rounded(clip),
                'pixmap_origin':[pix.x,pix.y], 'png_size':[pix.width,pix.height],
                'marker_expected_pixels':rounded(correct), 'marker_measured_pixels':rounded(actual),
                'word_expected_pixels':rounded(word_pixels), 'text_ink_measured_pixels':rounded(ink),
                'word_box_contains_ink':(word_pixels + (-1,-1,1,1)).contains(ink),
                'correct_max_error_px':max_error(correct,actual),
                'negative_control_max_error_px':{
                    'omit_rotation':max_error(no_rotation,actual),
                    'subtract_cropbox_twice':max_error(double_crop,actual),
                    'omit_pixmap_origin':max_error(no_origin,actual)
                },
                'png':label+'-'+mode+'.png'
            }
            if write_artifacts: pix.save(ART/row['png'])
            rows.append(row)
    doc.close()
    return {'runtime':{'python':platform.python_version(),'pymupdf':fitz.VersionBind,'mupdf':fitz.VersionFitz},
            'source_sha256':hashlib.sha256(data).hexdigest(), 'cases':rows}

if __name__ == '__main__':
    result = report()
    (ROOT/'results.json').write_text(json.dumps(result,indent=2)+'\n')
    for row in result['cases']:
        print(row['case'], 'correct_error=',row['correct_max_error_px'], 'contains_ink=',row['word_box_contains_ink'], 'negative_errors=',row['negative_control_max_error_px'])

14. 十个结果:普通整页会把三个错误一起藏住

十个案例中,正确变换的蓝色边界误差都是 0 像素,变换后的 TARGET 词框也都包含实际黑色墨迹。0 像素是这些固定矩形、比例和渲染器的实测结果;不表示所有字体、分数边界或不同渲染器都应得到同样的精度。

最大边界误差,单位:像素
案例正确变换漏乘旋转重复减裁剪漏减原点
uncropped-0-full0000
uncropped-0-clip000244
cropped-0-full002000
cropped-0-clip0015094
cropped-90-full02002000
cropped-90-clip0150150232
cropped-180-full03202000
cropped-180-clip0240150322
cropped-270-full04402000
cropped-270-clip0330150319

uncropped-0-full 中三种错误写法也都是 0:没有旋转、没有裁剪偏移,整页 pixmap 原点恰好又是 [0,0]。只拿这样一张普通页面检查,很容易误以为“把 point 乘缩放比例”就够了。旋转页让第一种错误出现,偏移 CropBox 让第二种出现,局部渲染让第三种出现;每个反例各有用处。

cropped-90-full 的 PNG 为 560 × 800,蓝色区域实测 [320,120,420,360]。局部 PNG 为 96 × 205,实测 [8,11,83,191]。下面两张直接来自生成的 PDF 渲染,没有手工挪动标记,也没有叠画预测框;文字随页面旋转是输入设置的预期效果。

实际整页 PNG:第 3 张合成页,90°,2 倍渲染,560 × 800 像素。
实际整页 PNG:第 3 张合成页,90°,2 倍渲染,560 × 800 像素。
实际局部 PNG:同一页,1.5 倍,96 × 205 像素。按原始像素尺寸显示,不放大填满栏宽。
实际局部 PNG:同一页,1.5 倍,96 × 205 像素。按原始像素尺寸显示,不放大填满栏宽。

15. 独立预期与真实变异:确认测试确实会失败

test_probe.py 写死了两个 90° 案例的图像尺寸、蓝色边界,以及裁剪图的整数原点。测试还检查四个旋转页的 PyMuPDF 词框保持 [70,82.65,143.008,107.382],并对十个案例检查几何误差与墨迹包含关系。预期值不从本次预测框反向生成;逆变换检查只是额外约束,不能代替像素证据。

保存为 test_probe.py:

"""Assertions fixed independently of the current output; no network or OCR."""
import unittest
import pymupdf as fitz
from probe import report, fixture_bytes

class CoordinateTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.result = report(write_artifacts=False)
        cls.rows = {r['case']:r for r in cls.result['cases']}

    def test_fixture_has_five_pages_and_reproducible_bytes(self):
        a, b = fixture_bytes(), fixture_bytes()
        self.assertEqual(a, b)
        with fitz.open(stream=a, filetype='pdf') as doc:
            self.assertEqual(len(doc), 5)

    def test_known_90_degree_full_frame(self):
        r = self.rows['cropped-90-full']
        self.assertEqual(r['marker_measured_pixels'], [320,120,420,360])
        self.assertEqual(r['png_size'], [560,800])
        self.assertEqual(r['word_unrotated'], [70,82.65,143.008,107.382])

    def test_known_90_degree_clipped_frame_and_rounding(self):
        r = self.rows['cropped-90-clip']
        self.assertEqual(r['pixmap_origin'], [232,79])
        self.assertEqual(r['png_size'], [96,205])
        self.assertEqual(r['marker_measured_pixels'], [8,11,83,191])
        # clip.x0*scale is 232.125; saved pixels start at the integer pix.x.
        self.assertNotEqual(r['clip_rotated_page'][0]*r['scale'], r['pixmap_origin'][0])

    def test_correct_mapping_and_text_ink_all_cases(self):
        self.assertEqual(len(self.rows), 10)
        for name, r in self.rows.items():
            with self.subTest(case=name):
                self.assertEqual(r['correct_max_error_px'], 0)
                self.assertTrue(r['word_box_contains_ink'])

    def test_rotation_does_not_change_extracted_word_box(self):
        expected = [70,82.65,143.008,107.382]
        for angle in (0,90,180,270):
            self.assertEqual(self.rows[f'cropped-{angle}-full']['word_unrotated'],expected)

    def test_missing_rotation_is_exposed_only_on_rotated_controls(self):
        for r in self.rows.values():
            e = r['negative_control_max_error_px']['omit_rotation']
            if r['rotation'] == 0: self.assertEqual(e, 0)
            else: self.assertGreaterEqual(e, 150)

    def test_double_crop_subtraction_is_exposed_only_with_offset(self):
        for r in self.rows.values():
            e = r['negative_control_max_error_px']['subtract_cropbox_twice']
            if r['case'].startswith('uncropped'): self.assertEqual(e, 0)
            else: self.assertGreaterEqual(e, 150)

    def test_ignored_origin_is_exposed_only_by_clipped_renders(self):
        for r in self.rows.values():
            e = r['negative_control_max_error_px']['omit_pixmap_origin']
            if r['case'].endswith('full'): self.assertEqual(e, 0)
            else: self.assertGreaterEqual(e, 94)

    def test_pixel_marker_coordinates_roundtrip(self):
        # Inverse only for tested axis-aligned scales and right-angle page rotations.
        for r in self.rows.values():
            box = fitz.Rect(r['marker_expected_pixels'])
            x,y = r['pixmap_origin']
            device = box + (x,y,x,y)
            unrotated = device * ~fitz.Matrix(r['scale'],r['scale']) * ~fitz.Matrix(r['rotation_matrix'])
            for got, expected in zip(unrotated,r['marker_unrotated']):
                self.assertAlmostEqual(got, expected, places=4)

if __name__ == '__main__':
    unittest.main(verbosity=2)

probe.py 中的三列错误结果便于阅读。下面再把 pixel_box 的实现分别换成三个错误版本,在独立临时目录实际运行原来的九项测试,避免“只算出几个错误数字,却没有证明测试能抓住错误”。未修改版本退出码为 0;三个变异版本退出码都非 0,并且都触发 test_correct_mapping_and_text_ink_all_cases。mutation-results.json 留下每个失败标签。

保存为 mutation_checks.py:

"""Run real implementation mutants in isolated temporary directories."""
from pathlib import Path
import json
import re
import subprocess
import sys
import tempfile

ROOT = Path(__file__).resolve().parent
SOURCE = (ROOT / 'probe.py').read_text()
PATTERN = r'def pixel_box\(rect, page, scale, pix\):\n.*?(?=\ndef blue_bounds)'
MUTANTS = {
    'omit_rotation': '''def pixel_box(rect, page, scale, pix):
    return shift(rect * fitz.Matrix(scale, scale), -pix.x, -pix.y)
''',
    'subtract_cropbox_twice': '''def pixel_box(rect, page, scale, pix):
    rect = shift(rect, -page.cropbox.x0, -page.cropbox.y0)
    device = rect * page.rotation_matrix * fitz.Matrix(scale, scale)
    return shift(device, -pix.x, -pix.y)
''',
    'omit_pixmap_origin': '''def pixel_box(rect, page, scale, pix):
    return rect * page.rotation_matrix * fitz.Matrix(scale, scale)
''',
}

def run_suite(source):
    with tempfile.TemporaryDirectory() as d:
        work = Path(d)
        (work / 'probe.py').write_text(source)
        (work / 'test_probe.py').write_bytes((ROOT / 'test_probe.py').read_bytes())
        p = subprocess.run([sys.executable, '-m', 'unittest', '-v', 'test_probe.py'],
                           cwd=work, text=True, capture_output=True)
    return {'exit_code': p.returncode,
            'tests_run': int(re.search(r'Ran (\d+) tests?', p.stderr).group(1)),
            'failure_labels': [line for line in p.stderr.splitlines()
                               if line.startswith(('FAIL:', 'ERROR:'))]}

def main():
    baseline = run_suite(SOURCE)
    assert baseline['exit_code'] == 0 and baseline['tests_run'] == 9
    result = {'baseline': baseline, 'mutants': {}}
    for name, body in MUTANTS.items():
        mutated, count = re.subn(PATTERN, lambda _: body + '\n', SOURCE, flags=re.S)
        assert count == 1
        report = run_suite(mutated)
        assert report['exit_code'] != 0 and report['tests_run'] == 9
        assert any('test_correct_mapping_and_text_ink_all_cases' in x
                   for x in report['failure_labels'])
        result['mutants'][name] = report
    (ROOT / 'mutation-results.json').write_text(json.dumps(result, indent=2) + '\n')
    print(json.dumps(result, indent=2))

if __name__ == '__main__':
    main()

16. 复现、交叉核对与尚未覆盖的边界

下载两组实验的完整离线源码包(ZIP):包含本文七个 Python 文件、original-experiment 目录、合成 PDF、生成的 PNG 与 JSON 结果,以及 README 和 SHA256SUMS。源码与正文代码逐字节一致;图片只是合成 PDF 的渲染结果,不是真实文档或产品截图。ZIP 的 SHA-256:0fb95593548c65f80066131650ae95d6777b3e560737d7b9607f2dd8383d1231。

检查已有依赖后,在新实验目录依次运行以下命令。源代码包同时保留前文的 original-experiment 目录,两组实验使用各自的 PDF 和输出文件,互不覆盖。新的核心实验只需要 PyMuPDF;独立渲染核对另外使用系统 pdftoppm 和 Pillow 12.3.0。命令不会安装软件、下载数据或连接网络。

python -c "import sys, pymupdf; print(sys.version); print(pymupdf.VersionBind, pymupdf.VersionFitz)"
python probe.py
python -m unittest -v test_probe.py
python mutation_checks.py
python verify_poppler.py

这些补测命令请使用普通 Python 模式运行,不要加 -O,也不要设置 PYTHONOPTIMIZE;生成器、变异检查和 Poppler 核对脚本中的 assert 在优化模式下会被跳过。README 分别列出两组实验的运行目录和已有依赖检查,SHA256SUMS 可在重新生成输出前用于核对包内文件。

在同一运行时中,生成器会连续造两份 PDF 并检查字节相同。本次 PDF 的 SHA-256 是 28ef72aa86a2207360119fd3728dffef0b2dd30b5ed8b557f360ccea7b8c7082;它用于核对这份合成输入,不保证换版本后仍产生相同字节。results.json 保存五页的页面索引、CropBox、旋转矩阵、原始词框、clip、pixmap 原点、PNG 尺寸、预测框与实测框,便于逐项复算。

以下脚本使用 Poppler 的 pdftoppm,以 -cropbox、144 dpi 渲染物理第 3 页。实测版本 26.05.0 得到 560 × 800 图像和蓝色边界 [320,120,420,360],与这一案例相符。这只交叉核对一个整页案例,没有核对其余九个案例,也没有比较两个渲染器的逐像素结果。

保存为 verify_poppler.py:

"""Independent renderer check for the single 90-degree cropped full-page case."""
import json
import subprocess
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent
source = ROOT / 'artifacts/coordinate-fixture.pdf'
out = ROOT / 'artifacts/poppler-cropped-90'
version = subprocess.run(['pdftoppm','-v'],text=True,capture_output=True,check=True).stderr.splitlines()[0]
subprocess.run(['pdftoppm','-f','3','-singlefile','-cropbox','-r','144','-png',str(source),str(out)],check=True)
im=Image.open(out.with_suffix('.png')).convert('RGB')
xy=[(x,y) for y in range(im.height) for x in range(im.width)
    if (lambda c:c[0]<10 and c[1]<10 and c[2]>240)(im.getpixel((x,y)))]
b=[min(x for x,y in xy),min(y for x,y in xy),max(x for x,y in xy)+1,max(y for x,y in xy)+1]
assert im.size==(560,800)
assert b==[320,120,420,360]
result={'renderer':version,'page_physical1':3,'cropbox':True,'dpi':144,'png_size':list(im.size),'marker_bounds':b,'passed':True}
(ROOT/'poppler-result.json').write_text(json.dumps(result,indent=2)+'\n')
print(json.dumps(result,indent=2))

本次只覆盖指定的五页、四种直角旋转、一个偏移 CropBox、两种等比缩放和包含整个标记的局部区域。没有验证任意仿射变换、UserUnit、负向或超出页面的 clip、字形裁切、跨库通用映射、OCR、真实扫描、恶意文件、资源耗尽防护或真实 Agent 回答。若要接回实际资料入口,先为目标文件确认提取器与坐标约定,再记录完整渲染参数,并查看标注是否落在原图中的正确内容上。

物理页与 PDF 标签能直接互换吗?

不能。本例第二张的物理页是 2,标签是 1;第四张标签是 3,图像页脚仍写 i。

sort=True 会自动恢复双栏阅读顺序吗?

本例不会。已知裁剪区域可以分别提取两栏,但区域来自人工版面约定。

文字提取为空表示空白页吗?

不表示。本例纯图页有可见文字,却无文字层,应记为 needs_review。

这套代码已验证真实扫描或 Agent 准确率吗?

没有。验证范围是四页合成 PDF 的确定性提取和位置检查,没有 OCR 或模型调用。