
写 PDF 翻译工具这事其实是被一篇 40 页的强化学习论文逼出来的。当时我对着屏幕逐段复制到网页翻译文本一进编辑器全是断行符还要手动拼回完整句子翻译完又得对着原 PDF 来回比对位置大半天只啃下五六页。明明只是“把 PDF 读出来、翻完、再放回去”这三件事做起来却异常难受。于是花了一周时间自己写了一个面向科研场景的 PDF 翻译工具从 PDF 解析、文本还原、翻译调度到译文 PDF 重排把整条链路完整打通了。今天就把这套开发思路、技术选型和踩过的坑一次性写清楚。这篇文章适合谁看一是天天要读英文 Paper 但不想被 PDF 格式折磨的研究生和工程师二是想自己动手做 PDF 解析、翻译集成或者文档还原类工具的程序员。我会重点讲清楚三个核心问题PDF 文本为什么难提取、双栏论文怎么还原阅读顺序、翻译之后如何把中文重新排回 PDF并且给出可以直接复用的代码片段和方案选型。1. 整体思路与设计拆解做这个工具之前我其实先列了一堆需求又推翻了好几版方案。最开始想的特别简单解析 PDF提取文本调翻译 API再生成 PDF。真动手才发现每一步都比想象中复杂。这个工具能真正用起来靠的不是“翻译准不准”而是“PDF 这个容器本身带来的问题处理得好不好”。1.1 核心痛点PDF 文本不是文本很多人误以为 PDF 里的文字可以直接“读”出来实际上 PDF 里的每个字符都是一堆绘图指令它记录的是“在这个位置、用这种字体、画这样一个字形”而不是一段有逻辑顺序的文本流。这也是为什么从 PDF 复制英文到文本编辑器时经常出现断行、乱序、连字被拆成两个字符之类的怪象。对翻译来说这种结构带来的麻烦是致命的因为翻译引擎需要的是完整、连续的句子而不是被坐标打散的字符碎片。所以这个工具的第一个设计原则就是不能只做“文本提取”要做“结构化文本重建”。也就是说不仅要把 PDF 里所有文字抠出来还要根据它们在页面上的坐标关系把属于同一段、同一句的文字重新拼接起来。这个步骤没做好后面的翻译质量就无从谈起。1.2 技术方案三段式架构整个工具我设计成了三个阶段对应 PDF 翻译的三类核心问题解析与重建从 PDF 中提取字符级数据坐标、字体、字号再按视觉位置聚合成行、段、块还原出符合人类阅读顺序的纯文本。翻译调度把重建好的文本切分成适合翻译引擎处理的最大单元控制并发和频率拿到高质量译文。还原与生成把翻译结果按原 PDF 的布局信息重新排回 PDF 页面生成一份中英文对照或直接替换原文的双语文档。这个三段式架构各层解耦非常彻底每一步的输入输出都是纯文本或结构化 JSON调试起来特别方便。比如我可以在第二阶段直接输入一篇文章先验证翻译质量不必关心 PDF 解析也可以在第三阶段用一段临时文本测试排版代码不必依赖完整链路。1.3 为什么没直接用现成的 PDF 翻译工具市面上确实有不少 PDF 翻译软件在线翻译网站也支持直接上传 PDF但它们有两个普遍问题。第一个问题是版式信息丢失严重很多工具其实是把 PDF 转成图片再 OCR 出一堆文本进行翻译公式、图表、上下标全部错乱。第二个问题是对双栏论文支持差翻译软件往往按照 PDF 内部对象的顺序输出文本拿到的是左栏一句、右栏一句交叉排列的乱序内容。我的目标不只是翻译文字而是要保留论文原有的结构包括段落位置、图表位置、公式区占位。这就决定了工具必须做到内容和版式两层都要处理既要提取文本还要记录每个文本块在页面上的坐标。这个“坐标”字段是整个项目里最重要的数据后面翻译结果要放回 PDF靠的就是它。2. 技术选型与核心原理技术选型这件事我在 PDF 解析库上花了最多时间。Python 生态里 PDF 相关的库一大堆但各自的定位差别非常大有些擅长提取文本有些擅长读写页面对象还有些只适合做简单拼接。选错了库后面写再多代码也白搭。2.1 PDF 解析库的对比与选择我当时重点对比了三类方案可以给大家一个参考候选方案擅长方向主要问题PdfPlumber文本和表格提取返回字符坐标简单易用大文件速度偏慢无法直接生成新 PDFPyMuPDF (fitz)提取和生成一体渲染速度快能直接修改页面中文文本块切分粒度较粗需要二次处理PDF.js (Node 服务端)前端渲染效果好浏览器兼容性强服务端做文本重建时性能一般代码量大pdf-lib轻量创建和修改 PDF没有文本提取能力只适合生成阶段最终我选了 PyMuPDF 作为主力。原因有两个第一它同时覆盖了解析和生成两端解析时能拿到精确的字符坐标生成时可以直接在指定矩形位置插入中文文本不需要再引入第二条技术线处理 PDF 输出第二是速度极快一本几百页的论文 PDF提取全部字符信息只要几秒钟。pdfplumber 虽然在表格提取和文本细节上更强但对大型论文文件处理得太慢而且它和 PyMuPDF 在坐标体系上有差异混用两种库处理同一份文件容易精神分裂。PDF.js 也考虑过如果目标是完全在浏览器里实现纯前端翻译工具它是很好的选择。但服务端场景下PyMuPDF 的效率和 Python 生态的便利性更胜一筹开发调试也更快。如果你们团队的前端能力很强想把整套工具做成浏览器插件PDF.js 值得深入研究如果和我一样以脚本、后端服务为主建议直接走 PyMuPDF。2.2 翻译引擎的选择与取舍翻译引擎的选择我在 DeepL、Google 翻译 API 和 OpenAI 系的大模型接口之间做了对比。简单来说Google 翻译在“术语一致性”上稍弱DeepL 在欧语互译上表现好而在中文学术论文翻译场景里大模型翻译的自然度和上下文理解能力明显更强尤其是在处理被动语态和长难句时不容易翻译出“机器味”。我最后采用的方案是设计了一个翻译接口抽象层把不同翻译引擎封装成相同的方法签名这样可随时切换。默认用的是 DeepL API因为它的学术文献翻译质量和接口稳定性很均衡性价比也高。对于需要理解上下文术语的场景可以切换到 OpenAI 兼容接口通过 prompt 注入论文标题和领域词汇表让模型保持术语一致。有一点很关键千万不要把整段摘要一次性扔给翻译引擎要按句子边界切分后再翻译这样既降低单次请求超时风险也方便后续对每个句子单独对齐原坐标。2.3 版面信息保留的关键设计保留版式这件事我采用的思路是“包围盒记录法”在解析阶段对每个文本块记录它在页面上的矩形包围盒坐标左上角 x、y宽度和高度。翻译完成后生成译文 PDF 时直接把中文绘制到同一个矩形区域内。这样原文的段落位置、标题层级关系在视觉上都能保持下来。但这里有个无法回避的问题中英文长度不一样。同一段英文翻成中文通常会短很多如果直接把中文塞到原文的矩形框里页面下方会留下大片空白。为了解决这个问题我设计了一个“压缩重排模式”优先保证每个文本块的相对顺序不变但在垂直方向上对空白区域做收缩这样整篇论文翻译下来页数会变少但阅读顺序完全一致不会出现一页只有三行字的尴尬情况。3. 核心功能实现与实操步骤下面进入正题这套工具的关键代码长什么样每一步怎么操作。我会按照从解析到生成的完整链路来写贴出核心代码片段并解释每一段代码背后的逻辑。这里以 Python PyMuPDF 为例。3.1 环境准备与项目初始化第一步先把依赖装好。除了 PyMuPDF我还用到两个辅助库regex处理跨行断词问题tenacity做翻译 API 的重试控制。pip install pymupdf regex tenacity requests创建项目结构时我建议按功能模块拆分而不是一个脚本写到底。我自己的项目结构大概是pdf_translator/ ├── parser/ │ ├── extract.py # PDF 文本块和坐标提取 │ └── rebuild.py # 阅读顺序重建、段落还原 ├── translator/ │ ├── base.py # 翻译接口抽象层 │ ├── deepl_engine.py │ └── llm_engine.py ├── renderer/ │ └── layout.py # 译文 PDF 生成与重排 └── main.py # 总流程编排这样拆的好处是任何一环出了问题可以直接单独测试不会影响其他模块。特别是翻译引擎这块接口抽象层设计之后新增一个翻译源只需要继承基类实现两个方法不需要动业务代码。3.2 按块提取文本与坐标解析阶段的第一步是提取页面中每个文本块的准确坐标。PyMuPDF 的page.get_text(dict)方法能返回页面上所有文本块的详细信息包括每个 block 的类型、包围盒坐标以及内部每个 span 的字体和内容。import fitz # PyMuPDF def extract_blocks(pdf_path, page_num0): doc fitz.open(pdf_path) page doc.load_page(page_num) blocks page.get_text(dict, flagsfitz.TEXTFLAGS_TEXT)[blocks] result [] for block in blocks: if block[type] ! 0: # 0 表示文本块跳过图像块 continue bbox block[bbox] # (x0, y0, x1, y1) texts [] for line in block[lines]: for span in line[spans]: text span[text].strip() if text: texts.append({ text: text, font: span[font], size: span[size], bbox: span[bbox] }) result.append({ bbox: bbox, lines: texts }) return result这段代码看起来简单但有几个细节值得注意。我特意加上了TEXTFLAGS_TEXT标志这个标志会让 PyMuPDF 跳过页眉页脚中的装饰性文本减少噪声。另外span才是文本提取的最小粒度单元一个 span 通常代表连续的同字体文本段记录它的字体和字号很重要后面判断标题层级和正文时要用。踩过的一个坑是有些 PDF 的文本编码不规范提取出来的字符串里混着不可见字符比如软连字符。直接拿去翻译会出现奇怪的空格和断词。我为此写了一个清洗函数把\u00ad软连字符这类字符删掉再把接在后面的部分重新连起来。import re def clean_text(text): text text.replace(\u00ad, ) # 去掉软连字符 text re.sub(r\s, , text) # 连续空白合并为单空格 text text.replace(- , ) if re.search(r\w- \w, text) else text return text.strip()3.3 阅读顺序重建与段落还原这是整个工具最核心、也是我调试最久的一段。双栏论文的阅读顺序在 PDF 内部对象顺序里通常是乱的左右两栏的文本块会交错排列。要还原正确顺序靠的是“按坐标排序”而不是按对象顺序。思路是这样的先把页面按垂直方向分成若干条带strip每个条带的高度大概是一行文字的高度。在同一水平条带里左侧的文本块排序在前右侧的在后。但更稳妥的做法是直接按文本块左上角坐标排序先比较 y 坐标垂直位置当 y 坐标相差小于某个阈值时比较 x 坐标。这个阈值一般取该页面正文行高的 0.8 倍。def rebuild_reading_order(blocks, line_height14): # 按照 (y, x) 排序但 y 相差小于阈值时视为同一行按 x 排 def sort_key(block): x0, y0, x1, y1 block[bbox] row_group round(y0 / line_height) # 归入行组 return (row_group, x0) blocks.sort(keysort_key) return blocks这个简化版排序对大多数双栏论文是有效的但遇到复杂的多栏布局、嵌套表格或者浮动插图时还是会出错。更稳妥的方案是使用带容差的排序算法对每个文本块找到 y 坐标最接近的“行中心”再在行内按 x 排序。我之前做过一个改进版把每个块的中心点算出来然后对中心点进行聚类效果比纯排序好不少。段落还原又是另一层麻烦。同一段的文本在 PDF 里往往被拆成多个 block因为换行、缩进都会导致 block 切分。我的处理方法是按排序后的顺序遍历文本块如果当前块的左上角 x 坐标与前一个块接近且当前块的 y 坐标大于前一行的底部就可以认为它们是同一段否则判断为分段。段落合并完成后再按句子边界切分才能交给翻译引擎。def merge_paragraphs(sorted_blocks): paragraphs [] current_para [] last_x0, last_y1 None, None for block in sorted_blocks: x0, y0, x1, y1 block[bbox] if last_x0 is not None and abs(x0 - last_x0) 20 and y0 last_y1 10: current_para.append(block) else: if current_para: paragraphs.append(current_para) current_para [block] last_x0, last_y1 x0, y1 if current_para: paragraphs.append(current_para) return paragraphs这个阈值20 和 10是我根据常见论文 PDF 的排版参数试出来的不一定对所有文档都适用。所以我在实际项目里把它做成了可配置的参数通过命令行传入遇到特殊格式的 PDF 时逐个调整。3.4 翻译队列与并发控制翻译这一步要处理性能和稳定性两个问题。如果一篇文章有几百个句子逐句调用翻译 API串行请求可能要等十分钟并发太高又容易触发 API 限流。我用tenacity做重试同时用一个简单的信号量控制并发数。import requests from tenacity import retry, stop_after_attempt, wait_exponential retry(stopstop_after_attempt(5), waitwait_exponential(multiplier1, max30)) def translate_text(text, target_langZH, enginedeepl): # 以 DeepL 为例实际使用时替换为对应 endpoint 和 key resp requests.post( https://api-free.deepl.com/v2/translate, data{ auth_key: DEEPL_API_KEY, text: text, target_lang: target_lang, }, timeout15 ) resp.raise_for_status() return resp.json()[translations][0][text]重试策略用的是指数退避第一次失败等 1 秒第二次等 2 秒第三次等 4 秒最多等 30 秒累计尝试 5 次。这个策略对付瞬时限流和网络抖动很有效。并发控制我有一个经验值DeepL 免费版建议并发数不超过 5否则很容易触发 429 限流。付费版可以调到 10 左右。用信号量实现非常简单import threading semaphore threading.Semaphore(5) def translate_with_limit(text): with semaphore: return translate_text(text)句子级别的翻译还要注意上下文。我之前试过逐句独立翻译经常出现同一个术语在论文不同位置被翻成不同中文的情况。改进方式是在请求头带上一个全局术语表或者在段落级别做一次上下文缓存。最简单实用的做法是把论文标题和摘要提前翻译好注入到每句话的 prompt 里让翻译引擎知道这篇文章的领域背景效果提升非常明显。3.5 生成译文 PDF把中文放回页面翻译完成后最重要的一步是生成新的 PDF。这里我用 PyMuPDF 的insert_textbox方法把中文文本绘制到原有文本块的包围盒区域内。这个方法的优点是可以自动换行很适合用来绘制段落的译文。def render_translated_pdf(translated_blocks, output_path, src_pdf_path): src_doc fitz.open(src_pdf_path) out_doc fitz.open(src_pdf_path) # 复制原始页面布局作为底层 for page_num in range(len(out_doc)): page out_doc.load_page(page_num) for block in translated_blocks[page_num]: bbox fitz.Rect(block[bbox]) text block[translated_text] # 清除该区域的原始英文 page.add_redact_annot(bbox, fill(1, 1, 1)) page.apply_redactions() # 在中文字体处理上要特别小心 fontname china-s page.insert_textbox( bbox, text, fontnamefontname, fontsizeblock[font_size], alignfitz.TEXT_ALIGN_LEFT, lineheight1.4 ) out_doc.save(output_path)这里有个非常重要的坑中文字体。PyMuPDF 内置字体不包含中文字符直接使用默认字体插入中文会得到一堆乱码方块。PyMuPDF 从 1.18 版本开始提供china-s简体中文和china-ss宋体等内置 CJK 字体但如果 PDF 里的原始字体是西文字体直接切换中文字体后中文的显示效果会相对一致。另一种做法是注册系统内的中文字体文件比如思源宋体或 Noto Sans CJK这种方式对排版的控制更加精细。另外一个必须警惕的问题是apply_redactions()的性能。这个方法会把原本覆盖区域的文字内容彻底抹掉如果每页有几十个文本块一页调用几十次整个文档生成会特别慢尤其几百页的 PDF 可能卡到崩溃。我的优化方案有两个一是只在有文本翻译的块上做 redact空块直接跳过二是把多个块的 redact 注释一次性添加再统一执行apply_redactions()性能和速度都得到很大提升。3.6 双语对照模式与纯译文模式除了“替换原文”模式很多用户反馈更需要“双语对照”模式也就是上半部分是原文下半部分是译文。这种模式实现起来其实不难不需要在原位置上替换只需要在每页底部追加一个译文区域。我的实现方式是每页的页面高度在 PyMuPDF 里是固定的使用page.insert_textbox往页面底部画一个高度为原文高度 1/2 的矩形把译文按段落顺序绘制进去。这样原文排版原封不动译文在下方能够对照阅读适合需要核对原文细节的科研场景。我在实际测试中发现双语对照模式虽然容易理解但有个明显缺陷如果原文一页很长译文区域装不下所有句子的翻译就需要把超出的部分平移到下一页而这里面的“平移分配”逻辑很容易写错。我用的方案是将每页的译文做队列缓冲放不下的部分自动追加到下一页开头并加一条虚线连接提示读者“接上页”。这套小机制虽然简单但对阅读体验提升很明显。4. 常见问题与排查技巧实录开发过程中我踩了不少坑其中有一些是非常典型的。这里整理成速查表并详细讲讲排查思路。问题现象根本原因解决方案提取出的文字乱码或空白PDF 内嵌字体编码不规范或者文本以轮廓形式存储改用 OCR 方案如 PaddleOCR对扫描版 PDF 直接走图像文字识别链路双栏论文阅读顺序错乱排序算法对复杂多栏布局失效调大行高容差增加列中心点聚类必要时人工指定栏数中文渲染成方块未注册或加载 CJK 字体使用 PyMuPDF 内置 china-s 字体或注册 Noto Sans CJK 字体文件翻译 API 频繁返回 429并发数过高或触发限流降低并发数使用指数退避重试并检查 API 额度公式和特殊符号被翻译公式区未做过滤被当成普通文本发送在解析阶段先过滤包含大量数字、运算符和希腊字母的文本块跳过翻译页面底部大片空白中文长度远短于英文压缩重排逻辑未生效启用垂直方向空白压缩或切换到双语对照模式4.1 扫描版 PDF 的处理这套工具在最开始只处理文字版 PDF但实际使用中发现很多年代较久的论文是扫描版根本没有可提取的文本层。这种情况下只能走 OCR 流程。我后来把 OCR 作为解析阶段的一个前置选项接入使用的方案是 PaddleOCR 的版面分析模型它能识别出标题、正文、表格、图片等区域输出文本和坐标。OCR 路线的问题是速度和精度都不如原生文本提取。我的建议是先尝试原生提取如果一页提取出的字符数少于 50 个就自动判断为扫描版并触发 OCR。在论文场景下OCR 的准确率基本能到 95% 以上足以支持翻译。4.2 表格与公式的过滤策略翻译工具最忌讳的是把公式也翻译了。英文论文中的公式区通常由大量数字、希腊字母、数学符号组成特征非常明显。我在解析阶段写了一个判断函数如果一个文本块中数字和符号的占比超过 60%就认为它是公式区不参与翻译但保留它的坐标位置生成译文时直接留白或原样保留。表格的处理更复杂。表格有行列结构如果直接按文本块提取再翻译表格线会全部丢失变成一堆散落的文字。我的方案是检测到表格区域的文本块后按单元格坐标把每个单元格内的文字单独提取翻译再按原坐标一一放回。虽然单元格内的换行处理偶尔有问题但整体表格框架能保住。4.3 页面旋转与坐标系转换开发中我还遇到过一个问题部分 PDF 页面带有旋转属性get_text(dict) 返回的坐标是旋转前的坐标直接插入文本会错位。解决方法是读取page.rotation在生成译文时把目标矩形框做坐标变换。def adjust_rect_for_rotation(rect, page_width, page_height, rotation): if rotation 90: return fitz.Rect(rect.y0, page_width - rect.x1, rect.y1, page_width - rect.x0) elif rotation 180: return fitz.Rect(page_width - rect.x1, page_height - rect.y1, page_width - rect.x0, page_height - rect.y0) # 其他情况直接返回原矩形 return rect这个函数虽然简短但在处理从 IEEE 下载的某些横向表格页时必不可少。我最初没做旋转处理导致好几个人反馈“某些页面的译文位置偏了 90 度”排查了很久才发现是旋转属性在作怪。4.4 翻译质量的针对性优化翻译引擎只负责翻译不负责理解论文的领域术语。为了让译文更贴合学术语境我在翻译请求构造时增加了“上下文注入”功能如果当前文本块位于论文中的“Abstract”或“Conclusion”区域就在 prompt 中注入对应的全局提示词提示翻译引擎采用学术风格并保持术语一致。另外一个很实用的技巧是“术语表预翻译”。对于论文中出现频率极高的专有名词比如 model模型、reinforcement learning强化学习、neural network神经网络可以在解析阶段先做一个词频统计把高频词和对应的标准翻译写入一个 JSON 术语表在翻译 prompt 末尾追加“请注意以下术语的统一翻译...”。这样全文翻译下来术语一致性明显提高。5. 实战效果与可扩展方向工具开发完毕后我拿自己的论文库做了几轮测试。总体效果是单篇 20 页的双栏论文从 PDF 解析到翻译完成到最后生成双语 PDF耗时大约 3 分钟其中大部分时间耗在翻译 API 请求上解析和排版只占不到 10 秒。翻译后的 PDF 在阅读体验上已经能满足快速了解论文核心思想的需求术语统一、段落顺序正确、表格和公式占位完整。不过要坦白说这个工具有它的使用边界。对于需要精确理解每一个公式推导过程的场景机器翻译的译本无法替代人工精读但对于“快速筛选论文值不值得精读”这个高频场景它的价值非常明显。我自己现在的习惯是下载一批新论文后批量跑一遍工具生成翻译版先花 15 分钟浏览大意再挑出真正重要的文章进行精读。后续可扩展的方向我自己列了几个想法。一是把工具封装成可部署的 Web 服务接上用户上传下载的完整流程做成一个小型工具站二是增加批量任务队列支持几十篇论文排队翻译这个其实只需要在翻译调度层加一个 Redis 队列即可三是引入“术语库学习”机制根据用户上传的历史翻译结果不断更新术语表让翻译越用越准。最后再说一个可执行的小经验如果你只打算处理 PDF 翻译这一个场景别一上来就追求完整的 Web 架构。先用脚本把从解析到生成的最小闭环跑通再逐步加界面、加队列、加任务管理。最小可行版本能解决你 80% 的需求剩下的功能都是锦上添花。