夜雨聆风学习资料网

ARTICLE · 1333673

用 Python 写一个 PDF 转 DOC 小工具(支持扫描版 PDF)

用 Python 写一个 PDF 转 DOC 小工具(支持扫描版 PDF)

1. 需求分析:PDF 转 DOC 到底难在哪

PDF 和 DOC 是两种完全不同的文档模型:PDF 描述的是「在某个坐标画某个字形」,DOC 描述的是「段落、样式、表格」这样的结构化内容。所以转换的核心不是格式翻译,而是版面还原。

按 PDF 的来源,可以分成两类,处理方式完全不同:

PDF 类型
特征
转换思路
文本型(电子版)
文字可选中、可复制
直接抽取文本与版面信息,重建段落
扫描型(图片版)
文字不可选中,本质是一张张图片
先 OCR 识别文字,再重建段落

很多工具只能处理第一类,遇到扫描件就输出一堆空白。本文的小工具会同时覆盖两类,并自动判断走哪条路径。

2. 技术选型

环节
库
说明
PDF 解析
PyMuPDF
(fitz)
速度快,能同时拿到文本、坐标、图片
扫描件判断
PyMuPDF
统计每页可提取字符数
OCR 识别
PaddleOCR
中文识别效果好,自带版面分析
图片处理
Pillow
页面渲染、预处理
DOC 生成
python-docx
生成 .docx,兼容 Word/WPS

安装依赖:

pip install pymupdf paddleocr paddlepaddle pillow python-docx

说明:PaddleOCR 首次运行会自动下载模型,需要联网。如果只想处理文本型 PDF,可以不装 OCR 相关依赖。

3. 整体流程设计

关键点在于按坐标重建段落:OCR 和文本抽取拿到的都是「一行行带坐标的文字」,需要根据纵坐标把同一行的文字合并,再根据行间距判断是否换段。

4. 核心代码实现

4.1 判断是否为扫描版 PDF

import fitz  # PyMuPDFdef is_scanned_pdf(pdf_path: str, sample_pages: int = 5, threshold: int = 50) -> bool:    """抽样判断 PDF 是否为扫描版:可提取字符数过少即认为是扫描件"""    doc = fitz.open(pdf_path)    pages = min(sample_pages, doc.page_count)    total_chars = 0    for i in range(pages):        text = doc[i].get_text("text").strip()        total_chars += len(text)    doc.close()    # 平均每页字符数低于阈值,判定为扫描版    return total_chars / max(pages, 1) < threshold

4.2 文本型 PDF:抽取文本块

def extract_text_blocks(pdf_path: str):    """返回 [(page_no, [(x0, y0, x1, y1, text), ...]), ...]"""    doc = fitz.open(pdf_path)    result = []    for page_no, page in enumerate(doc):        blocks = []        for b in page.get_text("blocks"):            x0, y0, x1, y1, text = b[0], b[1], b[2], b[3], b[4]            text = text.strip()            if text:                blocks.append((x0, y0, x1, y1, text))        result.append((page_no, blocks))    doc.close()    return result

4.3 扫描型 PDF:渲染 + OCR

import ioimport numpy as npfrom PIL import Imagefrom paddleocr import PaddleOCR# 首次初始化会下载模型;use_angle_cls 用于纠正倾斜文字ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False)def ocr_page(page, dpi: int = 200):    """把一页 PDF 渲染成图片后做 OCR,返回 [(x0, y0, x1, y1, text), ...]"""    pix = page.get_pixmap(dpi=dpi)    img = Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB")    arr = np.array(img)    result = ocr.ocr(arr, cls=True)    blocks = []    if not result or not result[0]:        return blocks    for line in result[0]:        box, (text, score) = line        if score < 0.5:  # 置信度过低直接丢弃            continue        xs = [p[0] for p in box]        ys = [p[1] for p in box]        blocks.append((min(xs), min(ys), max(xs), max(ys), text))    return blocks

4.4 按坐标重建段落

这是整个工具里最影响效果的一步。思路是:先按纵坐标把文字块聚成「行」,再按行间距把「行」聚成「段」。

def group_into_paragraphs(blocks, line_tol: float = 8.0, para_gap: float = 1.8):    """把带坐标的文字块聚合成段落文本列表"""    if not blocks:        return []    # 1. 按 y 坐标排序,聚成行    blocks = sorted(blocks, key=lambda b: (b[1], b[0]))    lines = []    for b in blocks:        placed = False        for line in lines:            # 与已有行的纵向中心接近,视为同一行            if abs((b[1] + b[3]) / 2 - (line["y"] + line["h"] / 2)) < line_tol:                line["items"].append(b)                placed = True                break        if not placed:            lines.append({"y": b[1], "h": b[3] - b[1], "items": [b]})    # 2. 行内按 x 排序拼接    for line in lines:        line["items"].sort(key=lambda b: b[0])        line["text"] = "".join(i[4] for i in line["items"])    lines.sort(key=lambda l: l["y"])    # 3. 按行间距聚成段    paragraphs, current = [], [lines[0]["text"]]    for prev, cur in zip(lines, lines[1:]):        gap = cur["y"] - (prev["y"] + prev["h"])        if gap > prev["h"] * para_gap:            paragraphs.append("".join(current))            current = [cur["text"]]        else:            current.append(cur["text"])    paragraphs.append("".join(current))    return [p for p in paragraphs if p.strip()]

4.5 写入 DOCX

from docx import Documentfrom docx.shared import Ptdef write_docx(paragraphs, out_path: str, font_name: str = "宋体", font_size: int = 11):    doc = Document()    style = doc.styles["Normal"]    style.font.name = font_name    style.font.size = Pt(font_size)    # 中文字体需要额外设置 eastAsia    style.element.rPr.rFonts.set(        "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}eastAsia", font_name    )    for p in paragraphs:        doc.add_paragraph(p)    doc.save(out_path)

4.6 主流程串联

def pdf_to_docx(pdf_path: str, out_path: str, force_ocr: bool = False):    scanned = force_ocr or is_scanned_pdf(pdf_path)    print(f"[信息] 判定为{'扫描版'if scanned else'文本型'} PDF")    all_paragraphs = []    if scanned:        doc = fitz.open(pdf_path)        for page_no, page in enumerate(doc):            print(f"[OCR] 正在识别第 {page_no + 1}/{doc.page_count} 页")            blocks = ocr_page(page)            all_paragraphs.extend(group_into_paragraphs(blocks))        doc.close()    else:        for _, blocks in extract_text_blocks(pdf_path):            all_paragraphs.extend(group_into_paragraphs(blocks))    write_docx(all_paragraphs, out_path)    print(f"[完成] 已输出:{out_path}")if __name__ == "__main__":    import sys    if len(sys.argv) < 3:        print("用法: python pdf2docx.py input.pdf output.docx [--ocr]")        sys.exit(1)    pdf_to_docx(sys.argv[1], sys.argv[2], force_ocr="--ocr" in sys.argv)

5. 效果优化建议

  • 倾斜校正
    :扫描件常有轻微倾斜,OCR 前可用 PaddleOCR 的 use_angle_cls 或 OpenCV 做透视校正。
  • 表格还原
    :OCR 结果里表格会被拆成散乱文字,可结合 PaddleOCR 的版面分析模型(PP-Structure)单独识别表格并转成 docx 表格。
  • 图片保留
    :文本型 PDF 可用 page.get_images() 提取内嵌图片,按坐标插入到 docx 对应位置。
  • 多栏排版
    :双栏文档需要先按 x 坐标切分左右栏,再分别聚合,否则会把两栏文字串在一起。
  • 性能
    :OCR 是瓶颈,200 DPI 已足够,再高收益有限;大文件建议按页并行处理。

6. 小结

这个工具的核心只有三件事:判断 PDF 类型、拿到带坐标的文字、按坐标重建段落。文本型 PDF 走快速通道,扫描型 PDF 走 OCR 通道,最终统一输出为 docx。

需要提醒的是,PDF 转 DOC 不可能做到 100% 无损还原,尤其是复杂排版、公式和表格。实际使用时,把它当作「先转出可编辑的初稿,再人工微调」的工具,效率提升会非常明显。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料