跳转至正文

如何在 Capacitor App 中扫描文档并生成 PDF

在 Capacitor 应用中扫描文档并转为 PDF:使用原生扫描器捕获页面,生成单个 PDF,然后在 iOS 和 Android 上保存、分享、打开或打印。

在 Capacitor 移动应用中扫描纸质文档并生成 PDF

要在 Capacitor 应用中扫描文档并生成 PDF,请使用 @capgo/capacitor-document-scanner,获取其返回的 JPEG 页面,并使用 pdf-lib将它们合并为一个 PDF。然后使用 @capacitor/filesystem 写入 PDF,并将其传递给分享面板、PDF 查看器或系统打印对话框。本指南将详细介绍在 Capacitor 8 上运行的 code 的完整流程,支持 iOS 和 Android。

iOS 和 Android 如何实现扫描转 PDF

两个平台都自带系统文档扫描器,因此你无需自行开发边缘检测或透视校正功能:

iOS Android
原生扫描器 VisionKit VNDocumentCameraViewController Google ML Kit 文档扫描器(Play 服务)
边缘检测与裁剪 是 是
滤镜 / 清理 VisionKit 内置 base, base_with_filter, full 模式(污渍和指纹去除在 full)
每次会话的页数限制 24 1 到 24,默认 20
相机权限提示 需要 NSCameraUsageDescription 不需要,扫描器在 Play 服务中运行
支持模拟器 否 否
插件输出 JPEG 页面 JPEG 页面

该插件返回的是图片,而不是 PDF。这是有意为之的拆分:页面大小、压缩和文件名都由你控制,同一个 PDF code 既适用于从相册选择的照片,也适用于从后端接收的文件。

安装插件

bun add @capgo/capacitor-document-scanner @capacitor/filesystem pdf-lib
bunx cap sync

扫描仪插件遵循 Capacitor 的主版本号,因此插件 v8 对应 Capacitor 8。 pdf-lib 是一个纯 JavaScript 库,没有原生 code,因此它在 WebView 中运行,无需同步。

iOS 配置

将相机用途说明添加到 ios/App/App/Info.plistVisionKit 会打开相机,如果缺少这个键,iOS 会终止应用:

<key>NSCameraUsageDescription</key>
<string>We use the camera to scan your documents.</string>

该插件的 Swift Package 面向 iOS 15 或更高版本,与 Capacitor 8 的最低要求一致。请注意,App Store 上传现在要求 Xcode 26,所以提交前请用它构建。如果你没有 Mac, Capgo 构建 可以在云端生成签名的 iOS 二进制文件。

Android 配置

无需添加 AndroidManifest.xml。ML Kit 扫描器是 Google Play 服务的一个模块,首次使用时会自动下载并自行处理相机,因此你的应用无需请求 CAMERA。设备需要 Google Play 服务,这排除了大多数模拟器和去 Google 化的手机。如果发布到缺少 Play 服务的市场,请规划好回退方案(例如普通相机拍摄)。

扫描页面

从一个打开扫描器并返回页面路径的函数开始,或者 null 当用户取消时:

import {
  DocumentScanner,
  ResponseType,
  ScannerMode,
  ScanDocumentResponseStatus,
} from '@capgo/capacitor-document-scanner';

export async function scanPages(): Promise<string[] | null> {
  const result = await DocumentScanner.scanDocument({
    responseType: ResponseType.ImageFilePath,
    maxNumDocuments: 20,
    letUserAdjustCrop: true,
    scannerMode: ScannerMode.Full, // Android only
    croppedImageQuality: 90,       // Android only
  });

  if (result.status === ScanDocumentResponseStatus.Cancel) {
    return null;
  }
  return result.scannedImages ?? [];
}

有几个选项值得了解:

  • responseType: ImageFilePath (默认)返回文件路径, Base64 返回 base64 字符串。对于多页文档,建议优先使用文件路径。20 页高分辨率图像的 base64 字符串可能达到数十兆字节,且每个字节都会通过 Capacitor 桥接传输。
  • maxNumDocuments:将其设置为 1 适用于收据或身份证等单页流程。
  • reviewCapturedDocument:继续前显示每个拍摄的页面,用户扫描重要文件时很有用。
  • brightness 以及 contrast:对图像应用的后处理。对比度为 1.2 改为 1.5 通常有助于处理褪色的收据。
  • scannerMode (Android): Full 添加了基于 ML 的清理功能, Base 速度更快且保持图像原样。

文件最终存放的位置因平台而异。在 Android 上,页面会进入应用缓存目录;在 iOS 上,插件会把它们写入应用的 Documents 目录。这两个位置都不适合长期存放,因此下面的流程会生成 PDF、把它存到你指定的位置,并删除页面图片。

将页面合并为一个 PDF

pdf-lib 可以直接嵌入 JPEG 而无需重新编码,因此 PDF 体积接近各页文件大小之和。通过以下方式读取每一页 Capacitor.convertFileSrc,将原生路径转换为 WebView 可以获取的 URL:

import { Capacitor } from '@capacitor/core';
import { PDFDocument } from 'pdf-lib';

const A4 = { width: 595.28, height: 841.89 }; // points

export async function pagesToPdf(paths: string[]): Promise<string> {
  const pdf = await PDFDocument.create();
  pdf.setTitle('Scanned document');
  pdf.setCreator('My Capacitor App');

  for (const path of paths) {
    const bytes = await fetch(Capacitor.convertFileSrc(path)).then((r) => r.arrayBuffer());
    const image = await pdf.embedJpg(bytes);

    // Fit the image inside an A4 page, keeping its aspect ratio
    const scale = Math.min(A4.width / image.width, A4.height / image.height);
    const width = image.width * scale;
    const height = image.height * scale;

    const page = pdf.addPage([A4.width, A4.height]);
    page.drawImage(image, {
      x: (A4.width - width) / 2,
      y: (A4.height - height) / 2,
      width,
      height,
    });
  }

  // Base64 without the data URI prefix, ready for Filesystem.writeFile
  return pdf.saveAsBase64();
}

如果你希望页面与扫描的纸张完全匹配,请将 A4 逻辑替换为 pdf.addPage([image.width, image.height]) 并按原图尺寸绘制。对于长条形的收据,这种方式效果更好。

备选方案:使用原生方式渲染 PDF

如果你的 App 已经具备将 HTML 转为 PDF 的功能(如发票、报告),可以复用该路径,使用 @capgo/capacitor-pdf-generator。它通过平台的打印引擎渲染 HTML 字符串并返回 base64:

import { PdfGenerator } from '@capgo/capacitor-pdf-generator';
import { Capacitor } from '@capacitor/core';

const html = `
  <html><body style="margin:0">
    ${paths
      .map((p) => `<img src="${Capacitor.convertFileSrc(p)}" style="width:100%;page-break-after:always" />`)
      .join('')}
  </body></html>`;

const result = await PdfGenerator.fromData({
  data: html,
  documentSize: 'A4',
  orientation: 'portrait',
  type: 'base64',
});

if (result.type === 'base64') {
  // result.base64 contains the PDF
}

当你需要封面、标题或在每页添加日期页脚时,这非常方便。如果只是纯图片转 PDF, pdf-lib 能让你更灵活地控制页面几何结构和图像压缩。

保存 PDF

使用 @capacitor/filesystem写入 base64 输出。根据文件的存储时长选择目录:

目录 Android iOS 用途
Directory.Cache 应用缓存,可被系统清除 Library/Caches,可被系统清除 您立即上传或分享的 PDF
Directory.Data 私有应用文件,卸载时移除 应用 Documents 文件夹,已备份到 iCloud 您的应用管理并稍后再次显示的 PDF
Directory.LibraryNoCloud 私有应用文件 Library/NoCloud,未备份 您可以再次下载的大型 PDF

在 iOS 上, Directory.Data 以及 Directory.Documents 指向同一文件夹。添加 UIFileSharingEnabled 以及 LSSupportsOpeningDocumentsInPlace 改为 Info.plist 如果用户希望在“文件”应用中看到这些 PDF。

import { Filesystem, Directory } from '@capacitor/filesystem';

export async function savePdf(base64: string, name: string) {
  const fileName = `${name.replace(/[^a-z0-9-_]/gi, '_')}.pdf`;
  const { uri } = await Filesystem.writeFile({
    path: `scans/${fileName}`,
    data: base64,
    directory: Directory.Data,
    recursive: true,
  });
  return uri; // file:// URI you can share, open, or print
}

export async function deletePages(paths: string[]) {
  await Promise.all(
    paths.map((path) => Filesystem.deleteFile({ path }).catch(() => undefined)),
  );
}

在原生平台上, writeFile 将没有 encoding 选项的字符串视为 base64 并写入二进制字节。请勿传递 Encoding.UTF8 用于 PDF,否则文件会损坏。

如果用户需要在 Android 的“下载”文件夹中找到 PDF,请使用 @capgo/capacitor-file-sharer的 save() 该方法在 Android 10+ 上无需任何存储权限即可写入 MediaStore Downloads。在 iOS 上,同一个调用会打开分享面板,让用户选择“存储到‘文件’”。

把整个流程串起来

export async function scanToPdf(title: string) {
  const pages = await scanPages();
  if (!pages || pages.length === 0) return null;

  try {
    const base64 = await pagesToPdf(pages);
    return await savePdf(base64, title);
  } finally {
    await deletePages(pages);
  }
}

将其绑定到按钮,并在其运行时显示加载动画 pagesToPdf 。在中端 Android 手机上,处理 10 页通常用不了几秒,因为这里直接复制 JPEG 数据,而不是解码。

分享、打开或打印 PDF

使用系统分享面板分享

import { FileSharer } from '@capgo/capacitor-file-sharer';

await FileSharer.share({
  filename: 'contract.pdf',
  path: pdfUri,
  contentType: 'application/pdf',
  subject: 'Signed contract',
});

@capgo/capacitor-file-sharer 会把文件复制到自己的缓存文件夹,并通过自己的 FileProvider 在 Android 上,所以你无需修改 file_paths.xml,即使 PDF 存放在 Directory.Data。如果你改用官方的 @capacitor/share 插件,其 files 该选项在 Android 上开箱即用,但仅适用于缓存目录。其他文件夹必须添加到 android/app/src/main/res/xml/file_paths.xml.

在用户的 PDF 应用中打开

如需快速预览,社区提供的 file opener 会把文件交给 iOS 的 Quick Look 或 Android 的默认 PDF 查看器:

bun add @capacitor-community/file-opener
bunx cap sync
import { FileOpener } from '@capacitor-community/file-opener';

await FileOpener.open({
  filePath: pdfUri,
  contentType: 'application/pdf',
});

打印

@capgo/capacitor-printer 打开 UIPrintInteractionController (iOS 端)以及 PrintManager 在 Android 上:

import { Printer } from '@capgo/capacitor-printer';

await Printer.printPdf({
  name: 'Signed contract',
  path: pdfUri,
});

本指南中介绍的 name 会成为打印任务名称;如果用户在打印对话框中选择“另存为 PDF”,还会成为默认文件名。

上传 PDF

若要将 PDF 发送到你的后端,请将其读取为 Blob 并用以下代码发送 POST 请求 fetch,或者对大文件使用 native uploader,这样应用切到后台时上传也能继续:

import { Uploader } from '@capgo/capacitor-uploader';

const { id } = await Uploader.startUpload({
  filePath: pdfUri,
  serverUrl: 'https://api.example.com/documents',
  method: 'POST',
  headers: { Authorization: `Bearer ${token}` },
  mimeType: 'application/pdf',
});

请参阅 Capacitor 上传插件 用于进度事件和 multipart 上传。

让扫描的 PDF 可搜索

仅包含图像的 PDF 无法搜索或复制文本。如果用户期望搜索其扫描件:

  1. 在删除每页图像之前,先对其运行 OCR。设备端选项包括 Android 上的 ML Kit Text Recognition 和 iOS 上的 Vision framework,两者均可通过社区 Capacitor 插件访问,或者使用服务器端 OCR 服务。
  2. 将识别出的文本存储在数据库中,与 PDF 路径并列,并在那里进行搜索。
  3. 如果 PDF 本身必须包含文本,请使用 OCR 工具在服务器端生成,该工具会写入不可见的文本层,例如 OCRmyPDF。

对于大多数应用,步骤 2 就足够了,并且避免了在应用中打包第二个大型模型。

故障排除

“文档扫描器在 Android 模拟器上不受支持”:这是预期行为。请使用带有 Google Play 服务的物理设备。

iOS 上应用一打开扫描器就崩溃: NSCameraUsageDescription 未包含在 Info.plist,或者用户之前拒绝了相机访问权限。请检查“设置 > 隐私 > 相机”。

embedJpg 抛出“SOI not found”错误:字节不是 JPEG。这通常是因为你把 base64 data URL 传给了 pdf-lib(它需要原始字节),或者请求的路径返回了 HTML 错误页面。打印日志 Capacitor.convertFileSrc(path) 并在 Safari 或 Chrome 检查器中打开。

PDF 文件体积过大:调低 croppedImageQuality (Android)或使用以下方法重新压缩页面: @capgo/capacitor-file-compressor 后再进行嵌入。对于文本文档,70 到 80 的质量通常清晰可读。

页面显示方向错误:使用 pdf-lib 进行旋转 page.setRotation(degrees(90)) 或者让用户在扫描器预览界面中通过以下方法修正旋转: reviewCapturedDocument: true.

在 Android 上分享失败,错误为: FileUriExposedException 或“Failed to find configured root”:你传入的路径位于在 中声明的文件夹之外 file_paths.xml 改为 @capacitor/share。请使用文件分享插件,或将 PDF 复制到 Directory.Cache 首先。

旧页面图片会占满 iOS 存储空间:扫描器会将 JPEG 写入 Documents 目录。构建 PDF 后请删除它们,如 所示 scanToPdf.

向扫描流程推送更新

该功能的大部分代码位于 TypeScript 中:PDF 布局、文件命名、上传逻辑和 UI。对这些部分的修改无需发布新的应用商店版本。借助 Capgo 实时更新 ,你可以在同一天向所有用户推送 PDF 布局或上传端点的修复,而原生更改(添加插件、编辑 Info.plist)仍需通过常规构建流程。 文档扫描器文档 如需进一步调整扫描器,请列出所有选项。

为 Capacitor 应用提供 Live Updates

当 Web 层出现线上 Bug 时,通过 Capgo 发布修复,而不用等 App Store 审核好几天。用户会在后台收到更新,原生变更则仍走正常审核流程。

博客最新动态

Capgo 为你提供打造真正专业的移动应用所需的最有价值的洞察。