ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python读取发票PDF信息到Excel:从文本提取到OCR兜底的完整方案

Python读取发票PDF信息到Excel:从文本提取到OCR兜底的完整方案

写这批“python 读取发票pdf信息到EXL源码”相关的东西,其实一点都不神秘。你把需求拆开,无非就是三件事:拿到一批PDF格式的电子发票,把票面上那一堆代码、号码、日期、金额、税额、购销方名称抓出来,再按照固定表头写进Excel。听起来简单,真上手做一遍,就会发现坑全藏在细节里——有的PDF是文本型,有的压根就是一张扫描图,新版发票排版还跟老版不一样,金额出现了全角字符,税号写进Excel直接变科学计数法。这篇文章我就围绕这套自动化脚本,把环境选型、字段定位、OCR兜底、异常兜底、批量稳定的完整思路写清楚。适合想用Python解放手工操作的财务小伙伴、刚接触自动化脚本的开发者,以及任何常被电子发票对账折磨的人参考。

1. 先搞明白你手里的PDF是哪一类

拿到一个发票PDF,第一反应不要是“马上写正则”,而是先判断它到底属于哪种类型。这一步决定了后面整条解析路线。我记得第一次做这类工具时,把所有PDF都当成文本型去抽,结果一半文件抽出来全是乱码,后来才发现那些文件是扫描后转成的PDF,压根没有文本层。

发票PDF按内容形式可以分成三种:

类型特征推荐解析方式
文本型PDF能用鼠标选中、复制文字,由电子发票系统直接生成pdfplumber / PyMuPDF 直接抽取文本
图片型PDF每页就是一张扫描图,文字不可选中,可能是拍照或扫描归档渲染成高清图片后走OCR
混合型PDF部分文字可复制,部分关键区域是图片(如盖章、二维码)先抽文本,缺失字段再补OCR

判断方法很简单:用PDF阅读器打开,试着用鼠标拉选一段“购买方名称”。能选中,就是文本型;选不中,基本就是图片型。更严谨的做法是写一行代码验证:

import fitz doc = fitz.open("invoice.pdf") page_text = doc[0].get_text() print(len(page_text.strip()), repr(page_text[:100])) doc.close()

如果第一个页面抽出的纯文本长度不足,就要做好走OCR的准备。这一步判断成本极低,却能把后续方案直接分叉开,属于整个脚本的“总闸门”。

1.1 文本型PDF:可以直接抽字符串的票

文本型PDF是最省事的情况。电子发票系统生成的版式会包含完整的字体嵌入和文本结构,只要用工具抽取,字段基本都在。

我习惯优先上pdfplumber,而不是直接拿PyMuPDF跑。pdfplumber底层把页面当成一组字符、线条和矩形的集合,它有一个很好用的extract_text()方法,会按照阅读顺序把页面文本拼接成字符串。对于发票这种标签和值基本不跨行的版式,这个方法非常可靠。PyMuPDF同样能抽取文本,速度还要快不少,但它返回的内容更多是“按块排列”,有时候标签和值不在同一个文本块里,反而要自己额外拼一次。

pdfplumber另一个优势是能拿到字符坐标和表格线信息。遇到一张页面里有独立表格区域的发票,可以用page.extract_table()直接把表格结构还原出来,后面再做字段映射会轻松很多。所以我的推荐是:常规文本抽取、表格抽取用pdfplumber,大批量快速预筛时用PyMuPDF。

1.2 扫描件和图片型PDF:OCR兜底方案

文本层都不存在,就只能OCR。OCR不是首选方案,因为任何OCR都会引入识别误差,尤其是数字0和字母O、8和B、6和G这些易混字符。但扫描件没有选择余地,只能把PDF渲染成图片,再用识别模型把版面转回文字。

OCR工具我用的比较多的是PaddleOCR,开源、支持中文、可以本地离线跑,识别发票这种印刷体效果相当好。缺点是安装体积大,依赖paddlepaddle;如果机器没有GPU,CPU跑起来稍慢,但发票这种单页文档完全能接受。

具体做法是先用PyMuPDF把每一页渲染成PNG图片,再把图片交给OCR识别,最后把识别结果按行合并成一段文本,继续走后面同样的字段提取逻辑。这一步的好处是,字段提取规则和文本来源解耦了:不管文本是从PDF里直接来的,还是从OCR来的,到“正则定位字段”那一步可以复用同一套代码。

2. 环境搭建:Windows和macOS都能跑的依赖方案

2.1 核心库选型与安装

这套自动化脚本最少需要三个库:PDF解析用pdfplumber,渲染图片用PyMuPDF,写Excel用openpyxl。如果要用OCR,再加PaddleOCR套件。

pip install pdfplumber pymupdf openpyxl # OCR按需安装,CPU环境直接用默认版本 pip install paddlepaddle paddleocr

安装时踩过的坑主要有两个。第一个是paddleocr会自动带上一批依赖,网速不好的时候容易超时,建议用国内镜像源装。第二个是paddlepaddle的GPU版本和CPU版本不要装混,普通办公电脑纯CPU就够用了,别去折腾CUDA。

Python版本建议3.8以上。pdfplumber本身对版本要求不高,但PaddleOCR新版本已经在往3.x的API上靠,太老的Python版本会出现兼容问题。另外,项目路径里尽量不要带中文和空格。我见过不少同事在Windows上因为“我的文档”这个路径搞出各种莫名编码问题,最终统一改成纯英文目录才消停。

2.2 验证环境是否正常

装完库别急着写完整脚本,先花十秒钟验证一下是否能正常打开PDF:

import pdfplumber with pdfplumber.open("sample_invoice.pdf") as pdf: print("页数:", len(pdf.pages)) page = pdf.pages[0] text = page.extract_text() print(text[:200])

这里有个很常见的报错:如果你打开的是加密PDF,会提示“file has not been decrypted”。不少企业财务系统下载的发票PDF会带一层空口令保护,用pdfplumber直接打不开。这时候可以先预处理解密,再把文件交给解析流程。可以用的库不少,操作也不复杂,但你要知道有这层问题存在,免得卡在启动阶段。

3. 核心源码:从PDF到Excel的完整流水线

3.1 定义发票字段和数据结构

开始写提取逻辑前,我强烈建议先把字段结构定下来。不要写一行抓一个字段、东拼西凑到最后写Excel时发现少了一个列又回头补。我先定义一个data class,把所有可能用到的字段列清楚:

from dataclasses import dataclass @dataclass class InvoiceInfo: invoice_code: str = "" # 发票代码 invoice_no: str = "" # 发票号码 invoice_date: str = "" # 开票日期 buyer_name: str = "" # 购买方名称 buyer_tax_id: str = "" # 购买方税号 seller_name: str = "" # 销售方名称 seller_tax_id: str = "" # 销售方税号 amount: str = "" # 金额(不含税) tax: str = "" # 税额 total: str = "" # 价税合计 source_pdf: str = "" # 来源PDF路径 status: str = "OK" # 解析状态 remark: str = "" # 备注

为什么要这么早定义结构?因为后面写正则、写Excel、写校验、写日志,全都要围绕这组字段展开。字段一旦固定,后续扩展版式时只需要补充解析规则,不需要大幅度改动数据出口。

3.2 定位关键标签再提字段,别全文硬匹配

文本抽取出来后,最容易犯的错误是拿一个巨大正则匹配整个PDF文本,试图一次性把“发票号码:12345678”找出来。这样写看似快,实际上一遇到换行、空格、字体嵌入顺序变化就翻车。

我的方法是“锚点定位”:先找标签,再取标签后面的值。发票版式虽然五花八门,但标签相对固定,比如“发票号码”“开票日期”“价税合计(小写)”这些文字基本不会变。找到锚点,后面的值自然就跑不了。

import re def grab_value(text: str, label: str, pattern: str, default: str = "") -> str: # 把标签部分从文本里抠出来,再在标签后面找值 m = re.search(label + r"[::\s]*" + pattern, text) return m.group(1).strip() if m else default

具体字段提取可以写几个独立函数:

def extract_base_info(text: str): inv = InvoiceInfo() inv.invoice_no = grab_value(text, "发票号码", r"([0-9]{8,20})") inv.invoice_code = grab_value(text, "发票代码", r"([0-9]{10,12})") inv.invoice_date = grab_value(text, "开票日期", r"([0-9]{4}年[0-9]{1,2}月[0-9]{1,2}日|[0-9]{4}-[0-9]{2}-[0-9]{2})") return inv

这里有个细节需要注意:新版电子发票已经取消了“发票代码”,只有20位发票号码。所以写入Excel时,发票代码允许为空,发票号码的位数要兼容8位、10位、20位等多种情况,不要写死。

购方和销方名称的提取稍微复杂一点,因为名称可能跨行。我习惯把文本先按行切分,再找“名称”这一行,把冒号后面的内容当作值。如果名称过长,冒号后面的内容会被截断,那就得用下一行补齐。所以在写提取函数时,通常要结合行列表来处理,而不是一行正则搞定。

3.3 金额校验:不要相信第一次抽出来的数字

金额是三组数:金额(不含税)、税额、价税合计。它们之间存在一个关系:价税合计 = 金额 + 税额。这个关系就是最好的校验器。

我见过很多自动化工具把金额抽出来就直接写Excel,完全不校验,结果月底对账发现几百块钱对不上。后来排查发现,问题出在PDF文本里“金额小写”后面有换行、全角逗号、或者像“¥1200.00”这种带货币符号的场景。正则抽取时取到了一截不完整的数字。

稳妥做法是:不要对整个页面做一次模糊匹配,要针对“价税合计(小写)”这个锚点取数字,然后单独解析金额区域。写个小工具来做校验:

def validate_amount(inv: InvoiceInfo) -> bool: try: amount = float(inv.amount) tax = float(inv.tax) total = float(inv.total) except ValueError: return False return abs((amount + tax) - total) < 0.01

凡是校验不通过的,不要硬塞进Excel,直接把状态标记为“金额异常”,备注里写上具体原因。这样后续人工复核时能按状态列快速筛选,效率远高于在一堆数据里大海捞针。

处理金额文本时,有几个字符上的坑。全角数字要先转半角,¥符号要剥掉,千分位逗号要还原。但要注意,不能一股脑把所有逗号替换成空字符串,否则“1,200.50”会变成“1200.50”还好,遇到“12,345.67”替换成“12345.67”也没事,但一些OCR会把小数点识别成逗号,这时候就要靠校验逻辑来兜底了。

3.4 写入Excel:表头、样式、失败标记

数据都提取完,最后一步是落到Excel。用openpyxl控制力比较强,能设置表头样式、列宽、冻结窗口、数字格式。

写入前先定义表头:

HEADERS = [ "发票代码", "发票号码", "开票日期", "购买方名称", "购买方税号", "销售方名称", "销售方税号", "金额", "税额", "价税合计", "来源文件", "状态", "备注" ]

然后写一个写入函数:

from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill from openpyxl.utils import get_column_letter def write_excel(rows, output_path): wb = Workbook() ws = wb.active ws.title = "发票台账" header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill("solid", fgColor="4472C4") header_alignment = Alignment(horizontal="center", vertical="center") for col, name in enumerate(HEADERS, start=1): cell = ws.cell(row=1, column=col, value=name) cell.font = header_font cell.fill = header_fill cell.alignment = header_alignment for r, row in enumerate(rows, start=2): for c, value in enumerate(row, start=1): ws.cell(row=r, column=c, value=value) ws.freeze_panes = "A2" wb.save(output_path)

这里有两个非常实用的经验。第一,税号和发票号码这种长数字,在Excel里如果不做处理,会被显示成科学计数法。解决办法是写入前把这些字段直接当字符串写入,同时把对应单元格的数字格式设为文本:

for col in (1, 2, 5, 7): # 发票代码、发票号码、购买方税号、销售方税号 for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=col, max_col=col): for cell in row: cell.number_format = "@"

第二,解析失败的文件也要在Excel里留一行,状态列写“FAIL”,备注列写异常原因。这样批量任务处理完,直接按状态列筛一遍就知道哪些需要人工处理,不用回去翻日志。

4. 批量实操中最常见的四个坑

4.1 一页里有多张发票怎么拆

这个坑主要出现在扫描归档场景,一本纸质发票被扫描成一个PDF,一页A4纸上左右并排或上下排列两张发票。如果整体抽取文本,两张票的文字会交错混在一起,字段根本对不上。

解决办法是用坐标裁剪。pdfplumber可以按指定区域截取页面的一部分:

with pdfplumber.open(pdf_path) as pdf: page = pdf.pages[0] left = page.crop((0, 0, page.width / 2, page.height)) right = page.crop((page.width / 2, 0, page.width, page.height)) left_text = left.extract_text() right_text = right.extract_text()

更聪明的做法是,先抽取整个页面的文本,统计“发票号码”这个锚点出现的次数。如果出现两次,就自动触发切分逻辑,左右或上下各试一遍。电子发票正常一页只有一张,这种情况不多,但自动化脚本一旦遇到,不能直接报错退出,要能自动处理。

4.2 中文乱码和字体问题

文本型PDF偶尔也会翻车,主要原因是PDF内嵌的字体子集缺少了Unicode映射,pdfplumber抽出来的是CID码或者一堆乱码。现象就是文本长度看着正常,但内容完全不可读。

碰到这种情况,我的排查顺序是先换PyMuPDF再抽一次。PyMuPDF处理字体映射的能力比pdfplumber强一点,有时候同样的页面,它能抽到正常中文。如果还是乱码,那就别折腾了,直接渲染成图片走OCR。判断乱码有个土办法:检查抽取文本里可读字符的比例,如果大部分是中文字符乱码或者大量出现\ufffd替换字符,就自动切换OCR分支。

还有一种情况是票面字体太特殊,比如一些老版发票用了很小的嵌入字体,pdfplumber会丢字。这时候把渲染DPI调高到300,OCR效果往往比硬着头皮抽文本好得多。

4.3 OCR把8认成B,怎么拦截

OCR识别看错是常态,尤其扫描件清晰度不够时,“8”和“B”、“0”和“O”、“5”和“S”很容易混。既然无法完全避免,就先想好容错策略。

发票上的结构化字段都有规则可循:发票号码全是数字,统一税号是15到20位数字和字母组合,而且里面不包含I、O、Z等容易混淆的字符。所以对OCR结果可以做一个白名单清洗:

def clean_tax_no(raw: str) -> str: raw = raw.upper() cleaned = re.sub(r"[^0-9A-Z]", "", raw) return cleaned

清洗后的结果如果包含明显不该出现的字母,可以打上“OCR可疑”标记,塞进状态列或备注列。不要直接丢弃,也不要直接认定错误,更不要盲目“纠正”成数字。税务数据字段错一个字符,后续对账就是大麻烦。我个人的经验是:OCR只用来辅助提取,凡是结果里出现了字母或特殊符号的字段,一律进人工复核清单。

4.4 单个文件异常拖垮整个批量任务

批量处理300个PDF的时候,最怕的就是第17个文件损坏导致脚本直接崩溃,前面16个的结果全部作废。这个问题必须从架构层面规避。

核心思路是:一个文件一个函数,异常隔离在循环体外,失败文件单独记录,绝不中断整个批处理流程。

from pathlib import Path import traceback def process_one(pdf_path: Path, ocr_flag: bool) -> InvoiceInfo: inv = InvoiceInfo() inv.source_pdf = str(pdf_path) try: text = extract_text_from_pdf(pdf_path, ocr_flag) inv = parse_invoice(text) inv.status = "OK" except Exception as e: inv.status = "FAIL" inv.remark = str(e) traceback.print_exc() return inv

主循环里就一句话:

results = [] for pdf_file in sorted(Path(input_dir).rglob("*.pdf")): results.append(process_one(pdf_file, use_ocr))

这样任何一个文件报错都不会影响其他文件。最后统一写Excel时,状态列一眼就能看出哪些成功、哪些失败。真正的稳定不在于代码一次不报错,而在于报错后系统依然能把其他任务跑完,并且把失败原因留给后续处理。

5. 从“能跑”到“稳定跑”:工程化改造建议

如果这套脚本只是自己临时用一两次,那第四章的版本已经够了。但如果你要把它放在财务部门、每周固定跑一次,那就还要做几个工程化层面的改造。

5.1 日志和失败隔离是第一批要做的事

我的习惯是加一个logging配置,每天一份日志文件,记录每条PDF的开始时间、解析耗时、状态、失败原因。不要只在控制台print,因为Windows控制台编码时灵时不灵,把日志写到UTF-8文件里才是最稳的。

日志的价值在“复盘”。这一批500个文件跑完,哪些格式经常失败,点击日志按状态筛一下就能看到趋势。如果连续多次都卡在同一个版式上,说明要针对那个版式加解析规则,而不是每次都人工补救。

5.2 把字段配置和代码分开

发票字段映射、输入目录、输出Excel路径、是否启用OCR这些配置,不应该硬编码在Python文件里。抽出来放到config.json里,换一批数据、换一套模板时,只改配置不改代码。

{ "input_dir": "./pdfs", "output_path": "./out/invoice.xlsx", "use_ocr": false, "ocr_dpi": 200, "fields": [ "invoice_code", "invoice_no", "invoice_date", "buyer_name", "seller_name", "amount", "tax", "total" ] }

这样做还有一个额外好处:同事拿到脚本时,不需要读代码就知道怎么用,改一下JSON就能开工。对非程序员使用者友好很多。

5.3 增量处理与去重

有些场景是同一个文件夹里不断有新PDF投进来,脚本要重复跑。如果不做去重,每次跑都会把历史文件再解析一遍,Excel里产生一堆重复行。

去重思路可以很简单:计算每个PDF文件的MD5,把已经处理过的哈希值存在一个txt或SQLite里。每次批量任务开始前,先算一遍所有文件的哈希,跳过已存在的。

import hashlib def file_md5(path: Path): h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest()

如果是超大文件,不要一次读入内存,上面这种分块读法更稳妥。哈希去重还能顺便避免“同一个文件被复制改名”造成重复解析的问题。

5.4 大批量的性能优化

如果发票文件数量很大,瓶颈主要集中在两个地方:PDF解析本身和OCR识别。文本型PDF用pdfplumber逐页解析比较慢,可以先让PyMuPDF快速抽一遍,只有抽不出文本或文本异常时才切换到pdfplumber或OCR,这样能省下不少时间。

OCR场景下,PaddleOCR模型加载一次成本很高,不要每个文件都重新加载一遍。正确的做法是全局初始化一次,循环里只调用识别接口。

多文件并行也可以考虑,用Python的concurrent.futures.ProcessPoolExecutor做多进程,把控CPU核心数。不过要注意,OCR模型自带的内存占用不低,进程数开太多可能导致内存不足,一般设成CPU核心数减一比较稳。并行度不是越大越好,稳定第一。

最后再分享一点我个人的体会。第一次做发票PDF转Excel时,别想着一次性兼容所有版式。先把你手里数量最多的那种发票跑通,拿到一份完整结果,再在代码里留好正则分支和字段映射的扩展位,等遇到新模板时补规则就行。票据类数据有一个特点——格式再变,关键锚点不会乱变。只要把“锚点定位、金额校验、失败隔离”这三个习惯刻进代码,后面扩展版式基本就是加正则的事。输出Excel之后先抽检30条,重点看税号和价税合计,确认没问题再放开全量跑。别嫌这步麻烦,它能帮你省掉不少对账翻车的尴尬。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进