拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python实现PDF翻译:版式还原与文本回填实战

简介采用Python开发的PDF自动翻译工具面向需要批量处理多语言PDF文档的办公人员和开发者可解决产品手册、合同协议、学术文献等场景下的快速翻译需求脚本逻辑清晰对Python基础要求不高。压缩包采用RAR格式整体仅3KB共包含1个py文件——translate.py该脚本集中实现了读取PDF、提取文本、调用在线翻译接口以及通过docx库写入Word文档的完整流程结构紧凑、便于阅读和二次修改。目前已有517人学习下载获得了一定范围的关注。通过该脚本读者可以掌握PyPDF2/PDFMiner等库解析PDF的常用方法了解如何将外部翻译服务封装进自动化流程同时获得一个可直接运行的翻译脚本无需复杂配置即可处理待翻译文件还可基于其中的分段处理和结果写入逻辑继续扩展批量翻译、版式恢复、多引擎切换等功能为后续二次开发提供了清晰起点。1. 为什么你需要一个自己的 pdfTranslatePDF 翻译不只是调一次翻译 API拿到一份英文 PDF想把它翻译成中文整套流程里最费时间的不是“翻译”本身而是“版式还原”。PDF 在内部记录的是每个文本块在页面上的绝对定位并没有保留“段落”和“句子”的完整逻辑流。pdfTranslate 这类 Python 工具要做的就是把 PDF 的版式和文本抽取出来拆句送进机器翻译引擎再把译文以不破坏原排版的方式回填回去生成中英对照或中文替换版本。不少在线 PDF 翻译服务底层也是这套逻辑自己用 Python 实现的好处是可控API 自由换、术语可以固定、批量处理不限次数。这篇文章面向需要处理英文学术文献、产品手册、合同等非扫描版 PDF 的工程师和文档管理人员读完可以自己搭一条能跑的流水线并知道每一步的坑在哪里。2. 抽取阶段用 pdfplumber 与 PyMuPDF 把 PDF 变成带坐标的文本块2.1 PDF 不是“按段落存文本”的pdfTranslate 抽取阶段要拿到的三样东西PDF 的 content stream 里只有绘图指令在某个 (x, y) 坐标用某个字体绘制一串字符。阅读器能把它们显示成整齐的段落是因为每个字符都携带坐标信息。所以对 pdfTranslate 来说抽取阶段至少要拿到三样东西文本内容、包围盒 bbox、以及阅读顺序。缺了坐标翻译完的译文不知道该放回哪里缺了阅读顺序分栏 PDF 的文本会串行成“栏 1 第一行 栏 2 第一行”这种错乱结果。常见的 Python 抽取库有三个pdfplumber、PyMuPDFfitz、pdfminer.six。pdfplumber 解析出的words、chars、lines都带x0, y0, x1, y1调试方便但速度慢几百页的文件明显吃力PyMuPDF 直接返回 blocks速度快很多但文本合并策略比较粗糙pdfminer.six 最底层灵活性最高也意味着你自己要写的逻辑最多。我习惯的方案是抽取用 pdfplumber回填用 PyMuPDF两个库共用页面宽高坐标体系衔接比较顺。库速度坐标精度表格识别单词级提取适合场景pdfplumber慢高内置 extract_tablewords、chars页数少、需要精细排版PyMuPDF快高无blocks、spans页数多、快速回填原型pdfminer.six中高无自定义特殊字体、复杂结构你要是在乎构建速度可以只装 PyMuPDF 一路走到底但如果你要做表格翻译、或者要精细控制“哪一行算一句话”pdfplumber 的extract_words更合适。2.2 用 pdfplumber 读取文本和坐标的最小命令下面是读取某一页全部单词及包围盒的最小代码它是后续所有处理的基础import pdfplumber from collections import defaultdict def extract_words_with_bbox(pdf_path, page_idx): with pdfplumber.open(pdf_path) as pdf: page pdf.pages[page_idx] words page.extract_words( x_tolerance1.5, # 水平间距小于 1.5pt 的字符拼成一个词 y_tolerance3.0, # 垂直方向容差用于对齐同一行 keep_blank_charsFalse, use_text_flowFalse, # 关闭文本流保留原始坐标关系 ) return words # words 每项类似 # {text: Figure, x0: 54.0, top: 720.1, x1: 73.2, bottom: 733.5}x_tolerance决定字符水平间距小于多少被视为同一个单词。默认值对于某些 PDF 会把foo bar连在一起我一般设在1.0~2.0。y_tolerance影响行聚合不同 PDF 生成工具会有 0.5pt 级别的偏差设成3.0能容忍轻微错位。use_text_flow一定不要开开了 pdfplumber 会用内部逻辑重排行序反而丢掉原始坐标后面回填定位就会偏。2.3 按行合并段落分栏、页眉页脚与阅读顺序拿到单词后不能直接拼接。PDF 可能是双栏甚至三栏排版而且 content stream 里的对象顺序不一定是视觉顺序有的生成器先画页眉再画正文最后画页脚。我按“先分桶、再组行、后合段”的步骤处理def group_lines(words, page_height, line_height_tolerance5.0): raw_lines defaultdict(list) for w in words: # 过滤页眉页脚页面上方 50pt 和下方 50pt 一般不是正文 if w[top] 50 or w[bottom] page_height - 50: continue key round(w[top] / line_height_tolerance) raw_lines[key].append(w) lines [] for k in sorted(raw_lines.keys()): # top 小的在前 line_words sorted(raw_lines[k], keylambda w: w[x0]) text .join(w[text] for w in line_words) lines.append({ text: text, x0: min(w[x0] for w in line_words), x1: max(w[x1] for w in line_words), top: min(w[top] for w in line_words), bottom: max(w[bottom] for w in line_words), }) return linesline_height_tolerance按行高度做桶分类5pt对 10pt 字号是安全值。分栏问题要额外处理同一top带内x0相差特别大的两个单词组属于不同栏。常见做法是先按x0聚成两簇再逐簇按top排序就能得到“左上栏读完读右上栏”的阅读顺序。页眉页脚过滤阈值视版面而定学术论文的页眉页脚高度一般不超过 60pt保守点用 50pt。2.4 极端字体什么时候退回 pdfminer.sixpdfplumber 对绝大多数 PDF 够用但遇到内嵌自定义字体CID 映射不标准时抽出来的可能是私用区码点转成中文或英文都是乱码。这种 PDF 在金融对账单和打印软件的导出文件里很常见。处理思路是退回 pdfminer.six它暴露了 PDF 内部的 cmap 解析对象可以通过LTParsedText拿到字符级字体映射。这一步的调试成本高建议先看页面里是否有fitz.Document能正常检索原文能检索就说明字体映射没问题问题出在抽取工具。3. 翻译引擎接入与限流给 pdfTranslate 封装可热切换的 API3.1 翻译封装成独立模块主流程只认接口不认厂商翻译 API 的频控、重试、错误码处理是 PDF 翻译管线里最容易出问题的环节。一份 300 页的 PDF 拆成几千句任何一次请求失败导致的退出都会浪费前面的时间。我会把翻译引擎做成独立的Translator类主流程只依赖它的translate(texts: list[str]) - list[str]接口用配置文件切换百度翻译、DeepL 或腾讯翻译君。这样抽取和回填两个阶段完全不关心翻译服务商是谁以后换引擎只需要改一个类。3.2 用百度翻译 API 的最小封装签名、请求与参数说明百度翻译是目前最容易申请、国内网络环境可直接访问的翻译接口之一通用文本翻译的调用方式如下import hashlib import random import requests def baidu_translate(text, appid, secret_key, from_langen, to_langzh): salt str(random.randint(10000, 99999)) sign hashlib.md5((appid text salt secret_key).encode()).hexdigest() resp requests.post( https://fanyi-api.baidu.com/api/trans/vip/translate, data{ q: text, from: from_lang, to: to_lang, appid: appid, salt: salt, sign: sign, }, timeout10, ) data resp.json() if trans_result not in data: raise RuntimeError(ftranslate error: {data}) return .join(item[dst] for item in data[trans_result])参数说明salt是一个随机串sign是appid q salt 密钥按顺序拼接后取 MD5注意拼接时不要加空格或换行timeout必须设否则某个慢请求会卡住整条线程响应里没有trans_result时把原始data打出来看error_code常见的几个54001 签名错误54003 访问频率限制58003 单次翻译长度超限。3.3 并发调用要限流信号量、指数退避和本地缓存免费版百度翻译的 QPS 限制是 1付费版一般在 5 到 10。翻译大 PDF 时串行太慢我会用线程池加信号量控制并发并加上自动重试和缓存import sqlite3 import threading import time import random class CachedTranslator: def __init__(self, engine, max_workers2): self.engine engine # baidu_translate 等函数 self.sem threading.Semaphore(max_workers) self.conn sqlite3.connect(translate_cache.db) self.conn.execute( CREATE TABLE IF NOT EXISTS cache (src TEXT PRIMARY KEY, dst TEXT)) def translate(self, text): row self.conn.execute( SELECT dst FROM cache WHERE src?, (text,)).fetchone() if row: return row[0] for attempt in range(4): with self.sem: try: dst self.engine(text) self.conn.execute( INSERT OR REPLACE INTO cache VALUES (?,?), (text, dst)) self.conn.commit() return dst except Exception as e: if attempt 3: raise time.sleep(2 ** attempt * (0.5 random.random()))信号量把同时发出请求的线程数限制在 2 个。attempt循环实现指数退避第一次失败等约 1 秒第二次 2 秒第三次 4 秒重试 4 次后放弃。SQLite 缓存的作用不只是提速——翻译到一半程序崩溃重启后已翻译的句子直接读缓存不消耗 API 配额。这个缓存表按整句存储建议再加上normalized_text字段做空白归一化避免同样的句子因换行不同导致缓存失效。注意百度翻译单次q参数长度不能超过 6000 字节按英文算大约 1500 词。抽取出的段落如果过长需要按句子边界或长度阈值切分后再调用切分位置要保持在句号后面。3.4 按段落而不是按句子送翻译保留上下文机器翻译引擎对单词的翻译受上下文影响很大输入越长通常越准确但受长度限制又不能整段丢进去。我给 pdfTranslate 定的策略是先按第 2 节的group_lines结果组段段内按句子边界切分累积到 3000 字节左右再合并为一次请求。这样既保证上下文又留出余量import re SENT_BOUNDARY re.compile(r(?[.!?])\s) def chunk_paragraph(paragraph, max_bytes3000): sents SENT_BOUNDARY.split(paragraph) chunks, cur [], for s in sents: if len((cur s).encode(utf-8)) max_bytes and cur: chunks.append(cur) cur s else: cur (cur s).strip() if cur: chunks.append(cur) return chunks正则里的(?[.!?])是后行断言只在句号、问号、感叹号后且后跟空白的位置切分。引用参考文献里的et al.会被误切成两段这类问题要交给第 5 章的术语表去解决不要在切分逻辑里硬扛。4. 译文回填用 PyMuPDF 把译文插到原文的正下方4.1 回填的两种主流方案原文下方插入 vs 独立对照页翻译完之后的排版有两种常见路线。独立对照页是把原文页面复制一份译文全部放在新页面上实现简单但阅读时要反复翻页适合排版要求严格、不允许打扰原文样式的正式交付物。我平时用得最多的是原位回填在每个文本块下方插入译文字号比原文小一档颜色用可区分的深蓝色。这样打印出来就是中英对照材料特别适合文献批注场景。代价是一个页面能容纳的文本量会减少原文下方没有足够空间时要往下一页延展。4.2 用 PyMuPDF 的 insert_textbox 在原文块下方插入译文PyMuPDF 的insert_textbox允许指定一个矩形区域自动处理文本换行。下面是核心回填函数import fitz # PyMuPDF def estimate_lines(text, fontsize9, rect_width300): # 按中文平均字宽约等于字号估算 chars_per_line max(1, int(rect_width / fontsize)) return max(1, (len(text) chars_per_line - 1) // chars_per_line) def fill_translation(page, block_rect, translated_text, fontsize9): rect fitz.Rect(block_rect) rect.y0 rect.y1 2 # 译文从原文底部下方开始 est_lines estimate_lines(translated_text, fontsize, rect.width) rect.y1 rect.y0 14 * est_lines 8 rc -1 while rc 0 and fontsize 6: rc page.insert_textbox( rect, translated_text, fontsizefontsize, fontnamechina-s, # PyMuPDF 内置简体中文字体别名 color(0.1, 0.2, 0.6), align0, # 左对齐 lineheight1.6, ) if rc 0: fontsize - 0.5 rect.y1 8 # 同时把区域加高insert_textbox返回矩形剩余的高度正值表示放得下负值表示溢出。上面的 while 循环在溢出时缩小字号并加高矩形。fontnamechina-s是 PyMuPDF 内置的中文字体替换名它会自动从系统中找字体。如果你的运行环境是精简版 Linux没有中文字体这里会输出豆腐块需要安装fonts-noto-cjk。4.3 跨页超长文本把译文拆分到下一页一页原文下方空间可能不够放整段译文正确的处理是按句子切断把后半部分放到下一页开头def insert_continuation(doc, page_idx, remaining_text): page doc[page_idx] if page_idx 1 len(doc): new_page doc.new_page(widthpage.rect.width, heightpage.rect.height) else: new_page doc[page_idx 1] rect fitz.Rect(40, 40, page.rect.width - 40, 80) new_page.insert_textbox( rect, remaining_text, fontsize9, fontnamechina-s, color(0.1, 0.2, 0.6))问题在于怎么确定断点。我的做法是二分把译文按句子切成段逐段用fitz.Font测量文本宽度和换算高度找到第一个超过矩形边界的句子作为断点前面的句子放本页剩余文本递归插入下一页。效率不高但 PDF 翻译通常允许一分钟级延迟正确性优先。4.4 回填阶段的坐标对齐细节pdfplumber 的坐标和 PyMuPDF 的坐标并不完全一致。pdfplumber 的top是从页面顶端向下PyMuPDF 的y0也是从上向下但页面如果有旋转属性rotation90坐标轴会翻转回填位置会旋转 90 度。带旋转的页面要先执行page.set_rotation(0)归一化。另外pdfplumber 取的是word级 bbox而 PyMuPDF 的page.blocks默认合并多个行直接拿words的 bbox 去回填会更精准但文本块之间要留 1 到 2 pt 的垂直间距避免译文紧贴原文不好阅读。注意同类工具最容易翻的车是“译文覆盖原文”。pdfTranslate 只在你明确放弃原文、做全中文替换时才允许把译文直接画在原文 bbox 内中英对照模式下译文区域一定要从原文本底边往下偏移至少 2pt。5. 进阶术语表前置替换与整页对比验证5.1 术语统一用占位符保护专有名词机器翻译对专有名词最不稳定同一份 PDF 里ModelNet40、PointNet第一次翻译对了后文可能就变了。pdfTranslate 的做法是翻译前做词典替换把这些词替换成机器翻译不会触碰的占位符翻译完成后再替换回来TERMS { PointNet: §PN§, ModelNet40: §MN40§, Hydra: §HDR§, } def protect_terms(text): for k, v in TERMS.items(): text text.replace(k, v) return text def restore_terms(text): for k, v in TERMS.items(): text text.replace(v, k) return text占位符设计成“符号 大写字母”这样翻译引擎一般会原样保留。参考文献里的et al.如果不想翻译也可以放进保护词典。5.2 表格型 PDF 的翻译验证文本溢出与 6pt 底线PDF 里的表格经常不是真实表格而是用线条加绝对坐标画出来的。抽取时用 pdfplumber 的page.find_tables()识别表格区块把单元格文本合并后翻译回填时按每个单元格的中心点定位。验证是否溢出有个简单办法检查页面渲染后文本索引中是否出现乱码替换符以及insert_textbox的返回值是否为负def verify_page_text_ok(new_pdf_path, page_idx): doc fitz.open(new_pdf_path) page doc[page_idx] text page.get_text(text) return \ufffd not in text and len(text.strip()) 0表格行高固定译文太长塞不下时我的底线是字号不低于 6pt。低于 6pt 就不要压字了改成把表格所在行拆成两行或者把译文写成缩写形式。表格翻译里最常见的现象是译文区块互相重叠定位时应该用cell.bbox而不是cell.bbox取左边缘两个单元格共边时都要偏移 1pt。5.3 整页渲染对比看三处重点最后的验证一定不能只看 API 返回成功。把原 PDF 和回填后的 PDF 按同一页码渲染成 PNG左右对比是最快的检查方式def render_page_side_by_side(orig_path, new_path, page_idx, dpi150): doc fitz.open(orig_path) pix doc[page_idx].get_pixmap(dpidpi) pix.save(orig_p{}.png.format(page_idx)) doc fitz.open(new_path) pix doc[page_idx].get_pixmap(dpidpi) pix.save(new_p{}.png.format(page_idx))对比时重点看三处译文是否覆盖原文、译文是否超出页面右边距、中文字体是否正常渲染。页面右边距超出很容易被忽略insert_textbox的矩形宽度设置过大会让文本画到页面外渲染图里那一行文字会被裁掉一半。做完这一轮抽检pdfTranslate 处理的 200 页文档才敢说基本不用人工逐页校对。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门