1. 为什么你需要自己解析电子发票二维码最近在做一个财务自动化的小工具需要批量处理几百张电子发票PDF把里面的关键信息比如发票代码、号码、金额、日期自动提取出来存到数据库里。一开始我想这还不简单直接用OCR识别PDF上的文字不就行了结果踩了个大坑。我发现PDF里的文字虽然是可选的但很多电子发票的PDF其文字层和视觉层对不上或者字体嵌入有问题直接用PDF文本提取工具比如pdfplumber或PyPDF2读出来的金额、日期经常是乱的或者干脆读不出来。更头疼的是发票上的购买方、销售方名称如果OCR识别稍有偏差后续对账就是灾难。这时候我才把目光转向了发票左上角那个小小的二维码。你肯定也用过微信或支付宝“扫一扫”发票二维码它能瞬间把发票信息结构化地展示出来非常准确。这启发了我二维码里其实已经包含了发票最核心、最规范的结构化数据。如果我能直接从PDF里把这个二维码图片“挖”出来然后解码不就绕开了OCR识别不准的难题直接拿到了准确的数据源吗这个思路一打开事情就清晰了。整个过程可以拆解成两步第一步从PDF文件里精准地找到并提取出二维码图片第二步对这张二维码图片进行解码把那一串用逗号分隔的“神秘代码”解析成我们需要的字段。下面我就把自己实战中的步骤、用的工具、遇到的坑以及解决方案毫无保留地分享给你。即使你之前没怎么处理过PDF或二维码跟着做也能搞定。2. 实战第一步从PDF中精准捕获二维码图片电子发票的PDF看起来是一张完整的图片但其实在内部它是由很多元素“堆叠”起来的文本、图片、矢量图形等等。那个二维码本质上就是嵌入在PDF里的一张图片。我们的第一个目标就是把它“揪”出来。2.1 选择合适的PDF解析库工欲善其事必先利其器。在Python生态里有几个常用的PDF处理库PyPDF2 / PyPDF4 名气大但主要用于文本提取、合并、拆分等对于精准提取和操作图像对象功能比较弱不太适合我们这个场景。pdfplumber 功能强大擅长提取文本、表格和位置信息也可以获取图片。但它更侧重于基于页面视觉的分析对于直接访问原始的PDF图像资源不如下面这个库直接。PyMuPDF (fitz)这是我最终选择并强烈推荐的库。它是对MuPDF库的Python绑定速度极快而且提供了非常底层的访问接口可以让我们直接枚举PDF中的所有图像对象获取其原始数据操作起来非常灵活。所以我们这次就用PyMuPDF。安装很简单pip install pymupdf注意导入时要用import fitz这是它的传统模块名。2.2 定位并提取二维码图片拿到一个PDF我们怎么知道哪个图片是二维码呢一个最直观的判断是在标准的增值税电子普通发票上二维码通常位于左上角并且是页面上的第一个图像对象因为PDF内容的绘制通常是有顺序的。我们可以利用这个特性。下面是一个详细的代码示例我加了很多注释方便你理解每一步在干嘛import fitz # PyMuPDF from PIL import Image import io def extract_qrcode_image_from_pdf(pdf_path): 从电子发票PDF中提取二维码图片。 参数: pdf_path: PDF文件的路径 返回: 一个PIL.Image.Image对象即二维码图片。如果没找到返回None。 # 打开PDF文档 doc fitz.open(pdf_path) # 通常发票信息在第一页 page doc[0] # 获取页面中的所有图像信息 # get_images 方法返回一个列表每个元素是一个元组包含了图像的引用信息 image_list page.get_images() if not image_list: print(警告在该PDF页面中未找到任何图像。) doc.close() return None print(f在本页共找到 {len(image_list)} 张图片。) # 假设二维码是第一张图片根据常见版式 # 元组的第一项是图片的引用索引(xref) xref image_list[0][0] # 通过xref获取图片的原始字节数据 base_image doc.extract_image(xref) if not base_image: print(错误无法提取图像数据。) doc.close() return None image_bytes base_image[image] # 图像的二进制数据 image_ext base_image[ext] # 图像格式如 jpeg, png, bmp # 将二进制数据转换为PIL Image对象方便后续处理 pil_image Image.open(io.BytesIO(image_bytes)) print(f成功提取图片格式为{image_ext} 图片尺寸{pil_image.size}) doc.close() return pil_image # 使用示例 pdf_file 你的电子发票.pdf qrcode_image extract_qrcode_image_from_pdf(pdf_file) if qrcode_image: # 可以保存下来看看是不是二维码 qrcode_image.save(extracted_qrcode.png) print(二维码图片已保存为 extracted_qrcode.png) # 也可以直接显示如果在Jupyter Notebook中 # qrcode_image.show()这段代码干了啥用fitz.open打开PDF。获取第一页doc[0]因为发票关键信息都在首页。page.get_images()拿到这一页里所有图片的引用列表。我们假设第一张图片image_list[0]就是二维码。在绝大多数标准电子发票PDF中这个假设都成立。用doc.extract_image(xref)根据图片引用提取出图片的二进制数据和格式。最后用PIL库Pillow把二进制数据加载成一个内存中的图片对象这样我们就可以用它进行下一步的解码了。可能会遇到的坑图片顺序不对怎么办如果有些非标PDF的二维码不是第一张图你可以打印出image_list的长度然后手动把提取出来的图片都保存下来用眼睛找找哪个是二维码。更工程化的做法是遍历所有图片根据图片的尺寸二维码通常是正方形且大小固定在一个范围、位置通过page.get_images(fullTrue)可以获取更详细的信息包括位置等特征进行筛选。提取出来的图片格式可能是jpeg、png甚至bmp但不用担心PIL库都能很好地处理。3. 实战第二步解码二维码破解信息密文好了现在我们已经把二维码图片“捕获”到内存里了。接下来就是像扫码软件一样解读这张图片里的信息。3.1 选择二维码解码库在Python里qrcode库是用来生成二维码的解码我们得用另一个强大的库opencv-python配合pyzbar。pyzbar是ZBar条形码/二维码扫描库的Python封装识别速度快准确率高。安装它们pip install opencv-python-headless pyzbar这里安装的是opencv-python-headless它不包含GUI功能如显示图片的窗口体积更小更适合服务器环境。如果你需要在本地调试显示图片可以安装完整的opencv-python。3.2 编写解码函数并理解输出解码过程比提取图片更简单几行代码就能搞定import cv2 from pyzbar.pyzbar import decode import numpy as np def decode_qrcode_from_image(pil_image): 解码PIL Image对象中的二维码。 参数: pil_image: 一个PIL.Image.Image对象包含二维码。 返回: 解码后的字符串。如果解码失败返回None。 # 将PIL Image转换为OpenCV格式 (numpy数组) # PIL是RGB格式OpenCV是BGR格式需要转换 opencv_image cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) # 尝试解码 decoded_objects decode(opencv_image) if not decoded_objects: print(错误未能从图片中解码出任何二维码信息。) # 可以尝试一些预处理比如灰度化、二值化、调整大小等提高识别率 # gray cv2.cvtColor(opencv_image, cv2.COLOR_BGR2GRAY) # decoded_objects decode(gray) return None # 通常一个图片只有一个二维码 qrcode_data decoded_objects[0].data.decode(utf-8) print(f二维码解码成功原始数据{qrcode_data}) return qrcode_data # 接续上一步的代码 if qrcode_image: raw_text decode_qrcode_from_image(qrcode_image) if raw_text: print(f解码得到的原始字符串是{raw_text})运行成功后你会得到类似这样的一串字符01,10,3702221130,04800054,888.8,20230517,68893455791285901076,727C恭喜你这就是核心数据但这串看着像“天书”的字符到底是什么意思呢别急我们马上来拆解。3.3 深度解析二维码文本字段全解这串用逗号分隔的文本是国家税务总局制定的标准格式。每一段都有固定的含义。我结合官方文档和自己的测试给你整理了一个完整的字段解析表字段位置示例值字段名称详细说明与注意事项第1位01版本号/固定标识目前见到的基本都是01可以理解为数据格式的版本。第2位10发票种类代码这是非常重要的一个字段它决定了后续字段的解析逻辑。10代表“增值税电子普通发票”。常见的还有04增值税普通发票、01增值税专用发票、08增值税电子专用发票。第3位3702221130发票代码这是发票的“身份号”前段一般是10位或12位数字。第4位04800054发票号码这是发票的“身份号”后段一般是8位数字。发票代码发票号码才能唯一确定一张发票。第5位888.8开票金额不含税注意这里是不含税的金额。如果需要价税合计需要结合税率自己计算。金额可能是整数也可能带小数。第6位20230517开票日期格式是YYYYMMDD。示例表示2023年5月17日。需要你手动把它解析成日期对象。第7位68893455791285901076发票校验码后6位有时是后5位是校验码用于防伪和查验。注意增值税专用发票代码01和部分老式发票这个位置可能是空的。第8位727C加密版本/随机数最后一位可能是加密版本号或一个随机字符串用于安全性控制。长度不固定。了解每个字段的含义后我们就可以写一个解析函数把这串原始文本变成结构化的字典数据用起来就方便多了。def parse_invoice_qrcode_data(raw_data): 解析二维码解码后的原始字符串。 参数: raw_data: 类似 01,10,3702221130,04800054,888.8,20230517,68893455791285901076,727C 的字符串。 返回: 一个包含解析后字段的字典。 # 按逗号分割 parts raw_data.split(,) if len(parts) 8: print(f警告原始数据字段数不足8个当前为{len(parts)}个。数据{raw_data}) # 可以根据实际情况进行容错处理这里简单返回None return None invoice_info { version: parts[0], invoice_type_code: parts[1], invoice_code: parts[2], invoice_number: parts[3], amount_excluding_tax: parts[4], # 注意这里是字符串 invoice_date: parts[5], # 注意这里是字符串 check_code: parts[6], encryption_version: parts[7] } # 添加一些友好的映射和转换 type_map { 01: 增值税专用发票, 04: 增值税普通发票, 10: 增值税电子普通发票, 08: 增值税电子专用发票, # 可以继续补充其他类型 } invoice_info[invoice_type_name] type_map.get(invoice_info[invoice_type_code], 未知类型) # 尝试将金额转换为浮点数 try: invoice_info[amount_excluding_tax_float] float(invoice_info[amount_excluding_tax]) except ValueError: invoice_info[amount_excluding_tax_float] 0.0 print(f警告金额字段解析失败{invoice_info[amount_excluding_tax]}) # 尝试解析日期 date_str invoice_info[invoice_date] try: from datetime import datetime # 格式化为 YYYY-MM-DD parsed_date datetime.strptime(date_str, %Y%m%d) invoice_info[invoice_date_formatted] parsed_date.strftime(%Y-%m-%d) except ValueError: invoice_info[invoice_date_formatted] 日期解析失败 print(f警告日期字段解析失败{date_str}) return invoice_info # 使用示例 if raw_text: parsed_info parse_invoice_qrcode_data(raw_text) if parsed_info: print(\n 解析后的发票信息 ) for key, value in parsed_info.items(): print(f{key}: {value})运行这段代码你会得到一个清晰易用的字典像下面这样 解析后的发票信息 version: 01 invoice_type_code: 10 invoice_code: 3702221130 invoice_number: 04800054 amount_excluding_tax: 888.8 invoice_date: 20230517 check_code: 68893455791285901076 encryption_version: 727C invoice_type_name: 增值税电子普通发票 amount_excluding_tax_float: 888.8 invoice_date_formatted: 2023-05-174. 进阶技巧与避坑指南掌握了基本流程已经能解决80%的问题。但在实际批量处理中你肯定会遇到一些“奇葩”的PDF。下面分享几个我踩过坑后总结的进阶技巧。4.1 处理“不听话”的PDF二维码不在第一张图前面我们假设二维码是第一张图但总有例外。更稳健的方法是编写一个过滤函数。二维码图片通常有这些特征正方形或近似正方形、尺寸在一定范围内比如边长在150到300像素之间、颜色模式可能是二值黑白或灰度。我们可以遍历所有图片用规则筛选def find_qrcode_image_robustly(doc_page): 更稳健地查找二维码图片。 参数: doc_page: fitz.Page对象 返回: 找到的二维码图片的xref如果没找到返回None。 image_list doc_page.get_images(fullTrue) # fullTrue 获取更详细信息 candidate_images [] for img_info in image_list: xref img_info[0] # 提取图片 base_img doc_page.parent.extract_image(xref) if not base_img: continue pil_img Image.open(io.BytesIO(base_img[image])) width, height pil_img.size # 规则1近似正方形 (长宽比接近1:1) ratio max(width, height) / (min(width, height) 1e-5) # 防止除零 is_square_like 0.9 ratio 1.1 # 规则2尺寸在常见二维码范围内 size_ok 100 width 400 and 100 height 400 # 规则3颜色模式二维码通常是黑白或灰度 # 可以计算颜色复杂度简单起见这里检查模式 mode_ok pil_img.mode in [1, L] # 1为二值L为灰度 # 综合打分或判断 if is_square_like and size_ok and mode_ok: candidate_images.append((xref, pil_img)) print(f找到候选二维码图片xref:{xref}, 尺寸:{width}x{height}, 模式:{pil_img.mode}) # 如果有多个候选可以返回第一个或者根据位置如最靠近左上角进一步筛选 if candidate_images: # 简单返回第一个候选 return candidate_images[0][0] else: print(未找到符合二维码特征的图片。) return None在extract_qrcode_image_from_pdf函数中就可以用这个find_qrcode_image_robustly(page)返回的xref来替换之前写死的image_list[0][0]。4.2 解码失败怎么办图像预处理来帮忙有时候图片提取出来了但pyzbar就是解不出来。这可能是图片对比度不够、有噪点、或者尺寸不合适。这时候就需要一点简单的图像预处理def preprocess_image_for_qrcode(opencv_image): 对图像进行预处理提高二维码识别率。 参数: opencv_image: OpenCV格式的图片 (numpy数组) 返回: 处理后的OpenCV图片。 # 1. 转换为灰度图 gray cv2.cvtColor(opencv_image, cv2.COLOR_BGR2GRAY) # 2. 二值化 (自适应阈值效果通常比固定阈值好) # 如果图片光照不均用自适应阈值 binary cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2) # 或者使用OTSU阈值法 # _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 3. 可选降噪中值滤波 denoised cv2.medianBlur(binary, 3) # 轻微滤波避免破坏二维码结构 return denoised # 在 decode_qrcode_from_image 函数中如果直接解码失败可以尝试 def decode_qrcode_robustly(pil_image): opencv_image cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) # 先尝试直接解码 decoded_objects decode(opencv_image) if decoded_objects: return decoded_objects[0].data.decode(utf-8) print(直接解码失败尝试图像预处理后解码...) # 预处理后再试 processed_image preprocess_image_for_qrcode(opencv_image) decoded_objects decode(processed_image) if decoded_objects: return decoded_objects[0].data.decode(utf-8) else: print(预处理后仍解码失败。) return None4.3 构建一个完整的自动化处理脚本把上面的所有步骤串起来再加上异常处理和日志就是一个可以投入生产环境使用的脚本雏形了import fitz from PIL import Image import io import cv2 import numpy as np from pyzbar.pyzbar import decode from datetime import datetime import logging import sys # 配置日志 logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) class InvoiceQRCodeParser: def __init__(self): pass def parse_pdf(self, pdf_path): 主流程解析PDF提取并解码二维码信息 logger.info(f开始处理文件: {pdf_path}) # 1. 提取图片 qr_image self._extract_image(pdf_path) if not qr_image: logger.error(未能从PDF中提取到二维码图片。) return None # 2. 解码 raw_data self._decode_image(qr_image) if not raw_data: logger.error(二维码解码失败。) return None # 3. 解析文本 invoice_info self._parse_raw_data(raw_data) if not invoice_info: logger.error(二维码原始数据解析失败。) return None logger.info(f文件 {pdf_path} 解析成功。) return invoice_info def _extract_image(self, pdf_path): # ... 整合了稳健查找图片的代码 ... doc fitz.open(pdf_path) try: page doc[0] xref self._find_qrcode_xref(page) if xref is None: return None base_image doc.extract_image(xref) if base_image: return Image.open(io.BytesIO(base_image[image])) finally: doc.close() return None def _find_qrcode_xref(self, page): # ... 上面写的稳健查找函数 ... pass def _decode_image(self, pil_image): # ... 整合了预处理和解码的代码 ... pass def _parse_raw_data(self, raw_data): # ... 上面写的解析函数 ... pass # 使用 if __name__ __main__: parser InvoiceQRCodeParser() # 处理单个文件 result parser.parse_pdf(发票1.pdf) if result: print(result) # 批量处理一个文件夹 import os pdf_folder ./invoices/ for filename in os.listdir(pdf_folder): if filename.lower().endswith(.pdf): full_path os.path.join(pdf_folder, filename) info parser.parse_pdf(full_path) if info: # 这里可以将info存入数据库或CSV print(f{filename}: {info.get(invoice_code)}-{info.get(invoice_number)})这个类提供了一个清晰的框架你可以根据实际需求扩展它比如增加多线程处理、结果存储、失败重试等机制。5. 应用场景与扩展思考当你掌握了从PDF解析二维码的整套技术你会发现它能做的事情远超简单的信息提取。我来分享几个我实际用到的或者觉得很有潜力的场景。场景一企业财务自动化对账这是最直接的应用。财务同事每个月都要处理成百上千张电子发票手动录入到ERP或财务系统里耗时耗力还容易出错。你可以写一个脚本定时扫描某个邮箱附件或者网盘目录自动解析新到的发票PDF把结构化数据发票代码、号码、金额、日期、销售方名称直接写入数据库或者生成标准格式的导入文件。财务人员只需要在系统里做最终核对效率提升不是一点半点。我帮一个朋友的公司搭建了这么一套系统原来一个人一天的工作量现在半小时就跑完了。场景二报销单智能审核在公司内部的报销系统里员工上传发票PDF后系统可以自动解析二维码实时校验发票的真伪通过调用税务局的查验接口需要发票代码、号码、开票日期、校验码后6位和金额、检查发票是否重复报销根据发票唯一标识查重、甚至判断发票类型和金额是否符合公司报销政策。这能在提交环节就堵住很多问题减少后续财务审核的压力。场景三档案管理与数据挖掘对于拥有大量历史发票的企业可以批量解析所有存档的PDF建立一个完整的、可搜索的发票数据库。你可以轻松地分析某个供应商一年开了多少发票某个项目的成本构成如何哪些月份的进项税最多这些基于真实交易的数据洞察对于经营决策非常有价值。而且数据是结构化的做可视化图表也非常方便。技术扩展思考性能优化如果PDF数量巨大可以考虑使用PyMuPDF的多线程能力或者用Celery这样的分布式任务队列来并行处理。服务化将解析功能封装成一个REST API服务比如用FastAPI这样其他系统如OA、CRM就可以通过简单的HTTP调用来获取发票信息实现技术解耦。结合OCR二维码虽然包含了核心信息但购买方、销售方全称、商品明细、税率等更多信息还在PDF的文本或表格里。一个更完善的方案是“二维码解析为主OCR识别为辅”。先用二维码拿到准确的关键索引信息再用OCR去识别特定区域的文字比如销售方名称区域两者结合数据的完整性和准确性会更高。容器化部署把整个解析环境Python、库、你的脚本打包成Docker镜像部署和迁移会变得极其简单在任何服务器上都能一键运行。最后我想说技术本身不难难的是把技术稳定、高效地用在解决实际问题上。我在最初开发时就被各种“非标”PDF折腾得够呛有的二维码带logo有的PDF是扫描件还有的甚至把二维码转成了矢量路径。解决问题的过程就是不断调整图像预处理参数、完善异常处理逻辑的过程。现在这套代码已经稳定运行了大半年处理了上万张发票。如果你在实践过程中也遇到了奇怪的PDF别灰心那正是你深入理解PDF格式和图像处理的好机会。