前言
做 PDF 翻译流水线的人迟早会碰到同一个问题:译文回填到原文位置后,文字伸出了文本框。
这个问题的根源不是翻译质量,而是文本体积。同一段内容用不同语言写出来,占的横向空间完全不一样。原文是排满的版式,译文一长,就会溢出。
这篇文章用真实字体度量 + 真实 PDF 版面做一次完整实测,回答三个问题:
- 同一句话在不同语言下宽度差多少?
- 排版后的 PDF 行里还剩多少余量?
- 溢出发生后,四种常见策略的真实代价分别是多少?
所有数据来自本机真机实测,代码可复现。
环境准备
- Python 3.13
- PyMuPDF 1.28.2(负责字体度量、版面测量、文本回填)
- reportlab 5.0.1(负责造测试文档)
- 中文字体:
C:\Windows\Fonts\simhei.ttf
pip install pymupdf reportlab
一、先量清楚:同一句话有多宽
第一步不能靠经验估,要用字体度量算。PyMuPDF 的 Font.text_length() 能给出一段文字在指定字号下的真实渲染宽度。
import fitz
f_latin = fitz.Font("helv") # 拉丁字体
f_cjk = fitz.Font(fontfile=r"C:\Windows\Fonts\simhei.ttf") # 中文字体
def width(text, font, size=10.0):
"""返回 text 在 size 字号下的渲染宽度(磅)"""
return font.text_length(text, fontsize=size)
我准备了 20 句覆盖技术说明书、商务合同、学术报告三类文档的平行语料(中/英/德/西),逐句测量同一内容在 10 磅字号下的渲染宽度,再以英文为基准求比值。
实测结果:
| 语言方向 | 平均宽度比 | 最小 | 最大 |
|---|---|---|---|
| 英 → 中 | 0.5701 | 0.4262 | 0.7374 |
| 英 → 德 | 1.0965 | 0.8165 | 1.3491 |
| 英 → 西 | 1.0704 | 0.8514 | 1.2682 |
| 中 → 英 | 1.7893 | 1.3561 | 2.3461 |
这张表里有三个值得记住的结论。
**第一,中英互译的两个方向严重不对称。**英文转中文平均只占原来的 57%,反过来中文转英文平均要变成 1.79 倍,最长的句子到了 2.35 倍。也就是说,中译英是排版事故的高发方向。
**第二,德语比英语长,但不是最常见的"长 30%"。**实测均值 9.65%,最大 34.9%。有些句子反而更短(最小 0.8165)。
**第三,单位字符的宽度差得很夸张。**实测单个汉字宽度 10.0 磅,日文汉字 10.0 磅,韩文 10.0 磅,而拉丁字母平均宽度只有 4.895 磅。
一个汉字占的横向空间 ≈ 2.04 个英文字母。
这就是所有"译文撑爆版面"问题的物理来源。
二、真实文档的行里还剩多少余量
知道了译文的体积,还要知道原文留了多少空间。做法是:取出每一行文本的实际渲染宽度,和它所在分栏的宽度比一下。
import fitz
def measure_doc(pdf_path, pages=10):
"""统计真实 PDF 的行宽与分栏宽度"""
doc = fitz.open(pdf_path)
lines, blocks = [], []
for pno in range(min(len(doc), pages)):
for blk in doc[pno].get_text("dict")["blocks"]:
if blk.get("type") != 0:
continue
bl = [ln for ln in blk.get("lines", []) if ln.get("spans")]
bl = [ln for ln in bl if "".join(s["text"] for s in ln["spans"]).strip()]
if not bl:
continue
# 按原文实际字号算宽度,而不是统一字号
widths = [sum(f_latin.text_length(s["text"], fontsize=s["size"])
for s in ln["spans"]) for ln in bl]
col_w = max(widths) # 块内最长行 ≈ 该分栏的可用宽度
blocks.append({"lines": len(bl), "total_w": sum(widths), "col_w": col_w})
lines += [(w, col_w) for w in widths]
doc.close()
return lines, blocks
在两份真实 PDF 上跑(各取前 10 页):
| 文档 | 行数 | 平均分栏宽度 | 行平均填充率 |
|---|---|---|---|
| 报告 A | 310 | 288.1 pt | 96.68% |
| 报告 B | 410 | 231.0 pt | 96.67% |
行平均填充率 96.7% 是这份实测里最重要的一个数字。它说明排版引擎本来就会把每一行排到接近满格——原文的行几乎没有余量。
推论很直接:任何超过 3% 的宽度膨胀,都会让一部分行放不下。
按前面测出的宽度比代入,得到行级溢出率:
| 目标语言 | 报告 A 溢出率 | 报告 B 溢出率 |
|---|---|---|
| 中文 | 0.00% | 0.00% |
| 德语 | 74.19% | 32.68% |
| 西班牙语 | 74.19% | 32.68% |
两个文档的差异来自分栏宽度是否准确。报告 B 的块平均有 13.7 行,"块内最长行"能代表真实分栏宽度;报告 A 的块平均只有 1.35 行,此时"块内最长行"退化成了单行自身的宽度,会把溢出率算得偏高。
做这类测量时,一定要先确认块里有多少行,否则分栏宽度会失真。
三、端到端验证:造一份文档,真实回填
行级统计是静态推算,还需要一次端到端验证:造一份真实排版的文档,把译文真的填回去,看能不能放下。
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfgen import canvas
COL_W, F_SIZE, LEAD = 240.0, 10.0, 14.0
def make_src(path, paragraphs):
"""造一份固定列宽 240pt、10pt/14pt 行距的英文文档"""
c = canvas.Canvas(path, pagesize=A4)
W, H = A4
c.setFont("Helvetica", F_SIZE)
x, y = 56.0, H - 90.0
for en in paragraphs:
words, line, out = en.split(), "", []
for wd in words:
cand = (line + " " + wd).strip()
if pdfmetrics.stringWidth(cand, "Helvetica", F_SIZE) <= COL_W:
line = cand
else:
out.append(line); line = wd
if line:
out.append(line)
for ln in out:
c.drawString(x, y, ln)
y -= LEAD
y -= LEAD * 1.2 # 段后间距,保证每段能被切成独立块
c.save()
return path
文档造好之后,测量译文需要几行,靠的是"渲染后数行数"而不是估算:
def needed_lines(text, width, size, cjk=False):
"""把文本排进给定列宽,数出渲染后的真实行数"""
d = fitz.open()
p = d.new_page(width=width + 10, height=4200)
p.insert_textbox(fitz.Rect(0, 0, width, 4190), text, fontsize=size,
fontname="china-s" if cjk else "helv", align=0)
n = 0
for blk in p.get_text("dict")["blocks"]:
if blk.get("type") != 0:
continue
n += len([l for l in blk.get("lines", []) if l.get("spans")])
d.close()
return max(1, n)
这里有个坑:不要用 insert_textbox() 的返回值除以单行高度来推算行数。 它的返回值是剩余可用高度,不是精确的行高倍数,跨字体(尤其 CJK)算出来的行数会偏小。直接 get_text("dict") 数 lines 才是准的。
10 段英文(每段约 3–4 行),分别换成德语和中文,回填结果:
| 指标 | 德语 | 西班牙语 | 中文 |
|---|---|---|---|
| 文本宽度比 | 1.0933 | 1.0676 | 0.5741 |
| 原文总行数 | 37 | 37 | 37 |
| 译文所需行数 | 41 | 40 | 21 |
| 段落高度比(均值) | 1.125 | 1.0917 | 0.575 |
| 段落高度比(最大) | 1.3333 | 1.3333 | 0.75 |
宽度只膨胀 9.3%,段落高度就涨了 12.5%,最高的段落涨了三分之一。 中文方向反而缩了 42%。
四、四种自适应策略的代价对比
有了真实回填能力,就可以对比四种策略了。判定方式统一为"译文所需行数 vs 原文行数"。
策略一:保持原字号、原行距,盒高固定。
最朴素的做法,也是事故来源。实测:德语 10 段里有 4 段溢出(40%),西班牙语 3 段(30%),中文 0 段。
策略二:自动缩小字号直到放得下。
实测平均字号比 0.97,最小降到 8.75 磅(德语)/ 8.25 磅(西班牙语)。听起来代价不大,但注意这是"为了塞进原高度"的下限——真实文档里字号更小、行更密时,会掉到 7 磅以下,可读性会崩。
策略三:保持字号,压缩行距。
实测平均行距比 0.905,最小行距降到 10.5 磅(原 14 磅)。德语有 3 段、西语有 2 段的行距跌破了 11 磅。行距压到这个程度,阅读体验会明显变差,密集段落尤其明显。
策略四:保持字号与行距,允许段落变高。
实测每段平均多占 5.6 磅(德语)/ 4.2 磅(西语),10 段累计多占 56 磅 / 42 磅。这个方案保持了可读性,代价是整页内容往下推,跨页位置改变,目录页码可能失效。
四个策略汇总:
| 策略 | 可读性 | 版面稳定性 | 适用场景 |
|---|---|---|---|
| 原样回填 | 最高 | 最差(40% 溢出) | 中文方向;或原文有大片留白 |
| 缩字号 | 明显下降 | 好 | 文本框固定、且只差一两行 |
| 压行距 | 中等下降 | 好 | 段落短、行距原本宽松 |
| 扩盒高 | 不变 | 差(整体下移) | 译后重排、不要求页码一致 |
五、结论与选型判据
实测下来,我给三条判据:
**判据一:先看语言方向。**中译英是高风险方向(1.79 倍),英译中基本不会溢出(0.57 倍)。同样的文档,中译英要考虑溢出,英译中要考虑的是大片空白带来的版面空洞。
**判据二:先算原文的行填充率。**如果文档每行都排到 96% 以上,就不要指望"原样回填"能不出问题;如果有明显留白,按原样回填是最省事的。
**判据三:策略按"增量"选。**译文比原文多的行数在 1 行以内,缩字号或压行距更划算;超过 2 行,扩盒高更合适——因为缩字号会让你为了最后一行把全篇字号都降低。
如果是自动化流水线,建议这么实现:先算 needed_lines / src_lines 的比值,小于 1.05 用缩字号,1.05–1.25 用扩盒高,大于 1.25 直接判定"该重排",不要硬塞。
总结
这次实测最反直觉的一点是:排版好的文档,本来就没有余量。
行填充率 96.7% 意味着任何膨胀都会溢出,而德语相对英语只长 9.7%,就能让三分之一的行出问题。所以"格式保留"这件事的本质,不是把文字贴回原位,而是做一次完整的重排——重新计算每一行的容量,再决定这一页要不要调整。
对于不想自己写这套重排逻辑的场景,用在线工具处理整份文档是一个省事的选择,比如 PDFTranslator 这类支持保留原始排版的服务,把上传、翻译、重排三件事一起做掉,免去自己算行宽和盒高。
标签:PDF翻译、Python、PyMuPDF、排版、自动化
292



被折叠的 条评论
为什么被折叠?



