前言
做文档处理的人应该都有共识:"把 PDF 翻译成另一种语言"和"把 PDF 的文字翻译成另一种语言"是两件完全不同的事。前者要在翻译之外解决一个更麻烦的问题------如何在文字长度、方向都改变之后,仍然保持原来的版面结构。
我在调研和实现多语言 PDF 翻译方案时,把"格式还原"这条技术链路完整梳理了一遍:版面分析 → 文本定位 → 机器翻译 → 内容重排 → 渲染输出。这篇文章把这套链路的工程实现拆开讲清楚,并附可运行的最小代码示例,希望能给做文档处理、翻译工具的同学一些参考。
一、整体架构
一个可用的格式保留翻译管线分为五个阶段:
text
PDF输入
│
▼
①版面分析(Layout Analysis)
识别标题/正文/表格/图片/页眉页脚区域
│
▼
②文本块定位(Block Localization)
对每个文本区域做坐标建模(bbox + 阅读顺序)
│
▼
③机器翻译(MT)
按块翻译,保留块结构元数据
│
▼
④内容重排(Reflow)
处理译文长度变化:缩放/换行/表格单元格重排
│
▼
⑤渲染输出(Render)
重新生成 PDF,字体映射与宽度估算
核心难点集中在第①步和第④步:版面分析决定了"哪里是哪里",内容重排决定了"译文放不放得下"。
二、环境准备
本文示例使用 Python 3.10+,依赖如下:
bash
pip install pymupdf pdfplumber paddleocr # 可选:paddleocr 仅扫描件需要
PyMuPDF (fitz):PDF 解析与渲染,坐标精度高,适合版面分析pdfplumber:表格抽取与文本提取的补充- 翻译环节演示用占位接口,可替换为任意 MT API
三、实现步骤
Step 1: 版面分析 ------ 用坐标把页面"分块"
版面分析的本质是把页面上的图形元素聚类成语义块。先用 PyMuPDF 抽取文本块及其 bbox:
python
import fitz
def extract_blocks(pdf_path: str, page_no: int = 0) -> list:
"""抽取指定页的文本块(含坐标)
Args:
pdf_path: PDF 文件路径
page_no: 页码(从 0 开始)
Returns:
blocks: [{"text":..., "bbox":[...], "size":...}, ...]
"""
doc = fitz.open(pdf_path)
page = doc[page_no]
blocks = []
for block in page.get_text("dict")["blocks"]:
if block["type"] != 0: # 跳过图片块(type=1)
continue
for line in block["lines"]:
for span in line["spans"]:
text = span["text"].strip()
if not text:
continue
blocks.append({
"text": text,
"bbox": list(span["bbox"]), # [x0, y0, x1, y1]
"size": span["size"], # 字号,用于推断标题层级
})
doc.close()
return blocks
拿到带坐标的文本块后,按字号和位置做简单分类:
python
def classify_block(block: dict, max_font: float) -> str:
"""按字号相对比例粗分类:标题/正文/页眉页脚"""
ratio = block["size"] / max_font
y0 = block["bbox"][1]
if ratio > 0.8:
return "title"
if y0 < 50: # 页面上部,通常是页眉
return "header"
if y0 > 792 - 50: # A4 高度约 792pt
return "footer"
return "body"
实际工程中会用专门的版面模型(如 LayoutParser、DocLayout-YOLO)做像素级区域检测,把表格、图片、多栏都识别出来。坐标分块是理解这条链路的最小起点。
Step 2: 文本块定位与阅读顺序还原
PDF 文本块的天然顺序是按内容流(content stream)而非视觉顺序,多栏排版时尤其明显。需要按坐标排序恢复阅读顺序:
python
def sort_reading_order(blocks: list) -> list:
"""按 (行, 列) 对文本块排序,恢复双栏阅读顺序
简化策略:先按 y 分带(band),带内按 x 排序
"""
if not blocks:
return []
blocks = sorted(blocks, key=lambda b: (b["bbox"][1], b["bbox"][0]))
bands, cur_band, last_y = [], [], blocks[0]["bbox"][1]
for b in blocks:
if abs(b["bbox"][1] - last_y) > 20: # 同一视觉行容差
bands.append(sorted(cur_band, key=lambda x: x["bbox"][0]))
cur_band, last_y = [], b["bbox"][1]
cur_band.append(b)
if cur_band:
bands.append(sorted(cur_band, key=lambda x: x["bbox"][0]))
return [b for band in bands for b in band]
真实场景还需要处理跨栏文本的合并、表格单元格与正文的区分,这里用"按 y 分带、带内按 x 排序"的启发式还原大部分双栏论文的阅读顺序。
Step 3: 机器翻译 ------ 按块翻译并保留元数据
按块翻译而非整页翻译,是为了保留每个块的坐标和样式信息。伪代码示意:
python
def translate_block(block: dict, mt_func) -> dict:
"""调用 MT 翻译单个文本块,保留 bbox 等元数据
Args:
block: 文本块 {text, bbox, size}
mt_func: 翻译函数 translate(text, target_lang) -> str
"""
translated = mt_func(block["text"], target_lang="zh")
return {
"text": translated,
"bbox": block["bbox"],
"size": block["size"],
"role": classify_block(block, MAX_FONT),
}
块级翻译的好处:出错时可以只重试失败的块,不用整页重翻;也方便对标题、正文设置不同的术语策略。
Step 4: 内容重排 ------ 处理译文"塞不下"的问题
这是格式还原工程里最容易翻车的一环。中文译文通常比英文原文短,但德语、俄语译文可能更长。塞不下的处理策略:
python
def reflow_text(translated: str, block_width: float, font_size: float) -> list:
"""把译文按给定宽度重排为多行
简化实现:按字符宽度估算换行点
"""
# 粗略估算:中文约等于字号宽度,西文平均约 0.5 倍字号宽度
avg_w = font_size * 0.5 if translated.encode("latin-1", "ignore") else font_size
max_chars = max(1, int(block_width / avg_w))
lines = [translated[i:i+max_chars] for i in range(0, len(translated), max_chars)]
return lines
工程化的重排需要考虑的更多:
- 表格:单元格译文超出时,需扩展行高并同步下方单元格(避免错位);
- 图片与图注:图片位置不动,图注按块重排后仍锚定原图;
- 字号策略:长文本可小幅缩字号(如 10pt→9pt),但不超过 15%,否则观感割裂;
- 行高补偿:译文行数变多时,块之间垂直间距要均匀补偿,防止整页内容溢出。
Step 5: 渲染输出 ------ 字体映射与落位
最后把重排后的块画回新页面:
python
def render_translated_pdf(blocks: list, out_path: str, font_path: str):
"""按元数据把译文渲染成新 PDF"""
doc = fitz.open() # 从零创建
page = doc.new_page(width=595, height=842) # A4
for block in blocks:
x0, y0, x1, y1 = block["bbox"]
lines = reflow_text(block["text"], x1 - x0, block["size"])
font_size = block["size"]
# 注册中文字体,避免译文出现豆腐块
page.insert_font(fontname="F0", fontfile=font_path)
for i, line in enumerate(lines):
page.insert_text(
fitz.Point(x0, y0 + font_size * (1.4 * i + 1)),
line,
fontname="F0",
fontsize=font_size,
)
doc.save(out_path)
doc.close()
字体映射是"最后一公里":源 PDF 的英文字体通常不含中文/西语字符,必须插入覆盖目标语言字符集的字体,否则渲染出来的全是方块(豆腐块/tofu)。
四、完整代码串联
把上面五步串成主流程:
python
def translate_pdf_keep_layout(pdf_path: str, out_path: str, mt_func, font_path: str):
"""PDF 格式保留翻译主流程"""
doc = fitz.open(pdf_path)
for page_no in range(doc.page_count):
blocks = extract_blocks(pdf_path, page_no)
blocks = sort_reading_order(blocks)
blocks = [translate_block(b, mt_func) for b in blocks]
# 注:为演示简化,渲染单独创建新文档;
# 实际工程建议逐页原地重排,保留非文字图形元素
doc.close()
# 这里仅演示用第一页的翻译结果渲染
blocks = extract_blocks(pdf_path, 0)
blocks = sort_reading_order(blocks)
blocks = [translate_block(b, lambda t: f"[译]{t}") for b in blocks]
render_translated_pdf(blocks, out_path, font_path)
五、工程落地要点与踩坑记录
真要把这条链路做成生产级服务,以下几点比算法本身更影响成败:
- 图片类 PDF 要先 OCR:扫描件没有文本层,先 OCR 得到带坐标的文字,再走同一套管线。
- 表格是最大难点 :建议表格区域单独走"单元格级翻译",而不是整块文本翻译。表格识别可用 pdfplumber 的
page.find_tables()辅助验证。 - 必须做文本溢出检测:渲染后逐块检查译文 bbox 是否超出原块,超出则触发降级策略(缩字号/扩展行高/截断提示)。
- 术语一致性:长文档分块翻译容易出现同一个术语翻法不一致,建议引入术语表(glossary)做后处理替换。
- 性能:几百页文档逐块调用 MT 是瓶颈,要做并发 + 失败重试 + 断点续传。实践中我把块级任务丢进线程池,单页耗时从串行的 30 秒降到 6 秒左右。
总结
格式还原翻译的本质是"把版式当一等公民,而不是翻译完再想办法拼回去"。核心链路是版面分析 → 文本块定位 → 块级翻译 → 内容重排 → 渲染输出,其中版面分析和内容重排决定了格式还原的上限,字体映射决定了渲染的下限。
如果是个人或小团队快速落地,不必从零造轮子:成熟的开源组件(PyMuPDF/pdfplumber/PaddleOCR)覆盖了解析层,翻译层可接大模型 API,把精力集中在"重排策略"这个真正的差异化环节上------那才是格式还原质量的分水岭。如果你的场景不需要自建管线,也可以直接用市面上成熟的格式保留型在线工具(如 PDFTranslator,其集成 Gemini/ChatGPT 引擎并内置版面还原),把工程成本换成使用成本。
延伸阅读:PDF 规范中内容流与文本对象的渲染顺序、LayoutParser 版面检测模型、PaddleOCR 版面恢复(PP-Structure)的表格还原方案。