《Python编程实战》13.2 Excel / Word / PDF 自动化

把手工填表交给脚本:本节用 openpyxl 3.1.5 读写 Excel(样式、公式、只读模式),python-docx 1.2.0 生成 Word 与模板占位符替换,pypdf 6.19.0 合并拆分与提取文本,全部真跑并贴真实输出,附公式不自动计算的坑。

本节目标:用 openpyxl / python-docx / pypdf 把「手工填表、复制粘贴、手动合并 PDF」变成可复现的脚本,搞清公式读取、模板占位符、大文件只读三类真实坑。
适用版本:Python 3.12+(实测 3.14.6);openpyxl 3.1.5、python-docx 1.2.0、pypdf 6.19.0

13.2 Excel / Word / PDF 自动化

上一节我们治理了「文件」这个容器;这一节处理容器里的内容。办公自动化的三大主角是 Excel、Word、PDF,各自有成熟库,但它们的能力边界差异很大,踩坑点也完全不同。先把分工讲清楚,再逐个真跑。

13.2.1 三个库的分工与边界

库版本擅长关键限制
openpyxl3.1.5读写 .xlsx、样式、公式字符串不计算公式;不支持旧版 .xls
python-docx1.2.0生成/读取 .docx、样式、表格不支持 .doc;复杂排版能力有限
pypdf6.19.0合并、拆分、旋转、提取文本、加密不做 OCR;扫描件提取不出文字

一条铁律:它们都只能处理「现代格式」——.xlsx / .docx,不是 .xls / .doc。老格式得先用 LibreOffice 之类的工具转换。另外 .xlsx 本质是 zip 包(一堆 XML),所以「能读」不等于「读得快」——大文件有专门的优化模式。

13.2.2 openpyxl 写 Excel:数据、样式、公式

目标:生成一张带表头样式、金额公式和合计行的销售表。

from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side

OUT = Path("/tmp/python_book/scratch/13/sales.xlsx")
wb = Workbook()
ws = wb.active
ws.title = "销售明细"
ws.append(["日期", "产品", "数量", "单价"])          # 一次追加一行

rows = [
    ("2026-09-01", "笔记本", 3, 6999.0),
    ("2026-09-02", "键盘", 10, 299.0),
    ("2026-09-03", "显示器", 2, 1599.0),
    ("2026-09-04", "键盘", 5, 299.0),
]
for r in rows:
    ws.append(r)

head_font = Font(bold=True, color="FFFFFF")
head_fill = PatternFill("solid", fgColor="4472C4")
thin = Side(style="thin", color="BFBFBF")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
for col in range(1, 6):
    c = ws.cell(row=1, column=col)
    c.font, c.fill, c.border = head_font, head_fill, border
    c.alignment = Alignment(horizontal="center")

for i in range(2, 2 + len(rows)):                    # 金额 = 数量 × 单价
    ws.cell(row=i, column=5, value=f"=C{i}*D{i}").number_format = "#,##0.00"

total_row = 2 + len(rows)
ws.cell(row=total_row, column=4, value="合计").font = Font(bold=True)
ws.cell(row=total_row, column=5, value=f"=SUM(E2:E{total_row-1})").number_format = "#,##0.00"

for col, width in zip("ABCDE", (12, 10, 8, 10, 12)):
    ws.column_dimensions[col].width = width
wb.save(OUT)
print("已写入:", OUT, OUT.stat().st_size, "字节")

真实输出:

已写入: /tmp/python_book/scratch/13/sales.xlsx 5358 字节

三个要点:

  • ws.append(可迭代) 追加整行,比逐格 ws["A1"] = ... 简洁得多,且自动向下推进。
  • 样式是「单元格级」对象:Font / PatternFill / Border 都设在 cell 上,不是设在行列上。想让整列都有格式,得循环写每个 cell。
  • 公式就是字符串:"=C2*D2" 原样写进去,由 Excel 打开时计算。number_format 只管显示格式(千分位、两位小数),不改值。

13.2.3 openpyxl 读 Excel:公式不计算这个坑

把刚写的文件读回来。这是 openpyxl 最大的认知陷阱:

from openpyxl import load_workbook

wb = load_workbook("/tmp/python_book/scratch/13/sales.xlsx")
for row in wb["销售明细"].iter_rows(min_row=1, max_row=6, values_only=True):
    print(row)

真实输出:

('日期', '产品', '数量', '单价', '金额')
('2026-09-01', '笔记本', 3, 6999, '=C2*D2')
('2026-09-02', '键盘', 10, 299, '=C3*D3')
('2026-09-03', '显示器', 2, 1599, '=C4*D4')
('2026-09-04', '键盘', 5, 299, '=C5*D5')
(None, None, None, '合计', '=SUM(E2:E5)')

默认模式读回的是公式字符串,不是计算结果。那加 data_only=True 呢?

wb2 = load_workbook("/tmp/python_book/scratch/13/sales.xlsx", data_only=True)
for row in wb2["销售明细"].iter_rows(min_row=2, max_row=6, values_only=True):
    print(row)

真实输出:

('2026-09-01', '笔记本', 3, 6999, None)
('2026-09-02', '键盘', 10, 299, None)
('2026-09-03', '显示器', 2, 1599, None)
('2026-09-04', '键盘', 5, 299, None)
(None, None, None, '合计', None)

全是 None。原因:data_only=True 读的是文件里缓存的公式结果,而 openpyxl 自己没有计算引擎——它写出的文件从未被 Excel 打开过,所以没有缓存值。这个文件一旦用 Excel / LibreOffice 打开保存一次,data_only=True 就能读到值了。

工程结论:

  • 要「写入后立刻读到计算结果」,别指望 openpyxl。要么用 Excel/LibreOffice 打开保存一次,要么在 Python 里自己算(对简单公式完全可行)。
  • 若只是「报告类」脚本,直接写值而不是写公式反而更稳——值立即可读、跨工具一致,代价是失去 Excel 里的联动重算。
  • 本机未装 LibreOffice(soffice 不存在),无法演示「打开保存后再读」的路径,上面 None 的结果是本机实测。

大文件则必须开只读模式:

from openpyxl import load_workbook

wb3 = load_workbook("/tmp/python_book/scratch/13/sales.xlsx", read_only=True)
count = sum(1 for _ in wb3.active.iter_rows(values_only=True))
print("行数:", count)
wb3.close()                                          # 只读模式必须显式关闭

真实输出:

行数: 6

read_only=True 用流式解析替代把整表建进内存——几万行以上的表,普通模式可能吃掉几百 MB,只读模式则接近常量内存。代价是不能改单元格、随机访问受限,且必须 close() 释放底层 zip 句柄。同理,只写超大表用 write_only=True + ws.append()。

13.2.4 python-docx 生成 Word

python-docx 的 API 是「文档对象树」风格:add_heading / add_paragraph / add_table。

from pathlib import Path
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH

OUT = Path("/tmp/python_book/scratch/13/report.docx")
doc = Document()
doc.add_heading("2026 年第三季度销售报告", level=1)
p = doc.add_paragraph("本报告由 Python 脚本自动生成,数据来源为 sales.xlsx。")
p.runs[0].font.size = Pt(10.5)

doc.add_heading("一、概览", level=2)
doc.add_paragraph("第三季度共录入 4 笔销售记录,覆盖 3 类产品。", style="List Bullet")
doc.add_paragraph("键盘出货量最高(15 件)。", style="List Bullet")

doc.add_heading("二、明细", level=2)
table = doc.add_table(rows=1, cols=4)
table.style = "Light Grid Accent 1"
for i, t in enumerate(["日期", "产品", "数量", "金额"]):
    table.rows[0].cells[i].text = t
for r in [
    ("2026-09-01", "笔记本", "3", "20997.00"),
    ("2026-09-02", "键盘", "10", "2990.00"),
    ("2026-09-03", "显示器", "2", "3198.00"),
    ("2026-09-04", "键盘", "5", "1495.00"),
]:
    cells = table.add_row().cells
    for i, v in enumerate(r):
        cells[i].text = v

doc.add_heading("三、结论", level=2)
doc.add_paragraph("销售额合计 28,680.00 元。")
footer = doc.sections[0].footer.paragraphs[0]
footer.text = "内部资料 · 请勿外传"
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
doc.save(OUT)
print("已写入:", OUT, OUT.stat().st_size, "字节")

真实输出:

已写入: /tmp/python_book/scratch/13/report.docx 37816 字节

读回来验证结构:doc.paragraphs 拿段落(.style.name 是样式名、.text 是文本),doc.tables 拿表格,table.rows[i].cells[j].text 读单元格。实测读回后段落样式依次是 Heading 1 / Normal / Heading 2 / List Bullet …,表格 5 行 × 4 列与写入一致。

注意 style="List Bullet" 用的是 Word 内置样式名——样式名必须和 Word 里完全一致(大小写、空格),写错会抛 KeyError。表格样式 Light Grid Accent 1 同理。

13.2.5 模板占位符替换:小心被拆开的 run

批量生成合同、发票的经典做法是拿一份带占位符的 .docx 模板,替换 {{name}} 之类的标记。这里有个隐蔽的坑:Word 会把一段文字切成多个 run(按拼写检查、格式变化等切分),导致 {{amount}} 在底层被拆成 {{amou + nt}} 两个 run。逐 run 替换就会漏掉它。

def naive_replace(doc, mapping):                     # 逐 run 替换(会漏)
    for p in doc.paragraphs:
        for run in p.runs:
            for k, v in mapping.items():
                if k in run.text:
                    run.text = run.text.replace(k, v)

def replace_in_paragraph(p, mapping):                # 先合并整段再替换
    full = "".join(r.text for r in p.runs)
    if not any(k in full for k in mapping):
        return False
    for k, v in mapping.items():
        full = full.replace(k, v)
    p.runs[0].text = full                            # 结果写回第一个 run
    for r in p.runs[1:]:
        r.text = ""                                  # 其余清空
    return True

构造一个「{{amount}} 被拆开」的模板来对比两种做法:

朴素替换结果: '尊敬的 张三,您的账单金额为 {{amount}} 元,请于 2026-10-31 前支付。'
稳健替换结果: '尊敬的 张三,您的账单金额为 128.00 元,请于 2026-10-31 前支付。'

朴素做法把 {{amount}} 原样漏在了文档里,稳健做法才替换成功。代价是合并后整段变成一个 run,段落内的局部格式(部分加粗、颜色)会丢失——所以这种替换适合「整段同一格式」的模板。若段落内格式复杂,得做更精细的「跨 run 定位 + 只改命中区间」的算法。

13.2.6 pypdf:合并、拆分、提取文本

先说明样本来源:本机没有 reportlab / fpdf,示例 PDF 用 macOS 自带的 cupsfilter p1.txt > p1.pdf 生成(这是 macOS 命令,Linux 读者可用 LibreOffice 的 soffice --convert-to pdf 代替)。下面的合并、拆分、提取都是真跑。

from pathlib import Path
from pypdf import PdfReader, PdfWriter

D = Path("/tmp/python_book/scratch/13")
r1, r2 = PdfReader(D / "p1.pdf"), PdfReader(D / "p2.pdf")
print("p1 页数:", len(r1.pages), "| p2 页数:", len(r2.pages))

writer = PdfWriter()
for r in (r1, r2):
    for pg in r.pages:
        writer.add_page(pg)                          # 逐个页面搬运
with (D / "merged.pdf").open("wb") as f:
    writer.write(f)
print("合并后页数:", len(PdfReader(D / "merged.pdf").pages))

rm = PdfReader(D / "merged.pdf")                     # 拆分:每页一个文件
for i, pg in enumerate(rm.pages, 1):
    sw = PdfWriter()
    sw.add_page(pg)
    with (D / f"split_{i}.pdf").open("wb") as f:
        sw.write(f)

真实输出:

p1 页数: 1 | p2 页数: 1
合并后页数: 2
拆分文件: ['split_1.pdf', 'split_2.pdf']

提取文本:

for name in ("p1.pdf", "p2.pdf"):
    text = PdfReader(D / name).pages[0].extract_text()
    lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()]
    print(name, "->", lines)

真实输出:

p1.pdf -> ['Hello PDF Page One', 'Second line of page one.']
p2.pdf -> ['Hello PDF Page Two', 'This is page two content.']

三个必须知道的限制:

  • extract_text() 只能取「文本层」。扫描件是图片,提取出来是空的——那需要 OCR(如 Tesseract),不在 pypdf 能力范围。
  • 提取顺序不保证等于阅读顺序。多栏排版、文本框、表格里,pypdf 按底层内容流顺序拼,可能串行。别假设「提取的字符串就是人眼看到的顺序」。
  • add_page 是浅搬运:合并时页面对象被引用而非深拷贝,元数据、书签、表单域需要另行处理。要保留书签得手动重建 outline。

PDF 还能加密(writer.encrypt("password"))、旋转(pg.rotate(90))、加元数据,API 都在 PdfWriter 上,思路一致。

延伸阅读

小结

  • 三个库都只吃现代格式(.xlsx/.docx),老格式(.xls/.doc)需先转换;PDF 提取不做 OCR。
  • openpyxl 不计算公式:默认读到公式字符串,data_only=True 读到的是缓存值——文件没被 Excel 打开过就全是 None。报告类脚本建议直接写值。
  • 大表用 read_only=True / write_only=True 流式处理,只读模式必须 close()。
  • python-docx 用内置样式名(List Bullet、Light Grid Accent 1)时拼写必须与 Word 完全一致。
  • 模板替换要先合并段落文本再替换,否则 Word 拆开的 run 会漏掉占位符;代价是段落内局部格式丢失。
  • pypdf 合并/拆分靠 PdfWriter.add_page 逐页搬运,extract_text() 只取文本层且顺序不保证。

到这里,「把文档内容生成出来」的能力齐了。可脚本再强,也得有人在对的时间触发它——凌晨三点跑日报、每小时同步一次数据、开机自动启动。下一节我们把脚本挂到 cron 与 systemd timer 上,并解决重入、日志与幂等。

阅读导航:上一节:文件批处理与目录治理 · 下一节:定时任务与系统集成 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时