批量 Excel 转 PDF:纯 Python 免装 Office,表格样式和中文都不丢

2026-10-05 · 喵窝资源铺

场景:几十上百份 Excel 工资表/对账单/报价单要转 PDF 归档或发客户。手动「另存为 PDF」一个个点太慢;本机没装 Office 或在服务器上跑更麻烦。这篇给一个纯 Python 的批量转换脚本(openpyxl 读 + reportlab 写),整目录批转、每个工作表一页、坏文件和老格式自动跳过。文中代码实跑验证,真实输出贴在下面。

它做的几件事

输入脚本的处理
每个工作表转成一页表格,带表头底色、斑马纹、网格线;表名做小标题
嵌套目录递归找所有 .xlsx,输出平铺为同名 .pdf
坏文件截断/伪造的 .xlsx 报 BadZipFile 跳过,不崩整批、不残留空壳 PDF
老 .xls明确报错跳过并给出原因(openpyxl 不认,见坑三)
空工作簿识别为「无可见数据」跳过,不输出空 PDF
隐藏工作表wb.worksheets 会带上隐藏表——按需过滤(见边界说明)
长表格repeatRows=1 跨页自动重复表头

实跑抓到的坑一:openpyxl 根本不知道「数字格式」这回事

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 前自己格式化,这是这份脚本和「能跑就行」版本的差别。

实跑抓到的坑二:reportlab 加载 Noto CJK 直接报错

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 抽查一眼。

实跑抓到的坑三:openpyxl 不认老 .xls,以及空工作簿存不出来

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 都造不出来(但现实中存在),所以脚本按「无可见数据」分支处理而不是当异常。

脚本全文(excel_to_pdf.py)

#!/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()

实跑输出(5 个测试文件,一个不落)

{
  "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 个文件原样未动。

边界说明(如实)

📄 不想自己跑脚本?把文件发我们,代转好打包发回。
更多实用小工具与脚本服务:喵窝资源铺(9.9 元起,虚拟物品发货后不退款,介意慎拍)· 免费在线小工具

本服务由 AI 完成初稿+人工核验发布。转载请留链接。