OFD 预览有多难?我们用 Rust + WASM 把预览、验签、转档塞进了浏览器

15 阅读17分钟

一个基于 GB/T 33190-2016 国标的全功能 OFD 库,浏览器端纯前端渲染,文件不出本机

试用联系:694243440@qq.com

一、先说结论

我们做了一件在 OFD 圈子里不太常见的事:把完整的 OFD 内核编译成 WASM,让浏览器不依赖任何服务端就能打开、渲染、提取文本、验签,甚至把 PDF 转成 OFD。

  • 解析 / 渲染 / 文本与图像提取 / 数字签名与验签 / SM4+SM2 加解密 / 文档合并 / 水印 / OFD-A 档案合规检查,一套 Rust 内核全部覆盖;
  • 同一套内核出四种形态:CLI、WASM(前端)、C ABI、Python / C# / Java 绑定
  • 网页端渲染与桌面端渲染逐像素比对,6 份样例差异 0.000%
  • 73 份真实 OFD 样例(数科 / 百望电子发票、OFD R&W 测试集、国标文档)全部可解析、可渲染、可签名、可加解密。

如果你正为 OFD 文件打不开、发票要人工录入、验签没有趁手的工具 这类问题发愁,可以直接跳到第四节看演示,或拉到文末联系试用。

二、OFD 到底难在哪

OFD(Open Fixed-layout Document,开放版式文档)是国家标准 GB/T 33190-2016 定义的版式文档格式,用于电子发票、电子公文、电子证照、档案归档。它的“难”,不是难在格式本身,而是难在生态

坑一:浏览器不认,前端几乎无库可用

PDF 有 PDF.js,一个 JS 文件就能在网页里渲染。OFD 没有对应的东西。想在网页里展示 OFD,现实中的选项只有两个:买商业 SDK,或者搭一个服务端把 OFD 转成图片再传给前端。

服务端方案的代价是:每翻一页都要请求一次、图片体积大、并发上来 CPU 直接吃满;更要命的是文件必须上传到你的服务器——发票、公文、证照这类文件,客户往往不接受“上传到你那儿”。

坑二:文档常常不是自包含的

一份 OFD 是一个 ZIP 包,里面是 XML 描述 + 资源(字体、图像)+ 页面图形指令流。问题在于字体经常不嵌入,尤其是电子发票:它假定阅读器所在机器有相应字体。渲染器找不到字体,结果就是白页或者满屏豆腐块。

坑三:渲染语义处处反直觉

这是最耗时间的一类坑。OFD 有两套坐标系(页面空间 / 对象空间),Boundary 是对象空间的原点偏移,Clip 用的是相对 Boundary 的坐标而不是页面绝对坐标;DeltaX/DeltaY 的 g n v 语义是“值 v 重复 n 次”而不是循环取组值;C 是闭合路径指令而不是三次贝塞尔的别名(贝塞尔是 B)……

这些细节的可怕之处在于:错了页面也能画出来,只是画得不对。我们第一次跑通渲染时,发票文字全堆在页面底部——因为对象坐标系方向实现反了。rc=0 不等于渲染正确,这类问题只能靠逐像素比对和真实样例兜住。

坑四:签章是国密体系,验签不是“验一个签名值”

电子发票、电子公文上的签章走的是国密路线:SM2 签名、SM3 摘要、SM4 加密,签名结构是 SES(GB/T 35275 / GB/T 38540)。验签要回答三个层次的问题:

  1. 签名值本身有效吗——用证书里的 SM2 公钥验 SignedValue;
  2. 文档被改过吗——签名的 References 里逐文件列了摘要,要一个一个比对,任何一项不一致就是“文档在签名后被修改”;
  3. 签名人可信吗——证书链能不能走到你信任的根(信任锚)。这一点最容易被忽略:一份签名值完全有效、摘要全部匹配的发票,也可能来自一张自签证书。

WebCrypto 里既没有 SM2 也没有 SM3,这套东西在纯前端做,只能自己实现算法。

坑五:存量 PDF 要转 OFD,工具链闭源且重

政企系统里大量存量 PDF 要进 OFD 归档体系(GB/T 42133-2022《信息技术 OFD 档案应用指南》)。转换工具要么是闭源商业 SDK(贵、有水印或页数限制、不可能进浏览器),要么是 Java 服务端方案(部署重、要维护一整套服务)。浏览器里直接转,几乎没有可选方案。

三、我们的做法:一个 Rust 内核,四种形态

项目叫 rust-ofd,核心思路是把 OFD 的全部能力收敛到一套 Rust 内核里,再按需要编译成不同形态

crates/

├── ofd-core 数据模型(CT_/ST_ 类型体系)+ XML 序列化 + ZIP 容器读写 + 文本/图像提取

├── ofd-render 渲染引擎:tiny-skia 位图(PNG)与 Canvas 2D 指令流双后端

├── ofd-convert PDF→OFD 转换(pdf-rs 解析 + 内容流重建)

├── ofd-sign 数字签名/验签(SM3withSM2、SES、X.509、证书链)

├── ofd-crypto 加密解密(SM4-CBC 内容加密 + SM2 数字信封 + SM3 完整性校验)

├── ofd-tool 文档合并、文字水印

├── ofd-archive OFD-A 合规检查(9 条规则)与去技术化转换

├── ofd-ffi C ABI 动态库(ofd_ffi.dll + include/ofd.h)

└── ofd-wasm WASM 绑定(wasm-bindgen,含 PDF→OFD 转换)

关键设计一:渲染双后端。 服务端和 CLI 出 PNG 位图(tiny-skia);浏览器出的是 Canvas 2D 指令流——不是图片,而是一串绘制指令,由 JS 侧在 canvas 上重放。

这个选择的收益很直接:一页 A4 位图动辄几百 KB,而同一页的指令流通常只有几十 KB;更重要的是缩放不用重新请求,浏览器矢量重绘,200%、300% 放大依旧锐利。下面“国标正文页 200%”那张图,就是同一个指令流用更大的变换矩阵重放的结果。

关键设计二:网页渲染和桌面渲染是同一个内核。 不是“网页版近似实现”,而是同一份 Rust 代码。我们用 parity-check 做逐像素验证:同一份 OFD,分别走 WASM 路径和原生 CLI 路径,渲染成同尺度 PNG 后逐像素比对。

关键设计三:文件不出浏览器。 WASM 形态下,解析、渲染、验签、PDF 转换全部在本机内存里完成,没有任何网络请求。这对发票、公文这类敏感文件是决定性的。

四、跑起来看看:Vue 示例完整演示

示例在 examples/vue-demo,三步起服务:

cd examples/vue-demo

npm install

npm run dev

打开页面是这样一个干净的初始状态:顶部两个入口——打开 OFDPDF 转 OFD(转换完成后还会多出一个“下载 OFD”)。

图 1 示例初始状态

4.1 PDF 直接转 OFD:14 页国标文档 0.47 秒

点击“PDF 转 OFD”,选一份 PDF。我们直接拿 GB/T 42133-2022《信息技术 OFD 档案应用指南》国标原文(方正书版排版,1.0MB,14 页)做测试:

  • 转换耗时 0.47 秒,产物 1533.9 KB;
  • 138 个嵌入字体全部保真搬运,0 个退化为轮廓
  • 图像 2 个,书签 / 注释 / 附件 / DocInfo 元数据全量迁移。
    转换完成后页面顶部给出报告,并自动把 OFD 下载到本地(也保留了手动“下载 OFD”入口)。

图 2 PDF→OFD 转换结果与报告
放大到 200% 看正文页,中文排版、表格、字号层级、页眉页脚全部原样,矢量清晰:

图 3 国标正文页 200% 缩放
这份样例值得单独说一句:它是方正书版(BookMaker) 排版的 PDF,嵌入字体是裸 CFF(没有 sfnt 容器),早期版本转出来满页错字。现在的处理是现场解析 CFF 的 header / INDEX / Top DICT / charset,合成 head/maxp/hhea/hmtx/post 表拼出一个最小 OTF 容器,把字形按原样搬进 OFD。转换警告数从 152 条降到 1 条。
4.2 打开电子发票:文本可直接复制,信息可批量提取
打开一份数科增值税电子普通发票(案例样本,已含 SES v4 签名):

图 4 电子发票渲染与页面文本提取
右侧“页面文本(可复制)”是 pageText() 的输出——发票代码、购买方名称、纳税人识别号、地址电话、开户行账号、金额、开票人全在,直接可复制、可正则提取、可入库查重。
这条能力对财务共享中心、费控报销、发票查验平台是硬需求:过去这些字段要么靠 OCR(有错误率、要调模型),要么靠人工录入。
4.3 数字签名验签:SM2 签名值 + 逐文件摘要 + 证书链
同一份发票,左侧“数字签名”卡片在打开文档时自动完成验签:

图 5 数字签名验签结果
这份样例展示了验签的完整输出:

  • 徽章显示“有效” ——SM2 签名值有效,且 References 中逐个受保护文件的摘要全部匹配;
  • 签名人 / 序号 / 签名时间:#0(SESv4),签名时间 20201010071120Z;
  • 证书信息:主体为“全国统一发票监制章国家税务总局重庆市税务局”,签发者“税务测试CA2(SM2)”,有效期 2020-10-10 ~ 2030-10-10;
  • 证书链:未配置信任锚——注意这里没有报“有效但可疑”,而是明确告诉你链的结论:这份文档只嵌了签名者证书、缺上级证书,因此只能证明“签名值有效且内容未被篡改”,不能证明“签名人可信”。要得到“受信任”结论,需要提供税务 CA 根证书作为信任锚。
    面板下方可以直接粘贴信任公钥(纯数字签名场景),或选择 .cer/.crt/.pem 证书文件作为信任锚重新验签。对应接口:
    const doc = new OfdDoc(fileBytes);
    doc.signatureCount; // 签名数量
    const reports = doc.verifySignatures(trustedKeyHex, anchors);
    // 每项含 signatureValid / digestValid / signer / signTime / certSubject /
    // chainStatus('trusted'|'no-anchor'|'incomplete'|'untrusted-root'|'expired'|...)
    // / chainPath / references(逐文件比对结果)
    4.4 印章细节
    放大到 300% 看发票监制章——这是矢量绘制的印章注释(SEAL 类型 Annot),不是位图贴图:

图 6 印章细节 300%
4.5 长文档:42 页幻灯片秒开
换成一份 42 页的演示类 OFD,左侧页列表直接铺开 42 个页码,点击即跳转:

图 7 42 页文档与页列表
五、几个真正难啃的地方
如果只写“我们做了什么”,这篇文章就没价值了。下面是踩过的坑里最值得说的几个。
5.1 坐标系:错了也能画出来,只是画得不对
对象空间的实现一开始和国标是相反的。国标规定页面空间原点在左上、Y 轴向下;对象局部空间经 CTM 线性变换后,再按 Boundary(左上角)平移。我们最初按“绝对坐标 + 翻转 Y”实现,结果是一页文字整块堆到页面底部。
更隐蔽的是 Clip。转换端(PDF→OFD)最初把裁剪区域写成页面绝对毫米坐标,而渲染器和野生 OFD 里 Clip 的 Gpts 是相对 Boundary 的坐标——差了一个原点偏移。表现出来是“文字只剩一些残条”,肉眼几乎看不出是哪一层出的问题。最后靠逐层 dump 绘制指令定位,把 clips_mm 统一加上 origin 参数做相对化。
教训:OFD 渲染的问题不会以崩溃的形式出现,只会以“看起来差不多但不对”的形式出现。必须建立像素级回归。
5.2 字体:裸 CFF、CID-keyed、按名查 GID
方正书版那类老式排版软件输出的 PDF,嵌入字体不是标准的 TrueType / OpenType,而是裸 CFF(Type1C,魔数 01 00 04 xx) 。直接用 ttf-parser 解析必然失败,于是早期实现判定“无嵌入字体”,退化成用回退字体按字符编码猜 Unicode——画出满页错字。
解决办法是手写一个最小 OTF 封装器:

  • 解析 CFF 的 header、INDEX、Top DICT、charset;

  • 合成 head / maxp / hhea / hmtx / post 表。head 表必须是 54 字节——created / modified 各是 8 字节 LONGDATETIME,少写会导致 NoHeadTable;

  • post 2.0 用 CFF charset 里的自定义字形名(SID ≥ 391)承载 PDF Differences 里的 G 名,从而支持按名查 GID;

  • CID-keyed(Top DICT 里带 ROS 操作符)的字体额外产出一张 CID→GID 映射表。
    另一个必须记住的坑:回退字体的轮廓退化路径只能按 Unicode 寻址。拿原字体的 gid 去查回退字体是错的——两个字体的字形空间毫无关系。
    5.3 PDF→OFD 的文本双模式
    转换不是“把 PDF 内容画成矢量路径”就完事——那样转出来的 OFD 只是一张“能看的图”,文本不可检索、不可复制。我们的策略是分两条路:
    嵌入模式(优先) :PDF 里的 TrueType / OpenType 字体文件原样搬进 OFD 包,每个字形写入 TextCode.Glyphs(字形 ID 直写),同时迁移 ToUnicode 保证可检索。这一路出来的是结构完整、可搜索、可复制的 OFD。
    轮廓模式(兜底) :源 PDF 的字体没有可解析的嵌入文件(Base14、Type3 等),按字形轮廓转成矢量路径。视觉保真,但文本不可检索。
    图像侧同样做了分流:JPEG(DCT)字节透传零重编码(不重新压缩就不损失质量、也不耗时);Flate / LZW 采样数据解码后重编码为 PNG;带 SMask 的合成 alpha;ImageMask 按当前填充色栅格化。
    5.4 二维码:MMR + MQ 算术解码
    电子发票左上角的二维码是个典型的“看起来简单、做起来要命”的需求。很多发票把二维码存成 JBIG2 图像,而 JBIG2 的算术编码用的是 MQ 编码器(JPEG2000 同款)。实现要点:

  • 段解析(segment header + 引用关系);

  • MMR(Group 4)解码,码表对照 RFC 804;

  • MQ 算术解码的正确配置——概率状态表、INITDEC、GBAT、TPGDON、上下文位权,任何一项错了,输出就是随机噪点(不报错,只是图像全黑或者花屏);

  • 泛型区域(generic region)解码。
    这块我们是拿 pdf.js 的 JBIG2 实现做参照逐个参数对齐,再用真实发票的二维码做回归——渲染出来的二维码必须能被手机扫码识别,这是唯一的验收标准。
    5.5 验签的三个层次与“按签名时间判定”
    前面提过验签要分三层。这里补充两个容易做错的细节:
    有效期按签名时间判定,而不是“现在”。 数科 2019 年的发票证书有效期是 2019~2022,现在验签如果按“当前时间”判就全部误报“证书过期”。长期验证的正确语义是:看签署那一刻证书是否有效。
    空信任锚 ≠ 链不完整。 没有配置信任锚时统一报 no-anchor(“未配置信任锚”);只有在提供了锚、但链走不到锚时,才区分 incomplete(缺中间证书)/ untrusted-root(根不在锚里)等结论。把这两种情况混为一谈,会让人误以为文档有问题。
    还有一个工程性很强的坑:给已签名的 OFD 追加签名。默认保存路径会把 XML 按模型重新序列化,字节全变,原签名立刻失效。我们为此做了保字节的 raw_package 模式:以原始字节为基,只补入包内缺失的文件,仅当 OFD.xml 缺 Signatures 指针时才改写它。效果是对已签名文档追加签名后,原签名依然有效
    5.6 网页端的那些坑
    WASM 路径和原生路径的表现差异,多数是 canvas 语义差异造成的:

  • canvas 后端最初没有输出描边样式,结果电子发票的边框全变成黑色;

  • 虚线状态跨路径残留,导致不该有虚线的图形出现虚线;

  • 空的 Clip 会把整页抹掉——原生的空裁剪是无操作,canvas 里 clip() 之后就什么都画不出来了;

  • 指令流里的图像资源序列化时变成了 Map,JS 侧取 Object.keys() 拿到空数组。
    还有一个前端经典问题:canvas 在条件渲染里,fileName 一赋值 要到下一次 DOM 刷新才存在,同步取 ref 拿到的是 null,渲染被静默跳过——表现是“首次打开空白,点一下缩放或翻页才出现”。
    这些坑的共同点是只在网页端复现,原生测试全绿。所以我们的验证方式是“网页渲染 = 原生渲染”逐像素比对,而不是各写一套测试。
    六、我们诚实地知道边界
    一个负责任的介绍应该把做不到的说清楚:

  • JBIG2 的 symbol dictionary / text region 段尚未解码,遇到这类图像会画占位框(结构解析不受影响);JPEG2000 同样暂不支持;

  • 无 ToUnicode 的老式排版 PDF,转出来的 OFD 文本不可检索。这是源文件里就不存在的信息,转换阶段无法恢复。字形本身按嵌入字体保真渲染、视觉是对的,但 TextCode.Text 为空;

  • PDF 里的 shading(sh 操作符)和混合模式做了近似处理,AcroForm 表单、Type3 字体按轮廓近似;

  • 与其它厂商阅读器的互操作还需逐项核对。签名 / 加密实现遵循 SM2、SM3、SM4 与 SES 结构的标准技术路线,但要下“完全互操作”的结论,必须拿 GB/T 35275、GB/T 38540、GM/T 0099 原文逐条比;

  • 性能尚未做系统基准测试,本文的数字都是单机实测值。
    七、验证数据
    我们不相信“跑通一次就算支持”,所以建立了三层验证。
    7.1 样例矩阵
    73 份真实 OFD(OFD R&W 测试集、数科 / 百望电子发票、非标命名空间、缺失资源、附件、水印、签名样本等)× 7 项功能(info / list / render / sign / verify / encrypt / decrypt)。73 份样例全部可解析、可渲染、可签名、可加解密;验签对原始签名给出明确的有效 / 无效判定(部分样例本身是被篡改过的测试文件,报“文档已被修改”是正确结果)。
    7.2 网页 = 原生
    6 份样例的 WASM 渲染与原生 CLI 渲染逐像素比对,差异像素 0.000% (阈值 0.5%)。
    7.3 测试
    Rust 侧 85 项测试全部通过,覆盖解析、序列化、渲染、签名验签与篡改检测、加解密往返、合并、合规检查、PDF 转换(含裸 CFF 包装、文本双模式、图像分流、SMask、书签注释附件迁移);Python 绑定与 C# 绑定各有独立测试。
    八、试用与联系

  • 邮箱:694243440@qq.com(试用申请、技术交流、合作均可)

  • 线上地址ofd.11m18.cn

  • 欢迎测试使用并提出建议发现 bug 我会尽快修复。

现在就可以通过邮件联系我们,说明你的场景(例如:发票批量信息提取、公文预览、OFD 归档合规检查、PDF→OFD 批量转换、验签服务),我们可以先提供对应的 SDK 形态给你做验证:

  • 表 1 不同技术栈可用的形态**
你的技术栈可用形态
Web 前端(Vue / React / 原生 JS)npm 包 rust-ofd(WASM + Canvas 播放器 + TS 类型)
服务端 / 桌面(任何语言)ofd-cli 命令行
C / C++ / Go / Rustofd_ffi 动态库 + include/ofd.h
PythonPyO3 绑定(pip 包)
.NETP/Invoke 绑定(.NET 9,含测试)
JavaJNA 绑定

本文中的全部截图均来自 examples/vue-demo 的真实运行结果(Chromium / Edge,Windows),转换耗时、文件体积、字体数量等数据为单机实测值。