DuckDB 读不了数电发票,283 行 Lua 批量搞定
月底对账,财务发来一批 .ofd 文件,说这个月的发票都在里面。WPS 能打开,一张张看:金额、税号、明细、签章,排版工整。但要把几十张票的数字抄进表格,人就疯了。
一张发票,人打开看看就行。批量呢?我本来想用 DuckDB 直接读,read_text 读出来一堆乱码,read_csv 直接罢工。
OFD 是版式文档格式,和 PDF 一个性质——给人看的,不是给机器算的。国内数电发票(全电发票)默认就是这个格式。DuckDB 的扩展生态有 301 个社区扩展,parquet、iceberg、postgres 什么都有,但中国格式一个都没有。数电发票、OFD 版式、GBK 编码、券商流水,全是空白。
我花了点时间把 OFD 拆开看了一眼,发现它没那么神秘:OFD 就是一个 zip 压缩包,里面装 XML 版式文件。
● ● ●
OFD 里到底有什么
用 zip 工具把发票包解开,11 个文件:
OFD.xml ← 文档信息
Doc_0/Document.xml ← 页面结构
Doc_0/Pages/Page_0/Content.xml ← 版面内容(22038 字节)
Doc_0/Res/*.png ← 二维码、签章图片
最有意思的是 OFD.xml。生成发票的软件(诺诺、百望这些)会往里写 CustomDatas,发票号码、合计金额、合计税额、开票日期、购销双方税号,全在里面:
<ofd:CustomData Name="发票号码">24000000000012345678</ofd:CustomData>
<ofd:CustomData Name="合计金额">103.00</ofd:CustomData>
<ofd:CustomData Name="开票日期">2026年08月01日</ofd:CustomData>
光这一层,发票的元数据就白拿了。
OFD 包结构
明细和金额在 Content.xml 里,是版面文本——每个文本块带坐标:
<ofd:TextObject Boundary="34.5 14 18 5" Font="3" ID="39" Size="3.0">
<ofd:TextCode DeltaX="3.0 3.0 3.0 3.0 3.0" X="0" Y="3"><![CDATA[旅客运输服务]]></ofd:TextCode>
</ofd:TextObject>
按 y 坐标把文本块聚类成行,行内按 x 排序,整张发票的文字就还原出来了。
● ● ●
三步:unzip、meta、版面行
DuckDB 没有内建 zip 解压,所以先写了个通用的 unzip,178 行,FFI 调 zlib 解 deflate。这一步不只为发票——OFD、EPUB、DOCX、xlsx 全是 zip 容器,解压是第一块砖。
然后 inv_ofd,283 行,两件事:
元数据(标量函数)——读 OFD.xml 的 CustomDatas,吐 JSON:
SELECT json_extract_string(
luajit_s('inv_ofd', {'op':'meta', 'file':'invoice.ofd'}),
'$.发票号码') AS 发票号码,
json_extract_string(
luajit_s('inv_ofd', {'op':'meta', 'file':'invoice.ofd'}),
'$.合计金额') AS 金额;
版面行(表函数)——读 Content.xml 的文本块,按坐标还原:
SELECT split_part(val, '|', 1) AS y, split_part(val, '|', 2) AS 文本
FROM luajit_table('inv_ofd', list := '/tmp/invoice.ofd')
ORDER BY y;
拿一张真实发票跑了一遍,9 行全部还原(以下为示例数据):
| y | 文本 |
|---|---|
| 11.5 | 24000000000012345678 |
| 14.0 | 旅客运输服务 |
| 33.4 | 杭州某科技有限公司…北京某出行科技股份有限公司 |
| 57.0 | 交通运输服务客运服务费 100.00 1 100.00 3% 3.00 |
| 92.0 | ¥100.00 ¥3.00 |
| 143.5 | 壹佰零叁圆整 ¥103.00 |
明细、税率、税额、价税合计、大写金额,一行不缺。
● ● ●
顺手修了个隐藏 bug
写解压的时候发现仓库里 zip_list 这个库——它列出 zip 里所有文件,不压缩——中央目录的字段偏移全部算错了一位。第一条目名永远是空串,文件大小和 CRC 全是错的。老的 zip 文件解析出来"看起来对",一直没人发现。真实发票 11 个条目验证,修好了。
● ● ●
边界说清楚
OFD 是版式格式,结构化字段在配套的 XML 发票文件里。只有 OFD 文件时,元数据直接可用,明细是版面文本(要靠标签文本二次匹配),不是完美的结构化。rar、7z 没做——专有格式、容器复杂,成本高于价值。zip、tar 系列够了。
整个扩展在 alitrack/duckdb-luajit-libs,一条 SQL 装库即用:
SELECT * FROM luajit_module(mode := 'install', sql_name := 'inv_ofd');
MIT 协议,随便用。你手上有没有打不开的 OFD?评论区聊聊格式里还藏着什么。
参考来源:
- ●项目仓库: github.com/alitrack/duckdb-luajit-libs
- ●OFD 版式规范: GB/T 33190-2016《电子文件存储与交换格式 版式文档》
- ●数电发票: 国家税务总局关于全面数字化的电子发票的公告