我做了一个鸿蒙原生 OFD 库——纯 ArkTS、无 WebView、跑通电子发票全流程

0 阅读7分钟

我做了一个鸿蒙原生 OFD 库——纯 ArkTS、无 WebView、跑通电子发票全流程

tl;dr:HarmonyOS NEXT 上首个开源的原生 OFD 阅读库 ofdkit-harmony 发布。纯 ArkTS 实现,遵循 GB/T 33190-2016。本文聊我为什么从零写一个、踩了哪些坑、关键技术怎么解决。

缘起:鸿蒙生态缺一个 OFD 阅读器

OFD 是中国国标电子文档格式(GB/T 33190-2016),承载了几乎所有电子发票、电子公文、电子合同。

前段时间我在做一个鸿蒙原生应用,需要打开电子发票——然后发现:鸿蒙生态目前没有可用的原生 OFD 阅读方案。能找到的只有三条路:

  1. WebView 嵌套 PDF.js + OFD→PDF 转换:包体积大、启动慢、签章信息丢失
  2. JNI 接 ofdrw(Java):要塞 jar 包,违背鸿蒙原生应用的纯净度
  3. 纯 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 / MStartPoint / MoveTo
LLineTo
Q二次贝塞尔
B三次贝塞尔
CClose
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,完整性不一致只记录不下调状态。

核心能力演示

demo

当前支持:

  • ✅ 多页 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 概览

项目地址 + 开放协作

欢迎提 Issue / PR,讨论请到 Gitee 主仓。

国密 SM2/SM3 签章验签、光栅 / 矢量印章绘制等商业能力放在闭源版本 ofdkit-harmony-pro,有企业场景需求可以联系作者(README 文末有微信)。

如果这篇文章对你有用,给个 ⭐ 是最大的鼓励。下一步会推进 CMYK / Pattern / Gradient 色彩空间、表单填充等基础渲染能力。