简介:面向企业财务系统开发者及相关实施人员的电子发票识别与解析示例工程,聚焦电子普票、电子专票以及数电票PDF、OFD等场景的结构化提取,帮助读者快速理解发票关键字段的识别流程、解析步骤和集成思路。压缩包共21个文件,约416KB,以7个Java源码文件为解析核心,配合4个JavaScript、3个CSS等前端展示资源,并含配置与授权说明,适合作为企业业务系统二次开发或集成的参考基础。已有2184人浏览/学习,源码按标准Maven工程组织,主程序、测试代码与配置文件分层清晰,便于直接定位解析逻辑与测试入口。通过该工程可以掌握PDF与OFD电子发票的解析模式,理解发票号码、日期、金额、税号等关键信息如何自动提取,同时理清企业应用对接中的常见处理要点,是一份贴近实际业务的开源参考工程。
1. 电子发票识别:PDF、OFD、数电票三种格式的解析路线先理清
电子发票识别在开放接口失效后,基本都转到本地解析这条路了。如果想把电子普票、电子专票、PDF、OFD、数电票都收进同一个流程里,第一件事不是上OCR模型,而是把文件壳拆开看结构。电子发票识别这块最大的坑就是把版式文件当图片处理,后面每一个环节都会被带偏。这套资源的核心思路,是从PDF文本层、OFD内的XML和数电票版式里直接抽字段,适合正在对接报销系统、财务机器人、票据管理应用,并且想绕过第三方API的从业者。读完之后你会清楚能拆到什么粒度、卡点在哪个环节。
2. 解析前先拆壳:PDF的文本层与OFD的XML结构,决定选型路线
2.1 电子发票的文件真实结构:PDF和OFD都是「壳」
先说一个很多人会弄混的基础认知:电子普票和电子专票的PDF,绝大多数不是扫描件,而是「文本型PDF」。开票系统生成文件时,票面上的每一段文字,比如发票号码、购买方名称、金额,都直接写进了PDF的内容流里,pdfplumber可以完整抽出来。这是一种「文本层」结构,跟拿手机拍屏幕再OCR是两回事。
我一般拿到PDF会先做一次快速体检,用pdfplumber打开文件,跑一遍extract_text(),能抽出完整文字就说明根本没有必要上OCR框架。这一点决定了后面整套方案的工作量。
OFD的路子则完全不同。OFD是国标版式文件,本质是一个ZIP压缩包,内部装的是XML描述文件、字体文件和资源目录。票面文字全部以XML节点形式存在于Document.xml里,所以OFD解析的实质是「解压 + XML解析」,跟图像识别没有半点关系。
这个认知直接决定选型路线:
- 文本型PDF:用文本提取工具,比如
pdfplumber、PyMuPDF,直接抽文本。 - OFD:用
zipfile解包,再用ElementTree解析XML。 - 数电票PDF:版式偏文本型,但字段名和排列跟传统普票专票不一样,需要用新版式规则处理。
只有拿到图片型PDF,比如增值税发票的扫描存档,才需要走到OCR这一步。实际项目里,图片型PDF建议直接走人工复核队列,或者要求对方提供OFD原件,OCR识别的代价太大了。
2.2 普票、专票、数电票的字段差异与选型路线
传统电子普票和电子专票版式基本沿用纸质发票格局,顶部是发票代码和发票号码,下方分购买方、销售方两栏,中间是项目名称和金额税率区,底部是价税合计和开票人信息。普票和专票的关键差异在于:专票多出一组税率、税额和价税合计小计数,普票则只有「价税合计(大写)」一个总数。
数电票从2023年全面推广后,版式明显变化:不再有「发票代码」,只有20位数字的「数电票号码」;发票抬头不叫「购买方/销售方」,改叫「购买方信息/销售方信息」;监制章、发票专用章变成了「数电票号码池」,文件里大量使用坐标定位的块状布局。字段标签在PDF里的排布方式和旧票差距很大,如果照搬旧版正则,很可能会把「开票日期」和「数电票号码」搞混。
| 字段含义 | 传统电子票(PDF/OFD) | 数电票(PDF/OFD) |
|---|---|---|
| 发票代码 | 有,10位或12位数字 | 无 |
| 票号码 | 8位数字 | 20位数字+字母 |
| 抬头名称 | 购方信息/销方信息 | 购买方信息/销售方信息 |
| 税率列 | 每条项目单独列出 | 确认单中集中体现 |
| 价税合计 | 有,大写小写同时出现 | 有,小写显著 |
选型上就清楚了很多:如果对接的是存量历史发票池,按传统版式解析即可;如果对接的是新开票系统,优先按数电票版式做,因为新版接口和样式只会越来越多。这套资源把两类版式都覆盖了,实际使用时用文件类型和字段特征做分支判断,可以同时跑两种。
3. PDF发票解析实战:pdfplumber取坐标,关键字定位加正则兜底
3.1 环境准备与第一个可跑通的解析脚本
PDF解析主推pdfplumber,它在文本位置还原上的精度比PyMuPDF更适合做版式对齐,能拿到每个字符的坐标和字体大小,方便按区域截取。解析前最好在虚拟环境里装好依赖。
pip install pdfplumber然后先跑一个最基础的文本抽取脚本,确认文件是不是文本型PDF。
import pdfplumber def extract_pdf_text(pdf_path): with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: text = page.extract_text() if text: print(text[:500])这段代码把每一页的文本按行打印出来,pdf.pages是页面列表,extract_text()返回按阅读顺序排列的字符串。如果输出为空,说明PDF没有文本层,就需要走别的路线了。
确认有文本层后,升级到带坐标的词级提取,这一步对后面处理数电票尤其关键,因为新版票面有大量块状区域,按坐标裁剪比全文正则更稳。
def extract_words_with_position(pdf_path): with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: for word in page.extract_words(): print(word['text'], word['x0'], word['top'], word['size'])extract_words()返回字典列表,x0是单词左边界,top是上边界,单位是磅值,配合page.crop()可以锁定票面某一区域再做提取。这套坐标提取在数电票解析里很有用,因为数电票的「购买方信息」区域是一个整体框,用坐标框会比全文搜关键字稳得多。
3.2 字段提取逻辑:从关键字定位到正则兜底
核心字段提取逻辑,我一般是先做全文归一化,再用关键字定位加正则兜底。以发票号码为例,传统电子票的标签是「发票号码:」,数电票换成了「数电票号码:」,但两者都跟在「号码」关键字后面:
import re import pdfplumber def get_invoice_number(pdf_path): with pdfplumber.open(pdf_path) as pdf: full_text = "\n".join(page.extract_text() or "" for page in pdf.pages) # 归一化:去掉空白符和换行,避免号码被截断 normalized = re.sub(r"\s+", "", full_text) # 同时匹配旧版「发票号码」和新版「数电票号码」 match = re.search(r"(?:发票号码|数电票号码)[::]\s*([0-9A-Z]+)", normalized) return match.group(1) if match else None先拼全文再归一化,是为了处理PDF换行导致号码被拆断的问题,这在数电票中很常见。正则里[0-9A-Z]+覆盖了数电票的20位混合字符,传统8位数字也能匹配。
补充一个坑:不同开票软件的标签写法不完全一致,有的用中文全角冒号「:」,有的用半角「:」,所以字符组里两个都写上。
3.3 参数说明与误用差异
pdfplumber有两个容易混淆的接口:extract_text()和extract_words()。前者适合整页搜索、关键字定位,后者适合坐标级提取。它们各有用处。
三个主要的误用场景:
- 直接用
extract_text()的字符串做坐标裁剪,结果拿不到位置信息,因为它是按行排版拼出来的。 - 使用
extract_words()后不做词序排序,PDF解析出来的词序有时跟视觉顺序不一致,需要按(top, x0)排序后再拼句子。 - 忽略多页场景,把第一页结果当成全部文本;一张PDF不一定只有一页,存在多页情况,需要遍历
pdf.pages。
另外一个容易被忽略的点:page.crop()配合坐标使用时,pdfplumber的坐标系统是相对页面左上角起算的,跟PDF标准坐标的原点不同。如果是从其他工具拿坐标来复用,需要先确认两者坐标系一致。我一般会先打印几个特征词的坐标验证,再批量跑。
4. OFD解析实战:解ZIP读XML,路径兼容这一步不能少
4.1 OFD的基本组成:ZIP、XML和字体
OFD解包后,常见的目录结构长这样:
OFD.xml:入口文件,描述文档主入口。Doc_0/Document.xml:版式内容主体,票面字段都在这里。Doc_0/Pages/Page_0.xml:每一页的内容描述。Doc_0/Fonts/:内嵌字体文件。
需要明确一点:OFD内部文件不是固定写死的,有的发票文件入口不叫OFD.xml,有的文档目录叫Doc_1而不是Doc_0。不能把路径写死,要先列目录确认。
import zipfile def list_ofd_structure(ofd_path): with zipfile.ZipFile(ofd_path, 'r') as z: for name in z.namelist(): print(name)这一步非常重要,解包后能直观看到入口和内容目录,再决定如何写解析逻辑。
4.2 从OFD.xml里抽取发票字段:不是所有版本都一样
OFD票面的文本在Document.xml里以TextObject节点存在,每个TextObject内包含TextCode,就是实际显示的文字。抽取流程分两步:先通过OFD.xml找到Document.xml路径,再解析Document.xml里的文本节点。
import zipfile import xml.etree.ElementTree as ET def extract_ofd_text(ofd_path): with zipfile.ZipFile(ofd_path, 'r') as z: # 入口文件不一定叫 OFD.xml,先扫描所有 xml 找包含 DocumentPath 的那个 xml_names = [n for n in z.namelist() if n.endswith('.xml')] doc_path = None for name in xml_names: content = z.read(name) if b'DocumentPath' in content: # 从入口文件里读取 Document 的实际路径 root = ET.fromstring(content) doc_path = root.findtext('.//{http://www.ofdspec.org/2023}DocumentPath') break if not doc_path: raise ValueError('未找到 OFD 文档入口') # 拼出 Document.xml 的实际路径 doc_xml_path = doc_path.lstrip('/') doc_xml = z.read(doc_xml_path) root = ET.fromstring(doc_xml) # 兼容有无命名空间的场景,统一用局部标签名匹配 def local_name(tag): return tag.rsplit('}', 1)[-1] texts = [] for elem in root.iter(): if local_name(elem.tag) == 'TextCode': if elem.text and elem.text.strip(): texts.append(elem.text.strip()) return texts这段代码有两个关键点:第一,扫描所有包含DocumentPath字样的XML,而不是写死读取OFD.xml;第二,遍历TextCode时用局部标签名匹配,绕开命名空间差异。很多OFD文件的命名空间前缀可能不同,但局部名是稳定的TextCode。
拿到全部文本后,字段提取就回归到跟PDF一样的套路:用关键字定位加正则。购买方名称、销售方名称、金额的标签在OFD里同样存在,只是拆成了块。
4.3 OFD路径解析的坑:有的文件带签名目录
OFD解析最容易翻车的地方在入口文件定位。部分OFD的ZIP包内带Signature.xml等签名相关文件,如果简单用namelist()筛选Document.xml,可能命中签名目录里的XML,而不是版式正文。
解决方式:始终从入口XML的DocumentPath字段拿正文路径,而不是靠文件名猜测。另外,有的OFD在META.xml里有DocumentPath,有的在根目录OFD.xml里,所以扫描范围的兜底逻辑应该是「先扫根目录,再扫子目录」。
在跑批量发票时,可以先把所有OFD的入口路径抓出来做一次记录,如果发现某个文件结构异常,单独放到异常队列里人工检查,不要中断整个批处理。
5. 发票解析避坑指南:OCR误用、版式差异和兼容性排查记录
5.1 现象一:PDF解析全部为空,文件是扫描件
- 现象:同样的脚本,某些PDF能抽出文本,某些输出为空。
- 原因:这些PDF是扫描件或图片型PDF,没有文本层,
extract_text()只能返回None。 - 解决:先用
extract_text()判断文本层是否存在。为空时不要盲目上OCR,优先找同一张发票的OFD原件,OFD一定是文本型结构;实在没有,再走OCR流程,且OCR结果需要标记为「低置信度」,等待人工复核。
5.2 现象二:专票识别正常,普票缺税,普通专票缺税率
- 现象:解析普票没问题,解析专票少税率和税额。
- 原因:传统专票的票面是「金额/税率/税额」三列并排,PDF文本流里三者的视觉顺序不一定是「金额、税率、税额」,有的票面会把税额放在金额前面。
- 解决:按行解析时,对每行做「金额 + 税率 + 税额」三字段配平,使用项目名称所在行作为锚点,不要用关键字全局搜索。
5.3 现象三:OFD解包后找不到Document.xml
- 现象:本地解析正常,换一批发票就报
KeyError。 - 原因:部分OFD文件的
DocumentPath指向的是Doc_0/Document.xml,但实际包内是Doc_1/或把入口文件命名成了其他名字;还有的入口文件内容里包含DocumentPath节点,但路径前带了/,拼接时出错。 - 解决:入口匹配用内容扫描替代文件名匹配。找到包含
DocumentPath的XML后,再解析路径值,并对路径做lstrip('/')处理。路径值为空时,回退到Doc_0/Document.xml再尝试。
5.4 现象四:数电票号码匹配失败,中间混入换行或空格
- 现象:正则写好了,但是数电票号码匹配不到。
- 原因:PDF文本流里,20位号码在视觉上连续,但内部被换行切成了两段,或者在空白字符之间插入了空格。
- 解决:先用
re.sub(r"\s+", "", full_text)去除所有空白,再做正则匹配。注意不能直接去掉PDF里所有空格后做金额提取,¥ 100.00中间的空格会被错误合并,字段要分开归一化。
5.5 现象五:金额字段解析含全角字符
- 现象:金额字段打印出来是
¥100.00,正则\d+匹配失败。 - 原因:不同开票系统在场次和标点处理上不一致,有的使用了全角句点和小数点。
- 解决:解析前统一做字符归一化,把全角数字和符号转换为半角,再执行金额提取。转换表要覆盖全角数字、全角冒号、全角句点。
6. 发票字段归一化与落库校验:一份JSON走通三种来源
6.1 统一输出模型
PDF解析、OFD解析、数电票解析三条路径跑完,最后都统一输出成一份字段结构,具体到落库前所有字段名保持一致:
{ "invoice_type": "pdf", "invoice_code": "031002200211", "invoice_number": "12345678901234567890", "invoice_date": "2024-05-20", "buyer_tax_id": "91110000...", "seller_tax_id": "91110000...", "amount": 1000.00, "tax_rate": 0.06, "tax_amount": 60.00, "total": 1060.00, "raw_file_md5": "…" }这里建议在解析函数入口就加入文件MD5标识,方便排查问题,避免同一张票被重复解析入库。
6.2 校验技巧:发票号码格式与金额一致性校验
入库前做一致性校验:
import re def validate_invoice_fields(fields): errors = [] if not re.fullmatch(r"\d{20}", fields["invoice_number"]): errors.append(f"发票号码格式异常: {fields['invoice_number']}") if abs(fields["total"] - fields["amount"] - fields["tax_amount"]) > 0.01: errors.append(f"价税合计不一致") if fields["tax_rate"] is not None: expected_tax = round(fields["amount"] * fields["tax_rate"], 2) if abs(expected_tax - fields["tax_amount"]) > 0.01: errors.append(f"税额校验失败") return errors校验通过后进入正式库,校验失败的自动进入人工复核队列,别直接丢弃或硬写入库。
这条路我前后踩了好几次,尤其是在OFD入口路径和数电票归一化这两个环节。从那以后每次新增业务方的发票样本,我都强制跑一遍拆壳、字段提取、校验三段流程,把样例文件归档成回归测试集。希望帮到你。
本文还有配套的精品资源,点击获取