2026-10-05 · 喵窝资源铺
场景很常见:一批 Word 合同/报价单/报告要转成 PDF 发客户——PDF 格式不跑版、防改。桌面端装个 WPS 一个个「另存为 PDF」还凑合,几十上百份就崩溃了;而且服务器/容器环境根本没装 Word 或 LibreOffice,很多网上方案(docx2pdf 靠 COM 组件、libreoffice --headless)直接不可用。这篇给一个纯 Python 的批量脚本:python-docx 读 + reportlab 写,pip 装完就能跑,Linux 服务器/Windows/Mac 通吃。保留标题层级、正文、列表编号和表格。文中代码实跑验证,真实输出贴在下面。
| 输入 | 脚本的处理 |
|---|---|
| 正常 Word | 按文档真实顺序导出:Title/标题1/标题2 分级样式、正文、列表加「·」、引用变灰、表格画网格+隔行底色+表头灰底 |
| 嵌套目录 | os.walk 递归,输出目录保持原层级 |
| 空文档/纯图片 | 明确跳过并写进报告,不产出空白 PDF |
| 坏文件 | 截断损坏的 .docx 报 Package not found 跳过,不崩整批 |
| 老 .doc 格式 | 不在处理范围(二进制格式需另装工具),自动跳过 |
| Word 锁文件 | ~$xxx.docx 临时文件自动排除 |
python-docx 和 reportlab 都有一个叫Paragraph的类(docx.text.paragraph.Paragraph和reportlab.platypus.Paragraph),还有同名Table。后 import 的把先 import 的盖掉,结果把 docx 的段落对象直接喂给了 reportlab 的 Paragraph 构造函数,报AttributeError: 'CT_P' object has no attribute 'split'——这个报错完全看不出是名字冲突,第一次跑就是被它误导的。解法是 import 时起别名:from docx.text.paragraph import Paragraph as DocxParagraph、from docx.table import Table as DocxTable,reportlab 那边用Table as RLTable。
顺带一坑:中文字体。reportlab 内置字体不含中文,直接导出全是方块。脚本里按优先级探测本机字体(文泉驿/思源黑体等),Linux 没有 fontconfig 自动注册那套,必须手动pdfmetrics.registerFont(TTFont("CJK", 字体路径))并在每个样式里指定fontName="CJK"。服务器上没装字体的话apt install fonts-wqy-microhei一行解决。
# -*- coding: utf-8 -*-
"""批量 Word(.docx) 转 PDF:整目录处理,坏文件不崩。
用法:
python3 word_to_pdf.py <输入目录> <输出目录>
纯 Python 实现(python-docx 读 + reportlab 写),不需要装 Word/LibreOffice,
Linux 服务器可跑。保留标题层级、正文段落、列表编号和表格;页眉页脚/图片暂不支持。
"""
import os, sys
from docx import Document
from docx.table import Table as DocxTable
from docx.text.paragraph import Paragraph as DocxParagraph
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.platypus import Table as RLTable, TableStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
FONT_CANDIDATES = [
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/arphic/uming.ttc",
]
def register_cjk():
for p in FONT_CANDIDATES:
if os.path.exists(p):
pdfmetrics.registerFont(TTFont("CJK", p))
return True
return False
def iter_block_items(parent):
"""按文档真实顺序遍历段落和表格(doc.tables 是单独收集的,顺序会乱)"""
from docx.oxml.ns import qn
for child in parent.element.body.iterchildren():
if child.tag == qn("w:p"):
yield DocxParagraph(child, parent)
elif child.tag == qn("w:tbl"):
yield DocxTable(child, parent)
def esc(s):
return (s.replace("&", "&").replace("<", "<")
.replace(">", ">"))
def walk_docx(d):
for root, _, files in os.walk(d):
for f in sorted(files):
if f.lower().endswith(".docx") and not f.startswith("~$"):
yield os.path.join(root, f)
def out_path(outdir, in_root, p):
dst = os.path.join(outdir, os.path.relpath(p, in_root)[:-5] + ".pdf")
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
return dst
def make_styles():
base = dict(fontName="CJK", fontSize=10.5, leading=17)
return {
"Title": ParagraphStyle("Title", spaceAfter=10, fontSize=18, leading=24, fontName="CJK"),
"Heading1": ParagraphStyle("H1", spaceBefore=12, spaceAfter=6, fontSize=14, leading=20, fontName="CJK"),
"Heading2": ParagraphStyle("H2", spaceBefore=10, spaceAfter=5, fontSize=12, leading=18, fontName="CJK"),
"Normal": ParagraphStyle("N", **base),
"List": ParagraphStyle("L", leftIndent=14, **base),
"Quote": ParagraphStyle("Q", leftIndent=14, textColor=colors.grey, **base),
}
def convert(src, dst, styles):
doc = Document(src)
story, tbl_idx = [], 0
for block in iter_block_items(doc):
if isinstance(block, DocxParagraph):
text = block.text.strip()
if not text:
continue
name = block.style.name or "Normal"
if name.startswith("Title"): st = styles["Title"]
elif name in ("Heading 1", "标题 1"): st = styles["Heading1"]
elif name in ("Heading 2", "标题 2"): st = styles["Heading2"]
elif name.startswith(("List", "列表")): st = styles["List"]; text = "· " + text
elif name.startswith(("Quote", "引用")): st = styles["Quote"]; text = "▎" + text
else: st = styles["Normal"]
story.append(Paragraph(esc(text), st))
else: # 表格
t = doc.tables[tbl_idx]; tbl_idx += 1
rows = [[esc(c.text.strip()) for c in r.cells] for r in t.rows]
if not rows:
continue
ncols = max(len(r) for r in rows)
w = 16 * cm / ncols
fs = 8.5 if ncols > 5 else 9.5
rt = RLTable(rows, colWidths=[w] * ncols, repeatRows=1)
rt.setStyle(TableStyle([
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.93, 0.93, 0.93)),
("FONTNAME", (0, 0), (-1, -1), "CJK"),
("FONTSIZE", (0, 0), (-1, -1), fs),
("LEADING", (0, 0), (-1, -1), fs + 4),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [None, colors.Color(0.97, 0.97, 0.97)]),
]))
story += [Spacer(1, 4), rt, Spacer(1, 6)]
if not story:
raise ValueError("文档没有可导出的内容(空文档/纯图片)")
SimpleDocTemplate(dst, pagesize=A4, leftMargin=2*cm, rightMargin=2*cm,
topMargin=2*cm, bottomMargin=2*cm).build(story)
def main():
if len(sys.argv) < 3:
print(__doc__); sys.exit(1)
indir, outdir = sys.argv[1], sys.argv[2]
if not os.path.isdir(indir):
print("输入目录不存在:", indir); sys.exit(1)
if not register_cjk():
print("未找到中文字体,中文会显示为方块;请安装 fonts-wqy-microhei"); sys.exit(1)
styles = make_styles()
ok = skip = 0
for p in walk_docx(indir):
try:
dst = out_path(outdir, indir, p)
convert(p, dst, styles)
print("OK\t%s\t%s" % (os.path.basename(p), os.path.relpath(dst, outdir)))
ok += 1
except Exception as e:
print("[跳过] %s\t%s" % (os.path.basename(p), e))
skip += 1
print("\n处理 %d 个,成功 %d,跳过 %d" % (ok + skip, ok, skip))
if __name__ == "__main__":
main()
造了 4 个测试文件:带三级标题+项目符号列表+3×3 表格(中英数字混排)的报告、空标题的空文档、截断损坏的假 docx、还有一个 Word 锁文件 ~$report.docx(模拟 Word 开着文档时打包目录的场景)。
[跳过] empty.docx 文档没有可导出的内容(空文档/纯图片) OK report.docx report.pdf [跳过] broken.docx Package not found at 'testdata_w2p/子目录/broken.docx' 处理 3 个,成功 1,跳过 2
产物复核(用 pypdf 读回转换出的 PDF 提取全文):标题、两级小标题、正文数字「120 万元」、列表项「人力成本 45 万元」、表格内容「上海/50万/32%」全部命中——中文渲染正常、层级保留、表格数据无损:
pages: 1 季度运营报告 -> True 销售情况 -> True 120 万元 -> True 上海 -> True 32% -> True