# 手把手：拍一张发票，让报销单自己填好 # 智绘鸿蒙·如 7 而至#

## 前言

出差回来要贴发票。一百多张，每张都要把金额、日期、发票代码抄进报销表，抄错一个小数点就得重来。

如果有个功能：手机对着发票拍一下，三个字段自动落进表格——这篇文章就带你做这个。

拆开看，这件事只有两小步：**把图里的字认出来**，和**从一堆字里挑出我要的三个值**。

第二步听起来简单，其实坑最多。我们后面会亲眼看到它把 `11.04` 当成报销金额。

第一步交给鸿蒙的现成能力：**通用文字识别**（`textRecognition`），归属 `@kit.CoreVisionKit`。不需要联网、不需要申请任何权限、不用自己塞模型。

这里要特别说明一点：发票上有公司税号，属于敏感数据。**"识别过程完全在设备本地完成"这件事，对能不能上架、用户愿不愿意用，都是关键。**

## 先认识一下它给的数据长什么样

很多人以为 OCR 返回的就是一串字符串。不完全是，它的返回是**带层级的**：

```
TextRecognitionResult
├── value: string                    // 全部文本，就是我们以为的那串字
└── blocks: TextBlock[]              // 文本块（一段）
        ├── value: string
        └── lines: TextLine[]        // 行
                ├── value: string
                ├── cornerPoints: PixelPoint[]   // 这一行的四个角点
                └── words: TextWord[]            // 词
                        ├── value: string
                        └── cornerPoints: PixelPoint[]
```

> - **OCR（Optical Character Recognition）**：光学字符识别，把图片里的文字转成可编辑文本。
> - **PixelPoint**：一个二维坐标点 `{ x, y }`。
> - **cornerPoints**：四个角点，因为一行文字可能是倾斜的，用矩形框表达不了。

注意两点：

1. **坐标是 `cornerPoints`（四个点），不是 `boundingBox`（一个矩形）。** 你想做"点哪复制哪"，得用这四点算区域。
2. **`value` 是拼接结果，`blocks / lines / words` 才是结构。** 只拿 `value` 做正则，等于放弃了模型已经帮你切好的行。

接口本身很薄：

```typescript
textRecognition.init(): Promise<boolean>                                  // 5.0.0(12) 起
textRecognition.release(): Promise<void>                                  // 5.0.0(12) 起
textRecognition.getSupportedLanguages(): Promise<Array<string>>
textRecognition.recognizeText(visionInfo, configuration?): Promise<TextRecognitionResult>
```

`configuration` 目前只有一个字段 `isDirectionDetectionSupported`——是否允许旋转文字方向检测。

## 我们一步一步写

### 第一步：起一个引擎壳子

`init()` 是全局的，不要每次识别都调。我们用一个布尔位记住状态：

```typescript
export class OcrRunner {
  private inited: boolean = false;

  async init(): Promise<void> {
    if (this.inited) {
      return;
    }
    try {
      this.inited = await textRecognition.init();
    } catch (err) {
      const e = err as BusinessError;
      throw new Error(`OCR 服务初始化失败（${e.code}）：${e.message}`);
    }
  }

  async release(): Promise<void> {
    if (!this.inited) {
      return;
    }
    await textRecognition.release();
    this.inited = false;
  }
}
```

顺手把 `getSupportedLanguages()` 打出来看看它到底认几种字。我们在真机上实测返回：

```
zh-CN / en / ja / ko / zh-TW
```

五种。也就是说简中、繁中、英、日、韩都在能力范围内——**但它是按字符识别的，不做语义纠错**，这一点第三节末尾会看到后果。

### 第二步：拿到 PixelMap

样票走 `rawfile`，真实票据从相册选。两条路都是 `createImageSource` → `createPixelMap`：

```typescript
async decode(bytes: ArrayBuffer): Promise<image.PixelMap> {
  const source: image.ImageSource = image.createImageSource(bytes);
  const pm: image.PixelMap = await source.createPixelMap();
  await source.release();
  return pm;
}
```

从相册导入时传 `fd`：`image.createImageSource(fd.fd)`。

别忘了 `source.release()`。`ImageSource` 持有原生资源，页面反复进出会累积。

### 第三步：识别

```typescript
const info: textRecognition.VisionInfo = { pixelMap: src };
const conf: textRecognition.TextRecognitionConfiguration = { isDirectionDetectionSupported: rotate };
const res: textRecognition.TextRecognitionResult = await textRecognition.recognizeText(info, conf);
```

然后自己数一遍结构（返回值里没有现成的行数和词数）：

```typescript
let lines: number = 0;
let words: number = 0;
for (let i = 0; i < res.blocks.length; i++) {
  const bl: textRecognition.TextLine[] = res.blocks[i].lines;
  lines += bl.length;
  for (let j = 0; j < bl.length; j++) {
    words += bl[j].words.length;
  }
}
```

这三个数字一定要显示出来，理由见下一节。

### 第四步：抽字段——真正的坑在这里

我们的目标是三个值：金额、日期、号码。第一版正则我是这么写的（**这是错的，故意留着**）：

```typescript
const money = raw.match(/([¥￥]\s*\d[\d,]*(?:\.\d{1,2})?)|(\d[\d,]*\.\d{2})/);
```

看起来很合理：匹配一个带货币符号的金额，或者一个两位小数。

真机跑出来的结果：**金额 = `11.04`**。

为什么？因为增值税专用发票的表格里有十几行明细，每行都有单价、金额、税额三个小数。`11.04` 是票面上**第一个**出现的两位小数——它命中了正则的第二个分支，而正则只返回第一个匹配。真正的价税合计 `¥1401.08` 在票面靠下的位置，根本没轮到。

修正的思路：**不要找"第一个像金额的"，要找"最像合计的"**。票面上带 `¥` 符号的金额里，最大的那个就是价税合计：

```typescript
/** 票面上有十几个小数，取带货币符号的最大值才是价税合计。 */
static pickAmount(raw: string): string {
  const all: RegExpMatchArray | null = raw.match(/[¥￥]\s*(\d[\d,]*(?:\.\d{1,2})?)/g);
  if (!all || all.length === 0) {
    return '未识别';
  }
  let best: number = -1;
  let bestText: string = '';
  for (let i = 0; i < all.length; i++) {
    const num: number = Number(all[i].replace(/[^\d.]/g, ''));
    if (num > best) {
      best = num;
      bestText = all[i].trim();
    }
  }
  return bestText;
}

/** 优先取标签后的编号，避免命中统一社会信用代码。 */
static pickCode(raw: string): string {
  const m: RegExpMatchArray | null =
    raw.match(/(?:发票代码|發票代码|发票号码|發票号码|号码|码)[:：]?\s*(\d{8,20})/);
  if (m) {
    return m[1];
  }
  const any: RegExpMatchArray | null = raw.match(/\d{10,20}/);
  return any ? any[0] : '未识别';
}
```

改完再跑，三个字段全对：**金额 `¥1401.08`、日期 `2024年04月24日`、号码 `044032100111`**。

请注意这里的一个细节：`pickCode` 里我把繁体 `發票代码` 也写进了候选。原因是下面要讲的那个现象。

### 第五步：别忘了 release

```typescript
aboutToDisappear(): void {
  this.runner.release();
}
```

## 真机运行结果

测试机：HUAWEI MatePad Pro（MRDI-W00），HarmonyOS 7.0.0.107，API 26。样票是一张 720×900 的增值税发票照片。

识别完成后的界面：

![](assets/01-top.png)

自动抽取的三个字段：

![](assets/02-fields.png)

实测产出 **71 块 / 73 行 / 337 词 / 539 字，耗时 3330 ms**。

## 三个必须知道的结论

**结论一：耗时不低，必须给进度反馈。**

三秒多不是"点一下就出来"的体验，是"必须转圈并允许取消"的体验。而且耗时和文字量强相关，一张密密麻麻的行程单会更久。

**结论二：OCR 忠实反映源图质量，不做语义纠错。**

我们这张样票本身就有字形瑕疵，识别结果原样保留了：

- `显示器` 被识别成 `服示暴`
- `1TB` 被识别成 `ITB`
- `键盘` 被识别成 `健盤`
- 整张票**简繁混排**：`電子发票`、`發票代码`、`納税人識別號`、`统一社會信用代碼`

这不是 OCR 的 bug，恰恰说明它工作得很诚实：**图里长什么样，它就读成什么样**。

所以做产品时，"识别准确率"这个指标其实是由拍照质量决定的，不是你决定的。你能决定的是——给用户提供"识别结果可编辑"，以及把置信度低的字段标出来让人复核。

顺带一句：正因为简繁混排，我们的正则必须同时兼容简繁两种写法，否则繁体那半边的标签根本匹配不上。

**结论三：别只拿 `value` 做正则。**

`res.blocks[i].lines[j]` 是模型已经帮你切好的"行"。发票这种强结构文档，按行匹配（比如"找到含`合计`的那一行，再在行内取金额"）比在整段文本里捞数字可靠得多。我们的 `pickAmount` 之所以能用"取最大值"这种土办法，是因为价税合计恰好是票面最大金额——换一个票种就不成立了。

## 往生产走还差什么

1. **字段校验**：金额要能对上"大写金额"（票面有 `壹仟肆佰零壹圆零捌分`），对不上就标红让人复核。
2. **多图批量**：串行跑，别并发；一张 3.3 秒，十张 33 秒，进度条必须有。
3. **失败兜底**：`init()` 失败、识别返回空文本，都要有明确提示，不能静默。
4. **隐私声明**：虽然全程端侧，仍建议在界面上写一句"票据数据不会离开本机"，这是用户真正关心的。

## 总结

什么场景值得用它：报销、巡检、归档这类"拍张单据填表"的活。端侧 OCR 把字认全，简繁中英日韩都在能力范围内，票据数据不出设备。

不适合：指望它直接给你结构化数据。它交出来的是文本，字段还得自己抽，而且要预留抽错被人工复核的兜底。

> 💡 **一句话**：OCR 认字很靠谱，认业务不靠谱——那个被当成报销金额的 `11.04` 就是教训。

## 参考文献

1. Core Vision Kit 开发指南 · 通用文字识别：https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-text-recognition
2. Core Vision Kit API 参考 · textRecognition（文字识别）：https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-text-recognition-api
3. Core Vision Kit 错误码：https://developer.huawei.com/consumer/cn/doc/harmonyos-references/errorcode-core-vision
