我做了一个鸿蒙原生 OFD 库——纯 ArkTS、无 WebView、跑通电子发票全流程
tl;dr:HarmonyOS NEXT 上首个开源的原生 OFD 阅读库 ofdkit-harmony 发布。纯 ArkTS 实现,遵循 GB/T 33190-2016。本文聊我为什么从零写一个、踩了哪些坑、关键技术怎么解决。
缘起:鸿蒙生态缺一个 OFD 阅读器
OFD 是中国国标电子文档格式(GB/T 33190-2016),承载了几乎所有电子发票、电子公文、电子合同。
前段时间我在做一个鸿蒙原生应用,需要打开电子发票——然后发现:鸿蒙生态目前没有可用的原生 OFD 阅读方案。能找到的只有三条路:
- WebView 嵌套 PDF.js + OFD→PDF 转换:包体积大、启动慢、签章信息丢失
- JNI 接 ofdrw(Java):要塞 jar 包,违背鸿蒙原生应用的纯净度
- 纯 ArkTS 从零实现:包体积小、启动快,但没人做过
所以我决定走第三条。从零写一个纯 ArkTS 的 OFD 解析 + Canvas 渲染库。
技术选型对比
| 路线 | 包体积 | 启动速度 | 鸿蒙原生 | 维护成本 |
|---|---|---|---|---|
| WebView + PDF.js | 大 | 慢 | ❌ | 低 |
| JNI 接 ofdrw | 大 | 中 | ❌ | 中 |
| 纯 ArkTS 从零 | 小 | 快 | ✅ | 高(前期) |
纯 ArkTS 链路最短:
- 直接拿
PixelMap解码图片 - 通过
font.registerFont注册嵌入字体到系统字体表 - 用
@kit.CryptoArchitectureKit跑 SM2/SM3 国密验签(商业版能力) - 用 ArkUI
Canvas直绘所有内容
没有任何中间桥接,每个调用都是鸿蒙原生 API。
架构总览
整个库分四层:
┌──────────────────────────────────────────┐
│ UI 组件层 │
│ OFDPageView / OFDDocumentScroll / │
│ OFDThumbnailStrip / OFDSearchBar │
└──────────────────┬───────────────────────┘
│
┌──────────────────▼───────────────────────┐
│ Renderer 层 │
│ OFDRenderer — ArkUI Canvas 直绘 │
└──────────────────┬───────────────────────┘
│
┌──────────────────▼───────────────────────┐
│ Parser 层 │
│ OFDParser → OFDDocument │
│ (多页 + 资源索引) │
└──────────────────┬───────────────────────┘
│
┌──────────────────▼───────────────────────┐
│ 扩展点 │
│ ObjectParserExt / │
│ ObjectRendererExt / │
│ DocumentExtension │
└──────────────────────────────────────────┘
三类扩展点向上开放:验签、转换、表单都可以用同一套机制接入,不需要侵入核心库代码。
几个有料的技术细节
1. OFD 文字定位的反人类设计
OFD 文字对象用 DeltaX / DeltaY 数组定位每个字符的位置。听起来简单,但有几个坑。
坑 1:ST_Array 的 g N v 重复格式
<TextCode DeltaX="3.5 g 5 4.2 1.8">五个相同的字</TextCode>
这里 g 5 4.2 表示「重复 5 次 4.2」。完整解码后是:[3.5, 4.2, 4.2, 4.2, 4.2, 4.2, 1.8]。
坑 2:DeltaX 短于字符数
实际样本里经常出现 DeltaX 数组只有 3 个值但字符串有 5 个字。规范没写清楚怎么处理,实践中要重复最后一个值直到对齐:
function expandDeltaX(deltas: number[], charCount: number): number[] {
const result = [...deltas];
const last = deltas[deltas.length - 1] ?? 0;
while (result.length < charCount - 1) {
result.push(last);
}
return result;
}
坑 3:负 DeltaX 不能被自然宽度兜底覆盖
密码区那种文字会用负 DeltaX 把字符往回挤(实现多行布局)。早期我加了「如果 DeltaX 异常小就用字符自然宽度兜底」的逻辑——结果把负值也覆盖了,密码区直接错乱。后来改成:负值跳过兜底。
2. 字体处理:注册到系统字体表
OFD 的字体声明分两种:
- 嵌入字体:OFD 包里带
.ttf文件,字体名是 OFD 自己起的(如Font_F1) - 未嵌入字体:只声明字体名(如
宋体)指望系统有
嵌入字体的处理:解析后注册到 ArkUI 字体表:
import { font } from '@kit.ArkUI';
font.registerFont({
familyName: 'OFD_Embedded_F1',
familySrc: 'file:///path/to/extracted/font.ttf'
});
Canvas 绘制时直接用 OFD_Embedded_F1 这个字体名即可。
但大多数电子发票的字体只在 OFD 里写了 FontName="宋体",没把字体文件嵌进去——HarmonyOS 系统字体没有「宋体」,会退到 HarmonyOS Sans(无衬线),看起来比纸质发票的衬线宋体粗黑。
所以我加了一个全局兜底字体 API:
import { OFDRenderer } from 'ofdkit-harmony';
// 调用方先把自己的 OFL 字体注册进系统字体表
font.registerFont({
familyName: 'OFD-Fallback',
familySrc: 'file:///data/.../source-han-serif.ttf'
});
// 然后告诉 OFDRenderer:没嵌入字体的文字都用这个 family
OFDRenderer.setFallbackFontFamily('OFD-Fallback');
视觉效果立刻接近纸质发票。库本身不打包任何字体,字体来源由调用方决定——避免授权风险。
3. ZOrder 与模板页
OFD 的模板页有两种 ZOrder:
Background:模板内容在主页内容之下Foreground:模板内容在主页内容之上
电子发票的「红章框」通常是 Foreground 模板,要在最后绘制,否则会被发票数据盖住。完整渲染顺序:
// 1. 模板页 Background ZOrder
renderTemplate(page.template, 'Background');
// 2. 主页所有 PageObject
renderObjects(page.objects);
// 3. 模板页 Foreground ZOrder
renderTemplate(page.template, 'Foreground');
// 4. 注释 Annotations
renderAnnotations(page.annotations);
// 5. 签章 StampAnnot
renderStamps(page.stamps);
4. AbbreviatedData 路径命令
OFD 路径用 AbbreviatedData 编码,类似 SVG path 但语法不同:
| 命令 | 含义 |
|---|---|
S / M | StartPoint / MoveTo |
L | LineTo |
Q | 二次贝塞尔 |
B | 三次贝塞尔 |
C | Close |
A | 圆弧 |
A 命令最坑——OFD 的圆弧参数是 rx ry rotate largeArc sweep x y,跟 SVG 一样,但 Canvas 没有原生 SVG 圆弧 API,要自己换算成 ellipse(cx, cy, rx, ry, rotation, startAngle, endAngle, anticlockwise):
// 从 SVG 风格的圆弧参数推导 ellipse 的中心点 + 起止角
const { cx, cy, startAngle, endAngle } = arcEndpointToCenter(
startX, startY, endX, endY,
rx, ry, rotation, largeArc, sweep
);
ctx.ellipse(cx, cy, rx, ry, rotation, startAngle, endAngle, !sweep);
完整算法不展开,可以看仓库里 parsePath.ts 的实现。
5. OFD 套 OFD:矢量印章的递归解析
这是最有意思的一个细节。
电子发票的红章,它自己就是一个嵌入的 OFD 文件。完整结构是:
主 OFD 文档
└── StampAnnot(签章)
└── SES_Signature
└── picture
├── type='png' → 光栅印章,直接解码到 PixelMap
└── type='OFD' → 矢量印章,递归解析渲染
矢量印章在主文档里的渲染入口要走「嵌入 OFD」路径:
// OFDRenderer.renderEmbedded
const embeddedDoc = await OFDParser.parseFromBytes(stampOfdBytes);
const stampRenderer = new OFDRenderer({ /* 独立资源域 */ });
stampRenderer.renderPage(embeddedDoc.pages[0], stampBoundary);
关键坑:嵌入 OFD 的字体名经常跟主文档冲突(都叫 Font_F1)。我加了字体命名空间隔离——嵌入文档的字体注册时加 stamp_${stampId}_ 前缀,绘制时也用带前缀的字体名,避免覆盖主文档字体。
踩坑日志
除了上面技术细节里说过的,还有几个值得一提的小坑:
1. LongPressGesture 被 PanGesture 抢占
长按选中文字的手势放在主 GestureGroup 里,会被 PanGesture 优先识别——长按 500ms 触发不了。解决:用 priorityGesture 独立挂载 LongPress:
.priorityGesture(
LongPressGesture({ repeat: false, duration: 500 })
.onAction((event) => this.handleLongPress(event))
)
.gesture(GestureGroup(/* pan + pinch + ... */))
priorityGesture 比子组件和兄弟手势先识别,且独立维护识别状态。
2. 模板页有时不带 PhysicalBox
规范说模板页应该带 Area 属性指定页面尺寸,但实际样本里大多数模板页不带——只在主页里有 PhysicalBox。早期我对模板页 Area 强校验,直接解析失败。后来放宽:模板页 Area 可选,缺省时继承主页 PhysicalBox。
3. OFD 自身把路径大小写写错了
部分签发工具产出的 OFD 包路径大小写不一致——XML 里写 Doc_0/Document.xml,实际文件名是 doc_0/document.xml。直接按声明的路径取文件会拿不到。
解决:加 resolveCaseInsensitive 兜底,从文件名 lowercase 后的索引里找匹配项。
4. 部分电子发票 TextObject/PathObject 不带 ID
规范要求每个对象有 ID 属性,但实际有些电子发票样本完全不带——大概是签发工具偷懒。早期我把 ID 当必需字段,直接解析报错。后来把 ID 改成可选,渲染时用对象顺序号兜底。
5. SES_Signature 完整性校验的差异(商业版踩的坑)
各家签发工具(数科 / 福昕 / CFCA)对 Reference 路径规范、SignedInfo dataHash 字节范围的约定不完全一致。按严格策略校验会把合法签章误判为篡改。商业版采用保守策略:SM2 数学验签通过 = 一律 valid,完整性不一致只记录不下调状态。
核心能力演示
当前支持:
- ✅ 多页 OFD 文档打开 + 模板页 + 注释
- ✅ 双指捏合缩放、单指拖动、双击复位
- ✅ 侧滑切页 + 多页连续滚动 + 缩略图条
- ✅ 跨页全文搜索 + 命中高亮 + 上下跳转
- ✅ 长按选中文字 → 复制到剪贴板
- ✅ 嵌入字体注册 + 全局兜底字体 API
快速接入
import {
OFDParser,
OFDPageView,
installDefaultExtensions
} from 'ofdkit-harmony';
// 应用启动时调用一次
installDefaultExtensions();
// 解析
const parser = new OFDParser({ workDir: '/path/to/cache' });
const doc = await parser.parse('/path/to/file.ofd');
// 渲染
@Component
struct Reader {
@State doc: OFDDocument = doc;
@State pageIndex: number = 0;
build() {
OFDPageView({ page: this.doc.pages[this.pageIndex] })
}
}
OFDPageView 内部自动完成本页字体注册、图片解码、Canvas 绘制和手势。
完整集成代码(连续滚动 + 缩略图 + 全文搜索三件套)见 README API 概览。
项目地址 + 开放协作
- Gitee 主仓:gitee.com/notcoder/of…
- GitHub 镜像:github.com/monotcoder/…
- 协议:Apache License 2.0
欢迎提 Issue / PR,讨论请到 Gitee 主仓。
国密 SM2/SM3 签章验签、光栅 / 矢量印章绘制等商业能力放在闭源版本 ofdkit-harmony-pro,有企业场景需求可以联系作者(README 文末有微信)。
如果这篇文章对你有用,给个 ⭐ 是最大的鼓励。下一步会推进 CMYK / Pattern / Gradient 色彩空间、表单填充等基础渲染能力。