资讯动态

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

发布时间:2026/10/23 15:57:24 来源:尧图企业网站定制
写这批“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 扫描件和图片型PDFOCR兜底方案文本层都不存在就只能OCR。OCR不是首选方案因为任何OCR都会引入识别误差尤其是数字0和字母O、8和B、6和G这些易混字符。但扫描件没有选择余地只能把PDF渲染成图片再用识别模型把版面转回文字。OCR工具我用的比较多的是PaddleOCR开源、支持中文、可以本地离线跑识别发票这种印刷体效果相当好。缺点是安装体积大依赖paddlepaddle如果机器没有GPUCPU跑起来稍慢但发票这种单页文档完全能接受。具体做法是先用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 验证环境是否正常装完库别急着写完整脚本先花十秒钟验证一下是否能正常打开PDFimport 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(boldTrue, colorFFFFFF) header_fill PatternFill(solid, fgColor4472C4) header_alignment Alignment(horizontalcenter, verticalcenter) for col, name in enumerate(HEADERS, start1): cell ws.cell(row1, columncol, valuename) cell.font header_font cell.fill header_fill cell.alignment header_alignment for r, row in enumerate(rows, start2): for c, value in enumerate(row, start1): ws.cell(rowr, columnc, valuevalue) ws.freeze_panes A2 wb.save(output_path)这里有两个非常实用的经验。第一税号和发票号码这种长数字在Excel里如果不做处理会被显示成科学计数法。解决办法是写入前把这些字段直接当字符串写入同时把对应单元格的数字格式设为文本for col in (1, 2, 5, 7): # 发票代码、发票号码、购买方税号、销售方税号 for row in ws.iter_rows(min_row2, max_rowws.max_row, min_colcol, max_colcol): 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调高到300OCR效果往往比硬着头皮抽文本好得多。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条重点看税号和价税合计确认没问题再放开全量跑。别嫌这步麻烦它能帮你省掉不少对账翻车的尴尬。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑