ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

电子发票识别不止OCR:PDF与OFD格式工程实战

2026/10/8 16:25:20 拓冰建站 浏览量
电子发票识别不止OCR:PDF与OFD格式工程实战 简介面向企业应用系统开发与财务系统集成人员这是一套电子发票识别与解析的Java工程示例einvoice-master覆盖电子普票、电子专票与数电票场景支持PDF和OFD两种常用格式可提取发票号码、日期、金额、税号等关键字段便于企业打通发票数据的自动采集与入账流程。压缩包共21个文件7个Java文件对应核心识别与解析逻辑JS、CSS与GIF用于页面展示和效果预览XML、properties等配置文件负责项目构建与环境参数整体仅416KB结构精简便于快速阅读。目前已有2184人学习下载适合正在做财务系统对接、电子发票归档或数电票解析的开发者参考。包内提供完整Maven工程含pom.xml、src/main与src/test目录、LICENSE及预览图主程序、测试用例和授权说明一应俱全方便二次开发时直接复用工程结构和解析思路。1. 电子发票识别不是OCR问题是PDF与OFD的格式工程做电子发票识别和电子发票解析的人第一反应往往是上OCR——拍屏、扫件、跑识别模型。但真把电子普票、电子专票、数电票拿到手你会发现最卡你的不是文字识别而是版式这一层纸质发票可以靠视觉模型硬读电子发票里很多是带文本层的PDF有些是阅读器都打不开的OFD数电票甚至自带一份更干净的XML元数据。搞定PDF、OFD、XML三种载体之间的取舍再决定要不要OCR是整套方案性价比的分水岭。这篇笔记按我实际做过的路径展开适合正在做财务系统对接、RPA自动报销、票据结构化归档的从业者。2. 区分电子普票、电子专票与数电票版式、字段与自检命令2.1 三种票种的底层版式差异电子发票落到文件层面不外乎三种载体PDF、OFD、XML。传统税控盘开出的电子普票和电子专票交付给用户的是一个PDF文件里面是票面版式。这种PDF分两类一类是真正带文本层的数字原生PDF文字可选、可搜索另一类是打印扫描件或某些开票软件导出的图片型PDF整页只有一张底图文字不可选。前者可以直接做文本解析后者必须走OCR这是第一条分叉路。数电票全称“全面数字化的电子发票”从2023年起在全国推广它的交付物默认是一组文件XML元数据文件、PDF版式文件、OFD版式文件。XML是给机器读的字段完整且无排版干扰PDF和OFD是给人看的。很多从业者一上来就对PDF做版式解析但如果你能拿到同名的XML文件直接解析XML字段比从PDF反推要省事得多——遗憾的是实际业务中业务系统转手给你的往往只有PDF甚至只有一张拍屏照片。OFD是国标版式文件格式国内政策上鼓励电子发票使用OFD但它和PDF是两套完全不同的技术栈。PDF是页面描述语言文本、图形、字体混在一个流里OFD本质上是一个ZIP压缩包里面装着一组XML文件和资源图片页面上的每个文字对象都有精确坐标。所以OFD解析的正确入口不是“打开文件读文本”而是“解开ZIP包读XML按坐标重建版面”。2.2 解析目标字段对照表不管是哪种票最终要抽出来的结构化字段基本一致但细节差异直接决定正则和校验逻辑怎么写。字段税控电子普票税控电子专票数电票发票代码10位数字10位数字无发票号码8位数字8位数字20位数字开票日期格式固定格式固定格式固定精确到秒购买方/销售方名称税号名称税号地址电话开户行名称税号其余可选开票人开票人复核收款人同左仅有一个“开票人”价税合计小写大写小写大写仅有小写大写在PDF中项目明细无明细时整页明细多时附清单页明细多时附“项目清单”注意看数电票这一列发票号码是20位没有发票代码这意味着老的“代码号码”唯一性校验直接失效必须改成“号码开票日期价税合计”三元组。另外数电票的红字发票有自己的状态描述解析时要用“数电票号码”而非“发票号码”作为主键否则和税控票混在同一个库里会撞键。2.3 收到文件先做三件事判格式、验文本层、查容器我一般不会直接开解先花三十秒做三个自检判定输入到底是什么。file 数电票.pdf # 输出 PDF document 说明是 PDF输出 Zip archive 说明是 OFD 或普通压缩包 unzip -l 电子发票.ofd | head -20 # 正常 OFD 容器里应看到 OFD.xml 与 Doc_0/Pages/Page_0/Content.xml如果file命令不可用用一段Python做更明确的判别import zipfile def probe_invoice_file(path: str) - str: with open(path, rb) as f: head f.read(8) if head.startswith(b%PDF): return pdf if head.startswith(bPK): with zipfile.ZipFile(path) as z: names z.namelist() if any(n.endswith(OFD.xml) for n in names): return ofd return zip-other if head.lstrip().startswith(b): return xml return unknown这里的优先级逻辑是先看文件头再验容器内容。PK开头的既有可能是OFD也可能是普通ZIP或加密压缩包必须检查里面有没有OFD.xml才能确认。少数OFD文件被二次封装过OFD.xml不在根目录而在某个子目录下所以判断时用endswith(OFD.xml)而不是写死路径。这个探针函数建议直接留在代码库里后续每接入一个新渠道的发票先跑一遍它能省掉大量排错时间。3. 用PyMuPDF落地电子发票PDF解析锚点定位与字段抽取3.1 按“文本块坐标”组织内容而不是按行读电子发票PDF解析的第一步不是正则而是把页面文本提取成带坐标的对象列表。这里我用PyMuPDFfitz模块的dict模式它能拿到的信息比pdfplumber更细PDF版面解析是按字号、字体、位置拆成block、line、span三个层级每个span都带精确的包围盒坐标。import fitz # PyMuPDF def extract_pdf_blocks(pdf_path: str): doc fitz.open(pdf_path) page doc[0] # 电子发票基本是单页版式 d page.get_text(dict, flagsfitz.TEXTFLAGS_TEXT) blocks [] for block in d[blocks]: if block[type] ! 0: # 0文本块1图片块 continue for line in block[lines]: for span in line[spans]: text span[text].strip() if not text: continue x0, y0, x1, y1 span[bbox] blocks.append({ text: text, x0: x0, y0: y0, x1: x1, y1: y1, size: span[size], font: span[font], }) return blocks这里有个参数值得单独说明flagsfitz.TEXTFLAGS_TEXT。PyMuPDF默认的提取模式会包含图片上的OCR伪文本层但某些开票软件把“发票号码”渲染成图片后塞了一段不可见的文本层不关掉这个开关会把假文本混进结果里坐标也会乱。关掉后提取到的就是真正的文本层干净很多。3.2 锚点定位字段发票号码、购买方、价税合计拿到带坐标的文本块后最容易犯的错误是全文正则搜发票号码。票面上的“备注”区域经常出现“发票号码xxx”这类文字全文搜必然翻车。正确做法是把目标字段当作二维版面问题处理先找到固定标签锚点的位置再限定区域取值。def find_anchor(blocks, keyword: str): for b in blocks: if keyword in b[text]: return b return None def extract_right(blocks, anchor, x1_limit: float float(inf)): 取锚点右侧同行区域文本按x坐标排序拼接 x_left anchor[x1] y_top anchor[y0] - 2 y_bottom anchor[y1] 2 parts [] for b in blocks: if (b[x0] x_left and b[y0] y_top and b[y1] y_bottom and b[x0] x1_limit): parts.append((b[x0], b[text])) parts.sort(keylambda t: t[0]) return .join(p[1] for p in parts)锚点函数返回的是标签文本块本身extract_right利用锚点的x1右边界作为取值的左边界y0-2和y12作为纵向容差。2pt的容差覆盖字体渲染差异又不会大到混入相邻行。有些PDF的标签和值不在同一行基线上比如标签偏上、值偏下光靠同行容差不够这种情况我把容差放大到字号高度的1.5倍再按x0排序拼回。3.3 明细表格重建y坐标聚类成行x坐标切列发票明细区是解析里最头疼的部分。没有表格线的PDF里每行文字的列对齐完全靠坐标同一行字体的y坐标会有2-3pt的抖动必须先聚类成行再按x坐标切列。from collections import defaultdict def extract_table(blocks, region): x0, y0, x1, y1 region spans [ b for b in blocks if b[x0] x0 and b[x1] x1 and b[y0] y0 and b[y1] y1 ] # 行聚类y坐标除以步长取整步长按字号调整 step 6 rows defaultdict(list) for s in spans: key round(s[y0] / step) rows[key].append(s) table [] for key in sorted(rows): # 自上而下 row_spans sorted(rows[key], keylambda b: b[x0]) # 自左向右 table.append([b[text] for b in row_spans]) return tablestep6这个参数是从实际票样里调出来的标准数电票明细行高约20pty坐标抖动一般在3pt内6pt的聚类步长既能容忍抖动又不会把上下两行吞并。遇到行高被压缩的打印版发票把step调到4。列的顺序靠x0升序列与列的边界判断则依赖上一列末尾和下一列开头的坐标间隙间隙大于4pt就视为新列。3.4 输出JSON并做金额校验字段抽完后统一输出JSON结构。金额字段必须用Decimal而不是float——电子发票解析后要接财务系统浮点数精度问题会在汇总对账时炸出来。import json from decimal import Decimal, InvalidOperation def parse_amount(s: str) - Decimal: try: cleaned s.replace(,, ).replace(¥, ).replace(, ).strip() return Decimal(cleaned) except InvalidOperation: return Decimal(0) def build_json_result(fields): amount parse_amount(fields.get(amount, 0)) tax parse_amount(fields.get(tax, 0)) total parse_amount(fields.get(total, 0)) # 价税合计自检金额税额应等于合计允许0.01分钱差 delta abs((amount tax) - total) result { invoice_code: fields.get(invoice_code), invoice_no: fields.get(invoice_no), total_amount: str(total), validation: { amount_plus_tax_match: delta Decimal(0.01), delta: str(delta), }, } return json.dumps(result, ensure_asciiFalse, indent2)delta Decimal(0.01)这行是给四舍五入留的容错。有些开票软件按分逐项计算最后一项金额和税额进位方式不同会出现1分钱的理论差异直接判失败误杀率太高但容差也不能放大超过0.01否则字段错位问题会被悄悄吞掉。4. OFD解析拆开ZIP包读XML不依赖阅读器转换4.1 OFD不是PDF它是一组XML的压缩包OFD格式解析的认知门槛在于它的包结构。一个标准的OFD文件解压后能看到这些核心路径META/OFD.xml # 文档元信息声明命名空间和版本 Doc_0/Pages/Page_0/Content.xml # 第1页内容文字和图形都在这 Doc_0/PublicRes.xml # 公共资源字体、图片、色板Content.xml里描述页面上的每个对象TextObject是文字对象它的Boundary属性直接给出左上和右下坐标TextCode节点里的内容才是真正的票面文字。这意味着OFD的文本提取根本不需要OCR也不需要视觉模型它就是一套带坐标的结构化数据只是被包装在了XML里。看清楚这一点后整个OFD解析可以拆成两步第一步解压第二步把XML里的文字对象转成和PDF解析结果相同的统一结构。后面所有字段定位逻辑完全复用第3章的函数。4.2 提取TextObject与TextCode的坐标文本我直接用Python标准库加lxml解析不引重型框架。import zipfile, posixpath from lxml import etree OFD_NS {ofd: http://www.ofdspec.org/2016} def read_ofd_page_xml(ofd_path: str, page_index: int 0) - bytes: with zipfile.ZipFile(ofd_path) as z: names z.namelist() # 找到OFD.xml不假设它在固定目录 ofd_xml_name next(n for n in names if n.endswith(OFD.xml)) root etree.fromstring(z.read(ofd_xml_name)) doc_root root.xpath(//ofd:DocBody/ofd:DocRoot/text(), namespacesOFD_NS)[0] page_xml_path posixpath.join( doc_root, Pages, fPage_{page_index 1}, Content.xml ) return z.read(page_xml_path) def parse_ofd_text_objects(xml_bytes: bytes): root etree.fromstring(xml_bytes) text_objs [] for text_obj in root.xpath(//ofd:TextObject, namespacesOFD_NS): boundary text_obj.get(Boundary) if not boundary: continue x0, y0, x1, y1 [float(v) for v in boundary.split()] # 一个TextObject内可能有多个TextCode按出现顺序拼接 codes text_obj.xpath(ofd:TextCode/text(), namespacesOFD_NS) content .join(c or for c in codes) text_objs.append({ text: content, x0: x0, y0: y0, x1: x1, y1: y1, }) return text_objs这段代码有两点容易踩坑一是Boundary属性是空格分隔的四个数字顺序是“左上x、左上y、右下x、右下y”和PDF的bbox一致直接复用已有的锚点定位逻辑二是TextCode在XML里往往是多个节点拼一个完整句子必须按文档顺序拼接不能只取第一个否则“购买方名称”这类跨段文字会被截断。4.3 复用一个锚点引擎结构统一后不再区分PDF还是OFD两个解析函数输出结构一致后字段定位层就可以完全统一def extract_invoice_fields(text_blocks): fields {} anchor find_anchor(text_blocks, 发票号码) if anchor: fields[invoice_no] extract_right(text_blocks, anchor) anchor find_anchor(text_blocks, 价税合计) if anchor: fields[total_amount] extract_right(text_blocks, anchor) return fields我在生产代码里就是这么干的extract_pdf_blocks和parse_ofd_text_objects都输出同一个Block结构上层只认text和四个坐标字段。新增一种版式只需要写一个新的“版式读取器”后面所有锚点、表格、校验逻辑全部复用。实战中还有一种省事的兜底路径把OFD转成PDF再走PDF流程常见做法是用开源OFD转换库或阅读器的导出功能。转换兜底的缺点是依赖外部程序、批量处理慢但胜在省去维护两套解析逻辑。我的建议是解析量大、要求稳定的场景直接读XML解析量小、偶发处理时用转换兜底。5. 电子普票/专票与数电票解析的5个翻车场景5.1 文本全挤在一行或乱序现象提取出来的文本是一整段散文票面上的“购买方信息”和“销售方信息”串在一起完全没有分行。原因PDF创建时文本流没有按视觉顺序写入有的开票软件把版面当画布文字按绘制顺序而不是阅读顺序存储。解决不要试图按文本流顺序解析一律转成“坐标块”后按(y0, x0)排序重建阅读序。排序时注意先按y聚类成行再在行内按x排序直接用(y0, x0)二元排序会因行高抖动把上下行混排。5.2 OFD文件被当作普通ZIP解压后找不到XML现象明明探针判定是OFD解压后却没有OFD.xml只见一堆看不懂的目录。原因部分政务平台导出的OFD做了二次封装或者把OFD.xml藏在深层目录里路径根本不是固定结构。解决不写死路径遍历ZIP包内所有文件名用endswith(OFD.xml)定位真正入口拿到入口后DocRoot节点给出的文档根路径也不一定叫Doc_0必须用XML里的相对路径去拼页面文件路径而不是硬编码。5.3 数电票PDF提不出任何文本现象get_text(dict)返回结果几乎为空页面上的文字明明肉眼可见。原因数电票PDF的版式可能由底图加透明文本层组成但透明文本层的文字编码不规范PyMuPDF提取时把它当成了图形而不是文本。解决先算一下提取结果的总字符数低于50就判定为“图片型PDF”转OCR流程。OCR的代价是延迟和错误率所以这个阈值判断必须放在解析入口处不能等字段拼不起来再补救。5.4 金额精度差一分现象字段解析都成功但金额税额价税合计的校验时好时坏偶尔差0.01。原因开票系统按“每行金额×税率每行税额”分项计算最后一行四舍五入后税额合计和总价税合计之间存在进位差。解决用Decimal记录三位小数校验时允许0.01的容差同时在JSON里输出delta字段。不建议为了通过校验去改金额下游财务系统对金额精度极其敏感宁可标记“存疑”也不静默修正。5.5 发票号码匹配到备注里现象find_anchor找“发票号码”结果提取出来是备注栏里的一句“原发票号码作废”。原因备注区域文字也包含关键词锚点没限定页面区域。解决对锚点增加区域过滤参数——发票号码区域永远在票面右上方价税合计区域在票面右下方。为每个锚点预设一个候选区域超出区域的匹配直接丢弃如果候选区域内有多个匹配取坐标最靠前的一个。这套区域约束对PDF和OFD同样有效。6. 解析之后校验、去重、视觉兜底的最后一公里6.1 上线前先跑通校验规则字段解析完成不等于数据可用。我上线前必跑一遍三类校验结构性校验发票号码长度、税号格式、日期格式、计算性校验价税合计、税额税率匹配、票种专属校验数电票20位号码、无发票代码逻辑。跑完校验的样本票里通常有1%到2%会暴露解析逻辑缺陷这些缺陷靠单元测试发现不了必须用真实票压测。6.2 数电票去重与红字状态数电票没有发票代码入库主键建议用“发票号码开票日期价税合计”三元组。红字发票的号码和原蓝字发票同段但开票日期不同单纯按号码去重会误杀。我见过的稳妥做法是解析时额外抽一个“业务类型”字段取值“蓝字/红字/作废”红字票单独建表不与正常入账数据混排。作废票在XML里有明确标记PDF版式上没有最好优先解析XML拿不到XML再用“备注含作废字样”做辅助判断但准确率不如XML。6.3 哪些情况需要视觉模型兜底规则解析不是万能的。打印版发票、扫描件、拍屏照片这三类输入没有文本层也没有XML可依赖只能走OCR。我用OCR的阈值很明确先跑一遍文本层探针字符数不足或字段关键区无文本才送OCR而不是所有票都过一遍模型。多模态大模型的效果好但单张延迟秒级、成本高于规则解析几个数量级适合做异常样本的人工复核辅助不适合做默认流水线。真正的生产架构是“多重策略串行”规则解析优先文本层缺失触发OCROCR置信度低时转人工队列。这套链路做下来电子发票识别从“玄学问题”变成了可控的工程问题。我自己的习惯是每次接新的开票渠道先拿最近三个月的真实票样做全量回归至少覆盖三套不同开票软件的版式输出再切生产。OCR兜底和人工复核通道留着不拆它能兜住所有没想到的版式意外。希望帮到你。本文还有配套的精品资源点击获取