前言

文档扫描封面

会议通知、纸质表格或课堂资料需要放进应用时,用户通常会先拍一张照片。照片里可能带有桌面,纸张边缘也可能倾斜。等到需要把它作为附件发送或保存时,用户还得回到图库裁剪,再决定使用图片还是文档格式。

VisionKit 的 DocumentScanner 可以提供拍摄、选择图片和文档编辑界面。用户完成裁剪或调整后,应用再接收扫描结果,把文件保存到自己的目录。接入时需要把界面中的保存选择和回调中的文件类型对应起来,这样图片预览和 PDF 附件才能进入各自合适的处理流程。

一、为扫描页面准备输入和保存选项

页面提供两个入口。点击拍摄或图库时,扫描界面负责获取图片。点击固定文档时,应用先准备一张已有图片,再把它交给扫描控件。两种入口最终都进入同一个编辑流程,用户可以在保存前确认文档边缘和文字内容。

已有图片放在 entry/src/main/resources/rawfile 中,名称为 ocr.png。应用资源适合随程序一起分发,但扫描控件接收的是可访问的文件 URI,资源名称本身不能直接作为文件地址使用。因此页面先读取资源内容,将它复制到应用文件目录,再使用 fileUri.getUriFromPath() 把文件路径转换为 URI。

资源管理器读取的是打包在应用中的原始文件内容,context.filesDir 则指向应用可以管理文件的位置。页面完成复制后,新的文件才有一个可以转换的沙箱路径。扫描控件拿到 URI 时读取的是这份文件,因此需要等写入和关闭完成,再开始显示扫描界面。

复制完成以后,页面把这个 URI 放入 originalUris,再显示扫描控件。用户选择拍摄或图库入口时,页面将 originalUris 设为空,让扫描流程自行取得内容。每次打开扫描页面都重新设置输入,可以避免上一次准备的固定图片继续留在配置中。

通过 originalUris 传入的图片还需要满足扫描控件的尺寸要求:单边长度为 224 至 8000 像素,宽高乘积不超过 4800 万,长边与短边的比例不超过 3。控件会过滤不符合条件的图片。如果固定文档没有进入编辑页面,可以先检查复制后的图片是否满足这些条件。

扫描配置同时指定处理类型和保存选项。当前页面使用普通文档类型,并提供 JPEG 和 PDF 两种输出。用户在扫描界面完成编辑后,可以按接下来的用途选择图片或文档文件。

  private scannerConfig: DocumentScannerConfig = { supportType: [DocType.DOC], saveOptions: [SaveOption.JPG, SaveOption.PDF], isShareable: false };

supportType 限定扫描页面提供的文档类型,saveOptions 决定保存时可以选择哪些格式。页面把 isShareable 设为 false,让本次操作先回到应用接收文件。要调整支持的选项,应同时检查回调处理是否认识新的返回类型,避免界面开放了一种格式,应用却没有对应的接收方式。

配置只是告诉控件允许哪些操作,用户还要在扫描界面中完成选择。应用创建控件时同时传入配置和 onResult 回调,才能在用户结束操作后知道选择了哪种保存方式。页面不需要在用户编辑的每一步读取图片,接收逻辑集中在结果返回以后执行。

二、让用户完成编辑,再接收返回结果

页面使用 scanning 控制当前显示内容。打开扫描流程时,页面显示 DocumentScanner,用户可以在系统提供的编辑界面里查看图片并调整文档。应用自己的结果区域等到回调返回后再显示,因此不会因为扫描界面已经打开,就提前把文件标记为保存成功。

扫描编辑界面

编辑时需要结合材料内容判断是否调整。裁剪可以去掉文档外的桌面,但边缘靠得太近时也可能切掉页码或批注。滤镜能够改变对比度,带有印章、彩色图表的材料则需要确认颜色和文字仍然清晰。用户在保存前检查这些内容,应用收到的才是已经确认过的结果。

用户选择保存以后,控件通过 onResult 把返回码、保存类型和地址数组交给页面。返回码说明当前操作是否成功,保存类型告诉应用结果属于 JPEG 还是 PDF,地址数组则提供需要读取的资源。页面收到回调后先退出扫描界面,再判断返回码和地址是否满足接收条件。

地址指向的是扫描流程交出的资源,并不等于文件内容已经存进应用的附件目录。页面取得地址后还要逐项打开并复制,复制成功以后才能记录目标路径。如果只把返回地址显示在列表中,应用自己的文件管理就缺少了明确的保存位置。

当前代码在返回码为 200 且地址数组非空时开始复制。没有取得可用地址时,页面显示本次状态,不创建空附件。扫描页面的取消、返回和失败都可能让应用没有可接收的文件,因此页面应以实际回调为依据,而不是只根据用户是否打开过扫描界面判断结果。

三、把返回资源保存到应用目录

扫描控件返回资源地址以后,应用还需要决定文件由谁持有。当前页面逐项打开返回的 URI,使用 fileIo.copyFileSync() 把内容复制到 context.filesDir。复制完成后,页面保存目标文件路径,后续展示和附件处理都可以从应用自己的文件目录继续。

打开返回地址时使用只读模式,写入目标位置由复制接口完成。每个地址都对应一次文件打开操作,代码把关闭描述符放在 finally 中。这样某一项复制失败时,已经打开的文件仍会经过关闭路径,页面也能进入错误处理,不会继续把这次操作显示为全部成功。

文件描述符可以理解为应用打开源资源后取得的操作入口。复制接口通过它读取内容,目标路径则指出数据要写入哪里。当前实现逐个处理地址,只有一次复制完成后才继续下一项,因此文件与结果数组中的顺序能够保持对应。

文档扫描与文件接收流程

输出文件名由返回类型和数组下标组成。JPEG 使用 .jpg,PDF 使用 .pdf,页面根据同一个保存类型选择文件名和后续分支。扩展名有助于应用识别文件用途,真正决定内容格式的是扫描控件返回的类型,单纯修改后缀不能完成图片与 PDF 之间的转换。

地址数组表示本次返回的文件资源。应用遍历数组后,把成功接收的目标路径加入 scannedFiles,再更新页面中的文件列表。PDF 文件本身可以包含页面,返回地址数量与文档页数并不是同一个概念。需要展示页数时,应用应读取文档内容,不能直接拿地址数量充当页数。

四、分别处理图片预览和文档文件

两种输出进入应用以后,后续操作不同。JPEG 可以解码为像素图,页面可以直接显示预览,也可以把图片交给其他图像处理能力。PDF 适合按照文档文件交给附件或文档查看流程,页面需要保留文件路径和格式信息,才能在后续打开时选择合适的处理方式。

当前页面复制 JPEG 后,使用图像源解码第一份图片,再把返回的像素图交给 Image 组件。PDF 分支保留已接收文件的信息,不会把 PDF 文件直接传给图片解码器。用户先保存 JPEG、又保存 PDF 时,页面会清理原来的图片预览,让结果区域对应最近一次选择的输出类型。

JPEG 文件保存的是编码后的图片数据,页面需要创建图像源并解码,才能得到用于显示的像素图。图像源和打开的文件在解码结束后关闭,像素图继续交由页面持有。再次接收结果时,页面先释放上一次预览,再根据新的文件类型决定是否创建图片。

一次回调如果返回多个 JPEG 地址,复制循环会逐项保存,预览代码则只解码 paths[0]。因此,文件列表可以包含多张图片,预览区域显示的仍然只有第一张。如果应用需要让用户逐页检查,可以基于已经保存的路径增加切换入口,再按选中的文件创建预览。页面目前没有这个切换操作。

应用接收的导出文件

文件名、大小和预览可以帮助确认应用接收了什么,但它们分别回答不同问题。文件名和扩展名帮助区分附件类型,图片预览用于检查解码后的内容,PDF 是否能够打开还需要交给文档查看能力检查。应用若要支持搜索文档中的文字,也需要单独处理文字内容,不能仅凭保存格式判断已经具备全文检索。

文件列表中的大小由 fileIo.statSync(path).size 读取,数值来自复制后的目标文件。它有助于检查文件是否写入了数据,图片内容是否完整还要结合解码和预览判断。

当前命名方式使用 scan-0.jpg 或 scan-0.pdf 这样的路径。再次保存同一种格式时,数组下标会重新从零开始,同名文件可能被覆盖。路径列表保留的是保存位置,重复出现同一路径也不代表已经保留多份独立文件。需要长期保存多次扫描结果的应用,应为每次操作分配独立文件名,并将路径写入自己的附件记录。

五、让失败后的页面仍然有明确状态

文件接收可能发生在不同阶段。返回地址无法打开时,页面还没有取得源文件描述符,可以先检查扫描结果和资源访问是否正常。如果文件已经打开但复制失败,则需要继续检查目标目录、文件写入和可用空间。把错误码和消息显示出来,能够帮助开发者从发生问题的步骤开始排查。

页面在复制所有返回资源后才把目标路径加入列表。如果复制过程中途出错,已经写入目录的文件可能仍然存在,但本次路径尚未全部加入列表。面向长期附件管理时,可以补充逐项记录或失败清理,避免磁盘中的文件和附件列表脱节。当前页面保留了失败提示,没有把这些后续管理动作写成已经完成。

JPEG 预览发生在文件接收之后。若文件已经复制成功,而图片解码失败,应用目录中的文件仍然存在,页面会显示错误信息。检查时需要区分保存文件与生成预览两个步骤:前者决定附件是否已进入应用目录,后者决定能否在当前页面直接显示。

页面离开时,文件复制或图片解码可能还在进行。代码先标记页面已经离开,等待当前页面任务结束,再释放持有的预览像素图。如果解码完成时页面已经销毁,新得到的像素图会直接释放,不再交给界面显示。这样用户离开以后,尚未结束的文件处理也有明确的收尾位置。

页面还用 activeJob 记录是否有操作正在执行。用户触发接收文件以后,入口会拦住重复操作,直到当前任务结束才允许再次进入,避免多次点击同时写入相同名称。扫描界面出现期间,原页面的按钮不再显示,扫描控件通过回调将本次操作结果交回应用。

总结

文档扫描从准备图片或打开拍摄入口开始,用户在编辑界面确认内容,再选择 JPEG 或 PDF。应用根据回调中的返回码、类型和资源地址接收文件,并把它们复制到自己的文件目录。

DocumentScanner 负责扫描交互,originalUris 用于传入已有图片,onResult 把保存结果交还应用。文件接口负责读取和复制资源,图片解码器用于展示 JPEG,PDF 则作为文档文件保留。

大家可以先准备一张包含文字的图片,使用固定文档入口完成编辑,分别尝试保存 JPEG 和 PDF。检查应用接收到的文件名与预览,再根据自己的附件需求调整保存位置和命名方式。

核心关键代码

截图中的页面带有功能目录。下面的代码将文档扫描整理为独立页面,启动后可以选择拍摄或读取工程中的固定图片。

在自己的工程中准备 ocr.png,放入 entry/src/main/resources/rawfile。将页面放在 entry/src/main/ets/pages/Index.ets,并在 entry/src/main/resources/base/profile/main_pages.json 的 src 中包含 pages/Index。入口 Ability 的 loadContent() 需要加载这个页面。

扫描与文件接收:Index.ets

页面包含扫描配置、已有图片的 URI 准备、结果回调和 JPEG 预览。关键操作旁的注释说明了文件关闭与页面退出时的处理顺序。

import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo, fileUri } from '@kit.CoreFileKit';
import { DocumentScanner, DocumentScannerConfig, DocType, SaveOption } from '@kit.VisionKit';

@Entry
@Component
struct Index {
  @State scanning: boolean = false;
  @State busy: boolean = false;
  @State status: string = '选择文档来源';
  @State detail: string = '';
  @State output: image.PixelMap | undefined = undefined;
  @State scannedFiles: string[] = [];
  private alive: boolean = true;
  private activeJob: boolean = false;
  private pagePending: Promise<void> | undefined = undefined;
  private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  private scannerConfig: DocumentScannerConfig = { supportType: [DocType.DOC], saveOptions: [SaveOption.JPG, SaveOption.PDF], isShareable: false };

  aboutToAppear(): void { this.alive = true; }
  aboutToDisappear(): void { this.alive = false; this.dispose(); }

  private launch(action: () => Promise<void>): void {
    if (this.activeJob || !this.alive) { return; }
    this.activeJob = true;
    this.pagePending = action().catch((err: BusinessError) => { if (this.alive) { this.fail(err); } })
      .finally(() => { this.activeJob = false; });
  }

  private async dispose(): Promise<void> {
    // 文件复制和图片解码结束后,再释放页面持有的预览图。
    try { await this.pagePending; } catch (_) { /* 任务入口已处理错误。 */ }
    this.output?.release(); this.output = undefined;
  }

  private fail(err: BusinessError): void {
    if (!this.alive) { return; }
    this.status = '处理失败,可检查文件后重试';
    this.detail = '错误码:' + (err.code ?? -1) + '\n' + err.message;
  }

  private async copyRaw(name: string): Promise<string> {
    const bytes = await this.context.resourceManager.getRawFileContent(name);
    const path = this.context.filesDir + '/' + name;
    const file = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
    try { fileIo.writeSync(file.fd, bytes.buffer as ArrayBuffer); } finally { fileIo.closeSync(file); }
    return path;
  }

  private async scanner(fixed: boolean): Promise<void> {
    if (this.busy) { return; }
    this.busy = true;
    try {
      // 已有图片先复制到应用目录,扫描控件接收文件 URI。
      this.scannerConfig.originalUris = fixed ? [fileUri.getUriFromPath(await this.copyRaw('ocr.png'))] : [];
      if (!this.alive) { return; }
      this.scanning = true;
    } catch (err) { this.fail(err as BusinessError); }
    finally { if (this.alive) { this.busy = false; } }
  }

  private async scannerResult(code: number, type: SaveOption, uris: string[]): Promise<void> {
    this.scanning = false;
    // 只有扫描成功并返回资源地址时,才复制文件。
    if (code !== 200 || uris.length === 0) { this.status = '扫描未返回可保存结果:' + code; return; }
    this.busy = true;
    try {
      const paths: string[] = [];
      for (let i = 0; i < uris.length; i++) {
        const file = fileIo.openSync(uris[i], fileIo.OpenMode.READ_ONLY);
        try {
          const path = this.context.filesDir + '/scan-' + i + (type === SaveOption.PDF ? '.pdf' : '.jpg');
          fileIo.copyFileSync(file.fd, path); paths.push(path);
        } finally { fileIo.closeSync(file); }
      }
      this.scannedFiles = this.scannedFiles.concat(paths);
      this.status = '已接收并保存 ' + uris.length + ' 份' + (type === SaveOption.PDF ? ' PDF' : ' JPEG');
      this.detail = this.scannedFiles.map((path) => path.substring(path.lastIndexOf('/') + 1) + ' · ' + fileIo.statSync(path).size + ' 字节').join('\n');
      this.output?.release(); this.output = undefined;
      if (type === SaveOption.JPG) {
        // 只把图片结果交给图像解码器,PDF 保留为文档文件。
        const file = fileIo.openSync(paths[0], fileIo.OpenMode.READ_ONLY);
        let source: image.ImageSource | undefined = undefined;
        try {
          source = image.createImageSource(file.fd);
          const pm = await source.createPixelMap();
          if (!this.alive) { pm.release(); return; }
          this.output = pm;
        } finally { source?.release(); fileIo.closeSync(file); }
      }
    } catch (err) { this.fail(err as BusinessError); }
    finally { if (this.alive) { this.busy = false; } }
  }

  build() {
    Column({ space: 12 }) {
      if (this.scanning) {
        DocumentScanner({ scannerConfig: this.scannerConfig, onResult: (code: number, type: SaveOption, uris: string[]) => {
          this.launch(() => this.scannerResult(code, type, uris));
        } }).width('100%').height('100%')
      } else {
        Text('文档扫描与导出').fontSize(23).fontWeight(FontWeight.Bold).width('100%')
        Row({ space: 8 }) {
          Button('拍摄或图库').enabled(!this.busy).onClick(() => { this.launch(() => this.scanner(false)); })
          Button('固定文档').enabled(!this.busy).onClick(() => { this.launch(() => this.scanner(true)); })
        }
        if (this.output) { Image(this.output).height(330).objectFit(ImageFit.Contain) }
        Text(this.status).fontColor('#145897').width('100%')
        Text(this.detail).fontSize(14).width('100%')
      }
    }.width('100%').height('100%').padding({ top: 16, left: 18, right: 18, bottom: 16 }).backgroundColor('#F1F4F9')
  }
}
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐