One Logo Pool Ball
wwwwwwwwwwwwwwwwwww

Document Camera

Scan multipage documents with the iOS system camera

One.iOS.VNDocumentCameraViewController presents VisionKit’s document camera, including its multipage capture, crop, and review UI. Reach for it for receipt, contract, and whiteboard scanning where Apple’s capture experience beats a custom camera screen. For live text and barcode scanning in the viewfinder, see the data scanner instead.

This needs an iOS build, a supported physical device, and ios.infoPlist.NSCameraUsageDescription. isSupported is the SDK capability check, not a camera permission grant. Simulators cannot scan documents.

Usage

import { One } from 'one'
if (One.iOS.VNDocumentCameraViewController.isSupported) {
const camera = new One.iOS.VNDocumentCameraViewController()
camera.delegate = {
documentCameraViewController: async (controller, result) => {
try {
if ('didFinishWith' in result) {
const scan = result.didFinishWith
for (let at = 0; at < scan.pageCount; at++) {
const image = scan.imageOfPage(at)
const bytes = image.pngData()
// consume the encoded page bytes here.
}
} else {
const error = result.didFailWith
console.error(error.domain, error.code, error.localizedDescription)
}
} finally {
await controller.dismiss()
}
},
documentCameraViewControllerDidCancel: (controller) => {
void controller.dismiss()
},
}
await camera.present()
}

The delegate owns dismissal, as in VisionKit. Completion delivers the scan or the failure; cancellation arrives on its own callback and never fabricates a scan.

How it works

Retain the controller while using it. Each controller has its own delegate and presentation state. A concurrent presentation on the same controller rejects, and overlapping transitions reject too. Delegate errors preserve the native NSError domain, code, and localized description. Presentation errors reject the promise before any delegate result: unsupported hardware, a missing camera usage description, no presenter, or an already-presented controller.

The delegate adapts Swift’s labeled overloads to JavaScript: documentCameraViewController receives { didFinishWith: scan } or { didFailWith: error }, and documentCameraViewControllerDidCancel receives the controller. present and dismiss adapt view controller presentation to the current app presenter; the native camera content is unchanged.

The scan retains the native result. The title is a string, the page count a number, and imageOfPage returns the native page image for an integer index within range; anything else throws a page error. The image subset exposes the point size, the scale, and PNG and JPEG encoders. Encoding returns an ArrayBuffer, or undefined when UIKit cannot encode the image. JPEG quality must be finite within 0 to 1. Pages encode only when requested; results are never replaced by synthetic images or cached file lists.

API reference

Class availabilityTypeDescription
isSupportedbooleanStatic SDK device support check. False on web; native getter requires an iOS build.
booleanStatic SDK device support check. False on web; native getter requires an iOS build.
ControllerTypeDescription
delegateVNDocumentCameraViewControllerDelegate<VNDocumentCameraViewControllerInstance> | nullReceives completion, cancellation or failure. Defaults to null.
VNDocumentCameraViewControllerDelegate<VNDocumentCameraViewControllerInstance> | nullReceives completion, cancellation or failure. Defaults to null.
present(animated?: boolean) => Promise<void>Presents from the current app controller. Resolves after presentation; animated defaults to true.
(animated?: boolean) => Promise<void>Presents from the current app controller. Resolves after presentation; animated defaults to true.
dismiss(animated?: boolean) => Promise<void>Dismisses the camera. Resolves after dismissal; animated defaults to true.
(animated?: boolean) => Promise<void>Dismisses the camera. Resolves after dismissal; animated defaults to true.
ScanTypeDescription
titlestringNative scan title.
stringNative scan title.
pageCountnumberNumber of captured pages.
numberNumber of captured pages.
imageOfPage(at: number) => DocumentCameraImageNative page image at a valid zero-based index.
(at: number) => DocumentCameraImageNative page image at a valid zero-based index.
Page imageTypeDescription
sizeDocumentCameraImageSizeWidth and height in points.
DocumentCameraImageSizeWidth and height in points.
scalenumberPixels per point.
numberPixels per point.
pngData() => ArrayBuffer | undefinedEncodes the page as PNG.
() => ArrayBuffer | undefinedEncodes the page as PNG.
jpegData(compressionQuality: number) => ArrayBuffer | undefinedEncodes the page as JPEG.
(compressionQuality: number) => ArrayBuffer | undefinedEncodes the page as JPEG.

Platform differences

iOSAndroidWeb
CameraVisionKit UIThrowsThrows
SupportPer deviceNeverNever
PagesNative scanNoneNone

Limits and gotchas

Examples

Scan receipts to JPEG bytes

import { One } from 'one'
async function scanReceipts(): Promise<ArrayBuffer[]> {
if (!One.iOS.VNDocumentCameraViewController.isSupported) return []
const camera = new One.iOS.VNDocumentCameraViewController()
return new Promise((resolve, reject) => {
camera.delegate = {
documentCameraViewController: async (controller, result) => {
try {
if ('didFailWith' in result) {
reject(new Error(result.didFailWith.localizedDescription))
return
}
const scan = result.didFinishWith
const pages: ArrayBuffer[] = []
for (let at = 0; at < scan.pageCount; at++) {
const bytes = scan.imageOfPage(at).jpegData(0.85)
if (bytes) pages.push(bytes)
}
resolve(pages)
} finally {
await controller.dismiss()
}
},
documentCameraViewControllerDidCancel: (controller) => {
void controller.dismiss().then(() => resolve([]))
},
}
void camera.present()
})
}

The promise bridges the delegate into async flow: JPEG bytes on finish, an empty list on cancel, a throw on failure. The dismiss in finally covers every path.

Count pages without keeping bytes

import { One } from 'one'
async function countPages(): Promise<number> {
if (!One.iOS.VNDocumentCameraViewController.isSupported) return 0
const camera = new One.iOS.VNDocumentCameraViewController()
return new Promise((resolve) => {
camera.delegate = {
documentCameraViewController: async (controller, result) => {
try {
resolve('didFinishWith' in result ? result.didFinishWith.pageCount : 0)
} finally {
await controller.dismiss()
}
},
documentCameraViewControllerDidCancel: (controller) => {
void controller.dismiss().then(() => resolve(0))
},
}
void camera.present()
})
}

Page counts read straight off the scan without encoding a single image, which keeps a counting flow light on memory.

Edit this page on GitHub.