本节目标:用 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 三个库的分工与边界
| 库 | 版本 | 擅长 | 关键限制 |
|---|---|---|---|
openpyxl | 3.1.5 | 读写 .xlsx、样式、公式字符串 | 不计算公式;不支持旧版 .xls |
python-docx | 1.2.0 | 生成/读取 .docx、样式、表格 | 不支持 .doc;复杂排版能力有限 |
pypdf | 6.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 上,思路一致。
延伸阅读
- Python 文件 IO 与序列化
——
.xlsx/.docx本质是 zip + XML,理解序列化有助于调试 - Python 数据工程与 ETL —— 表格数据进了脚本之后,如何接进更大的处理管道
小结
- 三个库都只吃现代格式(
.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 上,并解决重入、日志与幂等。
阅读导航:上一节:文件批处理与目录治理 · 下一节:定时任务与系统集成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。