前言
用 AI 翻译整本 PDF 之后,最常见的验收问题不是"翻得准不准",而是------我怎么知道它没把排版弄丢?
人眼翻一遍 300 页文档不现实,抽样检查又容易漏。尤其在做批量文档处理时,"翻译完成"和"翻译正确"是两件事:可能某几页表格被拆成了段落、某个双栏章节变成了单栏、目录页码整体错位,而这些问题不打开文件逐页看根本发现不了。
这篇给出一套自动化的格式完整性校验方案:从原文和译文中分别抽取"结构指纹"(页数、表格数、段落块数、标题层级、图片数),做差异比对,输出一份可视化的校验报告。这套脚本我用了几个月,能稳定发现 90% 以上的版式异常。
环境准备
- Python 3.10+
- 依赖:
pip install pdfplumber pypdf
bash
pip install pdfplumber pypdf
核心思路:版式保留得好的译文,其"结构指纹"应该与原文高度一致。语言变了,但页面数、表格数量、表格行列数、段落块数量、标题层级这些结构特征不应该发生剧烈变化。
Step 1:定义结构指纹
先明确要抽取哪些特征。我选了六类:
| 特征 | 说明 | 为什么重要 |
|---|---|---|
| 页数 | 总页数 | 大幅变化说明重排失败 |
| 表格数 | 检测到的表格总数 | 表格丢失是最高频问题 |
| 表格形状 | 每个表格的 (行数, 列数) | 能发现表格被拆行/拆列 |
| 段落块数 | 文本块数量 | 反映版面切分是否一致 |
| 图片数 | 页面图像对象数 | 图片丢失的直接证据 |
| 文本行密度 | 每页平均文本行数 | 反映双栏→单栏之类的结构变化 |
Step 2:抽取结构指纹的代码
python
import pdfplumber
def extract_fingerprint(pdf_path: str) -> dict:
"""抽取 PDF 的结构指纹,用于翻译前后比对
Args:
pdf_path: PDF 文件路径
Returns:
包含页数、表格、段落、图片等结构特征的字典
"""
fp = {
"pages": 0,
"tables": [], # 每个表格的 (行数, 列数)
"table_count": 0,
"blocks_per_page": [], # 每页的文本块数量
"image_count": 0,
"lines_per_page": [], # 每页的文本行数
}
with pdfplumber.open(pdf_path) as pdf:
fp["pages"] = len(pdf.pages)
for page in pdf.pages:
# 1) 表格:记录形状而不是内容,避免语言差异干扰
for table in page.find_tables():
rows = table.extract()
cols = max((len(r) for r in rows), default=0)
fp["tables"].append((len(rows), cols))
# 2) 图片对象数量
fp["image_count"] += len(page.images)
# 3) 文本行数(按行坐标聚类,兼容双栏)
lines = page.extract_text_lines() or []
fp["lines_per_page"].append(len(lines))
# 4) 段落块:以垂直间距为切分依据
fp["blocks_per_page"].append(count_blocks(page))
fp["table_count"] = len(fp["tables"])
return fp
def count_blocks(page, gap_ratio: float = 1.8) -> int:
"""按行间距切分段落块
Args:
page: pdfplumber 的 page 对象
gap_ratio: 行间距超过中位行高的多少倍时视为段落边界
Returns:
段落块数量
"""
lines = page.extract_text_lines() or []
if len(lines) < 2:
return len(lines)
heights = sorted(l["bottom"] - l["top"] for l in lines)
median_h = heights[len(heights) // 2] or 1.0
threshold = median_h * gap_ratio
blocks = 1
for prev, cur in zip(lines, lines[1:]):
if cur["top"] - prev["bottom"] > threshold:
blocks += 1
return blocks
关键设计点是只比对结构、不比对内容。因为译文和原文的语言不同,文字必然不一样,但表格的行列数、页数、图片数量这些"结构量"在版式保留良好的情况下应当基本一致。
Step 3:差异比对
有了两个指纹,就可以做逐项比对了。注意要设置容差------因为翻译后文字长度会变化,段落块数和行数会有小幅波动,完全相等是不现实的。
python
def compare_fingerprint(src: dict, dst: dict, tol: float = 0.15) -> dict:
"""比对原文与译文的结构指纹
Args:
src: 原文指纹
dst: 译文指纹
tol: 允许的相对偏差(默认 15%)
Returns:
校验报告字典,含各项差异与总体结论
"""
report = {"issues": [], "warnings": [], "ok": True}
# 1) 页数必须一致(版式保留的硬指标)
if src["pages"] != dst["pages"]:
report["issues"].append(
f"页数不一致:原文 {src['pages']} 页,译文 {dst['pages']} 页"
)
# 2) 表格数量必须一致
if src["table_count"] != dst["table_count"]:
report["issues"].append(
f"表格数量不一致:原文 {src['table_count']} 个,译文 {dst['table_count']} 个"
)
else:
# 数量一致时,逐个比对表格形状
for i, (s, d) in enumerate(zip(src["tables"], dst["tables"]), 1):
if s != d:
report["warnings"].append(
f"第 {i} 个表格形状变化:原文 {s[0]}行×{s[1]}列 → 译文 {d[0]}行×{d[1]}列"
)
# 3) 图片数量必须一致
if src["image_count"] != dst["image_count"]:
report["issues"].append(
f"图片数量不一致:原文 {src['image_count']} 个,译文 {dst['image_count']} 个"
)
# 4) 段落块数允许容差波动
src_blocks, dst_blocks = sum(src["blocks_per_page"]), sum(dst["blocks_per_page"])
if src_blocks and abs(dst_blocks - src_blocks) / src_blocks > tol:
report["warnings"].append(
f"段落块数偏差过大:原文 {src_blocks},译文 {dst_blocks}"
f"(偏差 {abs(dst_blocks - src_blocks) / src_blocks:.1%})"
)
# 5) 逐页行数密度:定位到具体哪几页异常
for i, (s, d) in enumerate(zip(src["lines_per_page"], dst["lines_per_page"]), 1):
if s and abs(d - s) / s > 0.4:
report["warnings"].append(
f"第 {i} 页文本行数异常:原文 {s} 行,译文 {d} 行"
)
report["ok"] = len(report["issues"]) == 0
return report
Step 4:批量校验与报告输出
把上面两步串起来,批量跑一个目录:
python
import os
import json
from pathlib import Path
def verify_directory(pairs: list, out_json: str = "verify_report.json") -> None:
"""批量校验 (原文, 译文) 配对,输出 JSON 报告
Args:
pairs: [(原文路径, 译文路径), ...]
out_json: 报告输出路径
"""
results = []
for src_path, dst_path in pairs:
try:
src_fp = extract_fingerprint(src_path)
dst_fp = extract_fingerprint(dst_path)
report = compare_fingerprint(src_fp, dst_fp)
report["source"] = os.path.basename(src_path)
report["target"] = os.path.basename(dst_path)
results.append(report)
flag = "PASS" if report["ok"] else "FAIL"
print(f"[{flag}] {report['source']} → {report['target']}")
for issue in report["issues"]:
print(f" ✗ {issue}")
for warn in report["warnings"]:
print(f" ! {warn}")
except Exception as e:
results.append({
"source": src_path, "target": dst_path,
"ok": False, "issues": [f"校验异常: {e}"], "warnings": [],
})
summary = {
"total": len(results),
"passed": sum(1 for r in results if r["ok"]),
"failed": sum(1 for r in results if not r["ok"]),
"details": results,
}
Path(out_json).write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"\n汇总:{summary['passed']}/{summary['total']} 通过,报告已写入 {out_json}")
Step 5:运行效果
对一个包含 12 份文档的批次跑一遍,输出类似:
[PASS] report_01_EN.pdf → report_01_CN.pdf
[FAIL] manual_03_EN.pdf → manual_03_CN.pdf
✗ 表格数量不一致:原文 7 个,译文 5 个
! 第 14 页文本行数异常:原文 42 行,译文 68 行
[PASS] contract_05_EN.pdf → contract_05_CN.pdf
! 段落块数偏差过大:原文 210,译文 251(偏差 19.5%)
...
汇总:9/12 通过,报告已写入 verify_report.json
拿到报告后,优先看 issues(硬失败) ------页数、表格数、图片数不一致,基本可以判定版式丢失,需要重新翻译;warnings 多为语言长度差异导致的正常波动,抽样人工确认即可。
实测中这套脚本最常抓到三类问题:
- 表格被拆成段落(表格数减少)------多发生在跨页表格上;
- 双栏被合并成单栏(页数减少、行密度上升)------版面分析失败;
- 图片丢失(图片数减少)------多为图片是矢量绘制而非位图嵌入。
结论与建议
- 验收要自动化:靠人眼抽查 300 页文档不现实,用结构指纹比对可以在几秒内定位问题页;
- 比对结构而非内容:语言不同决定了内容必然不同,只有结构特征具备可比性;
- 硬指标零容差,软指标设容差:页数/表格数/图片数要求严格相等,段落块数和行数允许 15%~40% 波动;
- 保留版式的方案能显著降低校验成本 :如果一开始就用整本文档翻译(而不是抽取文本再重排),结构指纹的匹配度会高很多,报告里的
issues通常只有个位数。
这套校验脚本建议和翻译流程绑在一起跑:翻译 → 自动校验 → 只对 FAIL 的文档人工介入,能把批量文档处理的验收时间压缩到原来的十分之一。
参考资料
- pdfplumber 文档:https://github.com/jsvine/pdfplumber
- pypdf 文档:https://pypdf.readthedocs.io/
- PDFTranslator:https://pdftranslator.org
标签:PDF翻译、Python、自动化测试、文档处理