2026-10-05 · 喵窝资源铺
场景:几十上百份 Excel 工资表/对账单/报价单要转 PDF 归档或发客户。手动「另存为 PDF」一个个点太慢;本机没装 Office 或在服务器上跑更麻烦。这篇给一个纯 Python 的批量转换脚本(openpyxl 读 + reportlab 写),整目录批转、每个工作表一页、坏文件和老格式自动跳过。文中代码实跑验证,真实输出贴在下面。
| 输入 | 脚本的处理 |
|---|---|
| 每个工作表 | 转成一页表格,带表头底色、斑马纹、网格线;表名做小标题 |
| 嵌套目录 | 递归找所有 .xlsx,输出平铺为同名 .pdf |
| 坏文件 | 截断/伪造的 .xlsx 报 BadZipFile 跳过,不崩整批、不残留空壳 PDF |
| 老 .xls | 明确报错跳过并给出原因(openpyxl 不认,见坑三) |
| 空工作簿 | 识别为「无可见数据」跳过,不输出空 PDF |
| 隐藏工作表 | wb.worksheets 会带上隐藏表——按需过滤(见边界说明) |
| 长表格 | repeatRows=1 跨页自动重复表头 |
Excel 里C2设置了千分位格式显示 15,000.50,但cell.value读出来就是裸数字15000.5。number_format只是显示层的装饰,openpyxl 不会帮你应用它。
实测:Excel 里格式化好的工资列,转出的 PDF 里变成 15000.5,客户看到这种数字会怀疑你算错了。解决:写 PDF 前自己对数字做格式化,比如:
from openpyxl.utils_cell import get_column_letter # 无关,只是提示别指望 openpyxl 替你格式化
def fmt(v):
if isinstance(v, float) and v == int(v):
return f"{int(v):,}" # 9820.0 → 9,820
if isinstance(v, float):
return f"{v:,.2f}" # 15000.5 → 15,000.50
return str(v)
或者读 cell.number_format 字符串自己解析(工作量大,一般不值得)。结论:金额列一定要在转 PDF 前自己格式化,这是这份脚本和「能跑就行」版本的差别。
TTFont("cjk", "NotoSansCJK-Regular.ttc")→TTFError: postscript outlines are not supported。Noto CJK 官方发行版是 CFF/PostScript 轮廓,reportlab 的 TTF 引擎只吃 TrueType 轮廓。
Linux 上能直接用的替代:文泉驿(wqy-zenhei.ttc / wqy-microhei.ttc,TrueType 轮廓,apt install fonts-wqy-zenhei)。另外 Windows 用 msyh.ttc、macOS 用 PingFang.ttc,加载时记得 subfontIndex=0。不注册中文字体的另一个坑:不报错,但所有中文渲染成方块——交付前一定打开 PDF 抽查一眼。
1)InvalidFileException: openpyxl does not support the old .xls file format——2003 老格式要xlrd或先转 .xlsx。批转脚本里遇到 .xls 报清晰错误跳过,别让它崩掉整批。
2)Workbook()删光所有 sheet 再save()→IndexError: At least one sheet must be visible——连 0 个工作表的空工作簿 openpyxl 都造不出来(但现实中存在),所以脚本按「无可见数据」分支处理而不是当异常。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 批量 Excel→PDF:纯 Python,免装 Office。依赖: pip install openpyxl reportlab
import sys, os, glob, json
from openpyxl import load_workbook
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib import colors
FONT_NAME = None
for cand in ["/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"C:/Windows/Fonts/msyh.ttc",
"/System/Library/Fonts/PingFang.ttc"]:
if os.path.exists(cand):
pdfmetrics.registerFont(TTFont("cjk", cand, subfontIndex=0))
FONT_NAME = "cjk"; break
if FONT_NAME is None:
FONT_NAME = "Helvetica" # 中文会变方块——见坑二
STYLE = ParagraphStyle("cell", fontName=FONT_NAME, fontSize=9, leading=12)
def sheet_rows(ws, max_cols=40, max_rows=500):
rows = []
for row in ws.iter_rows(min_row=1, max_row=min(ws.max_row, max_rows),
max_col=min(ws.max_column, max_cols), values_only=True):
rows.append(["" if v is None else str(v) for v in row])
while rows and all(c == "" for c in rows[-1]):
rows.pop() # 去尾部全空行
return rows
def xlsx_to_pdf(path, out_path):
wb = load_workbook(path, data_only=True, read_only=True)
story = []
for ws in wb.worksheets: # 注意:包含隐藏表,需要时用 ws.sheet_state == "visible" 过滤
rows = sheet_rows(ws)
if not rows: continue
story.append(Paragraph(f"<b>{ws.title}</b>",
ParagraphStyle("t", parent=STYLE, fontSize=13, leading=16)))
story.append(Spacer(1, 4 * mm))
table = Table([[Paragraph(c, STYLE) for c in r] for r in rows], repeatRows=1)
table.setStyle(TableStyle([
("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8F0FE")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1),
[colors.white, colors.HexColor("#FAFAFA")]),
]))
story += [table, Spacer(1, 10 * mm)]
wb.close()
if not story:
return 0 # 空工作簿:无可写内容
doc = SimpleDocTemplate(out_path, pagesize=landscape(A4),
leftMargin=15*mm, rightMargin=15*mm,
topMargin=15*mm, bottomMargin=15*mm)
doc.build(story)
return 1
def main():
src, dst = sys.argv[1], sys.argv[2]
os.makedirs(dst, exist_ok=True)
ok, skipped = [], []
for path in sorted(glob.glob(os.path.join(src, "**", "*.xls*"), recursive=True)):
name = os.path.basename(path)
if name.startswith("~$"): continue
out_path = os.path.join(dst, os.path.splitext(name)[0] + ".pdf")
try:
n = xlsx_to_pdf(path, out_path)
(ok if n else skipped).append(
n and (name, out_path) or (name, "无可见数据(空工作簿)"))
except Exception as e:
skipped.append((name, f"{type(e).__name__}: {e}"))
if os.path.exists(out_path):
os.remove(out_path) # 失败不残留空壳
print(json.dumps({"converted": [o[0] for o in ok],
"skipped": skipped}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
{
"converted": ["hidden.xlsx", "normal.xlsx"],
"skipped": [
["bad.xlsx", "BadZipFile: File is not a zip file"],
["empty.xlsx", "无可见数据(空工作簿)"],
["old.xls", "InvalidFileException: openpyxl does not support the old
.xls file format, please use xlrd..."]
]
}
读回复核(pypdf 提取文本):normal.pdf 1 页,「工资表/张三/李四/9820/2026-09」全部命中——但「15,000.50」不在,PDF 里是裸的 15000.5,坑一是真的。转换后输入目录 5 个文件原样未动。
data_only=True 取「上次保存时的计算结果」;没被 Excel 打开保存过的公式单元格会读到 Noneif ws.sheet_state != "visible": continuesheet_rows 参数里调本服务由 AI 完成初稿+人工核验发布。转载请留链接。