【python】PDF文件翻译

技术教材型 PDF,正文大多是可提取文本,同时夹杂公式、图号、表号和术语,所以最稳的路线不是"整页截图直接翻",而是:

PDF 文本提取 → 按段分块 → 保留公式/编号/缩写 → 本地模型翻译 → 重建 DOCX/PDF

这也是你本地最容易复现、最省钱、效果最稳的一种做法。


一、整体流程

1)先判断 PDF 类型

先看它是不是"文本型 PDF"。

判断方法很简单:

  • 能直接选中 PDF 里的英文文字,多半就是文本型 PDF
  • 如果只能看到图片、选不中字,那就是扫描版 PDF

你的这批书章 PDF 很像文本型 PDF,这种最适合直接解析文字层。


2)提取文本,不直接整页 OCR

文本型 PDF 用 PyMuPDF 提取效果通常最好,因为它能按 block 读,比较容易保住阅读顺序。

处理时重点做三件事:

  • 按页面、文本块顺序提取
  • 修复断行和连字符断词,比如 effi-\nciency -> efficiency
  • 保留标题、公式、图号、表号、引用编号

3)按段落分块翻译

不要整本一次喂给模型。

正确做法是:

  • 按段落切分
  • 再把若干段拼成一个 chunk
  • 每个 chunk 控制在 1000--2500 英文词以内,或者 1500--3000 字符左右

这样做的好处是:

  • 不容易爆上下文
  • 失败了容易断点续跑
  • 更容易缓存翻译结果

4)翻译时加"保结构"提示词

对技术 PDF,提示词非常关键。核心要求一般是:

  • 忠实翻译,不要总结
  • 保留公式、变量名、缩写
  • Figure 5.1 → 图 5.1
  • Table 7.1 → 表 7.1
  • 121\] 这种引用不要动


5)输出成 DOCX,再转 PDF

本地最省事的输出链路通常是:

  • 先生成 .docx
  • 再用 LibreOffice 无头转换成 .pdf

因为直接从 Python 排 PDF 很麻烦,版式控制也不如 DOCX 顺手。


二、推荐的本地方案

我建议你先用这一套:

PyMuPDF + Ollama(本地大模型) + python-docx + LibreOffice

优点:

  • 全本地
  • 不依赖云端 API
  • 技术文档翻译质量通常比传统机器翻译更自然
  • 方便你自己改提示词

三、环境安装

先装这些:

bash 复制代码
pip install pymupdf python-docx requests tqdm

然后本地装:

  • Ollama
  • 一个中英技术翻译能力还不错的本地模型

再装一个:

  • LibreOffice,用于把 docx 转 pdf

如果 PDF 是扫描版,再额外装:

  • ocrmypdf
  • PaddleOCR

四、可直接跑的 Python 脚本

下面这个脚本适合文本型 PDF

它会做这几件事:

  1. 解析 PDF 文本块
  2. 清洗断行和断词
  3. 分块调用本地 Ollama 翻译
  4. 缓存结果,支持断点续跑
  5. 输出 DOCX
  6. 可选转成 PDF
python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from __future__ import annotations

import argparse
import hashlib
import json
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Dict, List, Tuple

import fitz  # PyMuPDF
import requests
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Pt
from tqdm import tqdm


OLLAMA_URL = "http://127.0.0.1:11434/api/generate"


def clean_block_text(text: str) -> str:
    """清洗 PDF block 文本,尽量修复断词和断行。"""
    text = text.replace("\u00ad", "")  # soft hyphen
    text = text.replace("\r", "\n")

    # 修复英语断词: effi-\nciency -> efficiency
    text = re.sub(r"([A-Za-z])-\n([A-Za-z])", r"\1\2", text)

    # 单换行合并为空格,双换行保留为段落
    text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)

    # 清理多余空白
    text = re.sub(r"[ \t]+", " ", text)
    text = re.sub(r"\n{3,}", "\n\n", text)

    return text.strip()


def is_noise_block(text: str) -> bool:
    """过滤明显无意义 block。"""
    s = text.strip()
    if not s:
        return True

    # 只含页码
    if re.fullmatch(r"\d+", s):
        return True

    return False


def extract_pages(pdf_path: Path) -> List[Tuple[int, str]]:
    """从 PDF 提取每页文本,尽量保持阅读顺序。"""
    doc = fitz.open(pdf_path)
    pages: List[Tuple[int, str]] = []

    for page_index in range(len(doc)):
        page = doc[page_index]
        raw_blocks = page.get_text("blocks")
        # blocks: (x0, y0, x1, y1, text, block_no, block_type)
        blocks = sorted(raw_blocks, key=lambda b: (round(b[1], 1), round(b[0], 1)))

        texts: List[str] = []
        for b in blocks:
            if len(b) < 7:
                continue
            block_type = b[6]
            if block_type != 0:  # 只取文本 block
                continue
            block_text = clean_block_text(b[4])
            if is_noise_block(block_text):
                continue
            texts.append(block_text)

        page_text = "\n\n".join(texts).strip()
        pages.append((page_index + 1, page_text))

    doc.close()
    return pages


def split_into_chunks(text: str, max_chars: int = 2200) -> List[str]:
    """按段落切块。"""
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks: List[str] = []
    cur: List[str] = []
    cur_len = 0

    for p in paragraphs:
        p_len = len(p)
        if cur and cur_len + p_len + 2 > max_chars:
            chunks.append("\n\n".join(cur))
            cur = [p]
            cur_len = p_len
        else:
            cur.append(p)
            cur_len += p_len + 2

    if cur:
        chunks.append("\n\n".join(cur))

    return chunks


def sha1_text(text: str) -> str:
    return hashlib.sha1(text.encode("utf-8")).hexdigest()


class Cache:
    def __init__(self, path: Path):
        self.path = path
        if self.path.exists():
            self.data: Dict[str, str] = json.loads(self.path.read_text(encoding="utf-8"))
        else:
            self.data = {}

    def get(self, key: str) -> str | None:
        return self.data.get(key)

    def set(self, key: str, value: str) -> None:
        self.data[key] = value

    def save(self) -> None:
        self.path.write_text(
            json.dumps(self.data, ensure_ascii=False, indent=2),
            encoding="utf-8"
        )


def build_prompt(text: str) -> str:
    return f"""
你是一名专业的英文技术文档翻译员。请将下面英文内容翻译成中文。

要求:
1. 忠实、准确,不要总结,不要解释,不要扩写。
2. 保留标题层级、章节号、公式、变量名、缩写、引用编号。
3. Figure 5.1 翻译为"图 5.1",Table 3.1 翻译为"表 3.1"。
4. 对 MAC、PE、NoC、SIMD、SIMT、DNN、CNN、FC 等常见缩写,默认保留英文缩写。
5. 公式、代码、数值、单位、括号内编号尽量原样保留。
6. 输出只包含翻译结果,不要额外说任何话。

待翻译英文:
{text}
""".strip()


def translate_with_ollama(text: str, model: str, timeout: int = 300) -> str:
    payload = {
        "model": model,
        "prompt": build_prompt(text),
        "stream": False,
        "options": {
            "temperature": 0.1,
            "num_ctx": 8192
        }
    }
    resp = requests.post(OLLAMA_URL, json=payload, timeout=timeout)
    resp.raise_for_status()
    data = resp.json()
    return data["response"].strip()


def translate_pages(
    pages: List[Tuple[int, str]],
    model: str,
    cache: Cache,
    sleep_sec: float = 0.0
) -> List[Tuple[int, str]]:
    translated_pages: List[Tuple[int, str]] = []

    for page_no, page_text in tqdm(pages, desc="Translating pages"):
        if not page_text.strip():
            translated_pages.append((page_no, ""))
            continue

        chunks = split_into_chunks(page_text)
        zh_chunks: List[str] = []

        for chunk in chunks:
            key = sha1_text(model + "\n" + chunk)
            cached = cache.get(key)
            if cached is not None:
                zh = cached
            else:
                zh = translate_with_ollama(chunk, model=model)
                cache.set(key, zh)
                cache.save()
                if sleep_sec > 0:
                    time.sleep(sleep_sec)
            zh_chunks.append(zh)

        translated_pages.append((page_no, "\n\n".join(zh_chunks)))

    return translated_pages


def set_docx_style(doc: Document) -> None:
    normal = doc.styles["Normal"]
    normal.font.name = "Microsoft YaHei"
    normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
    normal.font.size = Pt(10.5)


def save_docx(
    translated_pages: List[Tuple[int, str]],
    input_pdf: Path,
    output_docx: Path
) -> None:
    doc = Document()
    set_docx_style(doc)

    doc.add_heading(f"{input_pdf.stem} 中文翻译", level=0)

    for page_no, page_text in translated_pages:
        doc.add_heading(f"第 {page_no} 页", level=1)

        if not page_text.strip():
            doc.add_paragraph("[本页无可提取文本]")
            continue

        for para in [p.strip() for p in page_text.split("\n\n") if p.strip()]:
            doc.add_paragraph(para)

    doc.save(str(output_docx))


def convert_docx_to_pdf(output_docx: Path) -> Path | None:
    """优先尝试 LibreOffice 无头转换。"""
    soffice = shutil.which("soffice")
    if not soffice:
        print("未检测到 soffice,跳过 PDF 转换。")
        return None

    outdir = output_docx.parent
    cmd = [
        soffice,
        "--headless",
        "--convert-to",
        "pdf",
        "--outdir",
        str(outdir),
        str(output_docx),
    ]
    subprocess.run(cmd, check=True)
    pdf_path = outdir / (output_docx.stem + ".pdf")
    return pdf_path if pdf_path.exists() else None


def main() -> int:
    parser = argparse.ArgumentParser(description="本地 PDF 技术文档翻译脚本")
    parser.add_argument("--input", required=True, help="输入 PDF 路径")
    parser.add_argument("--output", default="", help="输出 docx 路径")
    parser.add_argument("--model", required=True, help="Ollama 模型名")
    parser.add_argument("--sleep", type=float, default=0.0, help="每个 chunk 翻译后的等待秒数")
    parser.add_argument("--no-pdf", action="store_true", help="只输出 docx,不转 pdf")
    args = parser.parse_args()

    input_pdf = Path(args.input).expanduser().resolve()
    if not input_pdf.exists():
        print(f"输入文件不存在: {input_pdf}", file=sys.stderr)
        return 1

    output_docx = Path(args.output).expanduser().resolve() if args.output else input_pdf.with_name(input_pdf.stem + "_中文翻译.docx")
    cache_path = input_pdf.with_name(input_pdf.stem + "_translate_cache.json")

    print(f"读取 PDF: {input_pdf}")
    pages = extract_pages(input_pdf)

    # 简单判断是否可能是扫描版
    total_text_len = sum(len(t) for _, t in pages)
    avg_len = total_text_len / max(len(pages), 1)
    if avg_len < 80:
        print("警告:提取到的平均文本很少,这个 PDF 可能是扫描版,建议先做 OCR 再翻译。")

    cache = Cache(cache_path)
    translated_pages = translate_pages(pages, model=args.model, cache=cache, sleep_sec=args.sleep)

    print(f"保存 DOCX: {output_docx}")
    save_docx(translated_pages, input_pdf, output_docx)

    if not args.no_pdf:
        try:
            pdf_path = convert_docx_to_pdf(output_docx)
            if pdf_path:
                print(f"保存 PDF: {pdf_path}")
        except Exception as e:
            print(f"PDF 转换失败,但 DOCX 已生成:{e}")

    print("完成。")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

五、怎么运行

假设你本地已经把 Ollama 跑起来了,并且有一个可用模型。

命令示例:

bash 复制代码
python pdf_translate_local.py \
  --input "07_Kernel Computation.pdf" \
  --model "你的本地模型名"

输出会生成:

  • 07_Kernel Computation_中文翻译.docx
  • 07_Kernel Computation_中文翻译.pdf(如果装了 LibreOffice)

六、批量处理整个文件夹

Windows PowerShell

powershell 复制代码
Get-ChildItem *.pdf | ForEach-Object {
    python .\pdf_translate_local.py --input $_.FullName --model "你的本地模型名"
}

Linux / macOS

bash 复制代码
for f in *.pdf; do
  python pdf_translate_local.py --input "$f" --model "你的本地模型名"
done

七、扫描版 PDF 怎么办

如果是扫描版,先做 OCR,再翻译。

方案 A:OCRmyPDF

bash 复制代码
ocrmypdf input.pdf output_ocr.pdf -l eng --force-ocr
python pdf_translate_local.py --input output_ocr.pdf --model "你的本地模型名"

方案 B:PaddleOCR

流程是:

  • 把每页转成图片
  • OCR 出文本
  • 再复用上面的翻译和 DOCX 输出逻辑

八、这套方法的优缺点

优点

  • 全本地
  • 便于批量跑
  • 能缓存,失败可续跑
  • 对教材、论文、技术手册很实用
  • 比"整页 OCR + 直接翻"更稳定

缺点

  • 双栏排版、复杂表格、脚注,顺序偶尔会乱
  • 图片里的文字默认不会翻
  • 公式很多的页面,有时需要额外做公式保护
  • 本地小模型翻译质量可能不如大模型

九、想进一步做好,建议你继续加这几个增强点

最值得加的增强是这 4 个:

  1. 术语表

    自己维护一个 JSON,比如:

    • processing element -> 处理单元
    • dataflow -> 数据流
    • throughput -> 吞吐量
    • latency -> 时延
    • roofline model -> Roofline 模型

    在翻译前先做占位保护,翻译后再替换回来,术语会更统一。

  2. 标题识别

    用字体大小、全大写、编号模式识别 Chapter 5 / 5.1 / 5.1.1,在 DOCX 里映射成不同 heading。

  3. 图表保护

    Figure ...Table ...、公式块单独处理,避免模型误改格式。

  4. 中英对照输出

    DOCX 中一段英文后一段中文,后期校对最方便。


十、最实际的建议

你先按这个顺序落地:

第一版:

  • 只做文本型 PDF
  • 只输出 DOCX
  • 不处理图片文字
  • 不追求完美版式

把这版跑通后,再加:

  • OCR
  • 术语表
  • 中英对照
  • 表格和公式特殊处理

这样最省时间,也最不容易陷进版式细节。

相关推荐
rockmelodies2 小时前
用 Python 实现 Docker 镜像批量推送(带进度条)
python·docker·eureka
2301_817672262 小时前
如何在 HTML 中正确使用 exif-js 库读取图片 EXIF 元数据
jvm·数据库·python
坐吃山猪2 小时前
Python19_WebSocket模拟pipeline进展
网络·websocket·网络协议
ZHENGZJM2 小时前
文档解析器:支持 PDF、DOCX、Markdown
react.js·pdf·全栈开发
2401_832635582 小时前
如何用 credentials 参数决定 Fetch 是否携带本地的 Cookie
jvm·数据库·python
粉嘟小飞妹儿2 小时前
mysql如何通过防火墙保护MySQL权限_MySQL网络层安全配置
jvm·数据库·python
2301_803538952 小时前
如何高效批量删除SQL数据_使用脚本分段删除降低压力
jvm·数据库·python
书到用时方恨少!2 小时前
Python 面向对象编程:从“过程清单”到“智能积木”的思维革命
开发语言·python·面向对象
2401_897190552 小时前
MySQL升级导致排序规则变化怎么处理_更新Collation配置
jvm·数据库·python