alitrack

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 包结构

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.524000000000012345678
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《电子文件存储与交换格式 版式文档》
  • ●数电发票: 国家税务总局关于全面数字化的电子发票的公告