How to Build an Ionic ID Card Scanner with the Dynamsoft MRZ Scanner Native SDK
Reading the Machine-Readable Zone (MRZ) of a passport or national ID card reliably from a phone camera
needs an on-device MRZ pipeline. With the Dynamsoft MRZ Scanner native SDK (mrzscannerbundle
3.4.1300 on Android, DynamsoftMRZScannerBundle 3.4.1300 on iOS) wrapped in a small in-app Capacitor
plugin, the entire workflow — camera preview, MRZ detection, field parsing, portrait cropping —
runs on the native side, while the Ionic Vue web layer only drives the UI and renders the
result. This article shows the complete ionic-id-card-scanner reference project: one Ionic Vue
app, one in-app Capacitor plugin, two data sources, and zero JavaScript scanning SDK.
The scanner detects the document boundary in real time, parses the MRZ, and returns the holder fields together with the cropped portrait photo. The same capture pipeline also deskews the card itself, so the result page can show the perspective-corrected front of the document alongside the parsed fields. The same code path also accepts a still image from the gallery, so a user can pick a saved photo of their ID card and get the same result.
What you’ll build
- An Ionic Vue app that exposes two clearly separated data sources — Camera and File — with no cross-contamination between them on the device.
- A native MRZ scanner that runs the built-in
ReadPassportAndIdtemplate on every camera frame, outlines the document quad, the MRZ lines and the detected portrait zone on the live preview through the SDK drawing layers, and confirms a TD1, TD2 or TD3 document when the MRZ is stable. - An in-app Capacitor plugin (
IdScannerNative) that returns the parsed fields, document type, document number, name, sex, date of birth, expiry, age, issuing state, nationality, a cropped portrait JPEG and a deskewed document JPEG produced by the same document-detection step the SDK runs to find the MRZ zone. - A matching iOS implementation in Swift with the same data flow, ready to run after the
standard
pod installinsideios/App(see Step 4).
Key Takeaways
- Native SDK, not the web SDK. All scanning runs through the Dynamsoft Capture Vision native bundle. The Ionic web layer never touches the camera or any MRZ model.
- One plugin, two data sources.
startScandrives a full-screen native scanner;scanFileandscanFromGalleryfeed the same engine on the native side with a still image. - Portrait is part of the result. The intermediate result units (scaled colour image, localized text lines, recognized text lines, detected quads, deskewed image) are used to crop the portrait automatically; you do not have to do that work in JavaScript.
- Document boundary detection + perspective correction come for free. The same SDK pipeline that finds the MRZ zone also deskews the card. The result page therefore displays the perspective-corrected document together with the parsed fields, without any extra native work.
- Latest stable SDK versions. Android uses
com.dynamsoft:mrzscannerbundle:3.4.1300, iOS usesDynamsoftMRZScannerBundle 3.4.1300, both released as part of the Capture Vision 3.4 line.
Architecture
The web layer is a thin shell. Every scan call crosses the Capacitor bridge into the native plugin, which talks to the Dynamsoft SDK and returns a small JSON payload.
Common Developer Questions
Which platforms does this app target?
Android and iOS only. There is no web fallback. When IdScannerNative.startScan is invoked in a
regular browser the call rejects with the standard Capacitor “not implemented” error, which is
the desired behaviour for a native-only scanner.
Which SDK should I use, capturevisionbundle or mrzscannerbundle?
Use mrzscannerbundle. It is the official MRZ product and bundles the ReadPassportAndId
template together with the dcp (code parser) and diu (IdentityProcessor for portrait
detection) modules. The plain capturevisionbundle does not include the MRZ template.
Why two data sources, not one?
The two data sources use the same template on the same engine. Splitting them keeps the UI unambiguous: a user can either aim the camera at the MRZ zone or pick an existing photo of their document. There is no implicit conversion between the two paths, so the result is predictable.
Do I need to bundle an MRZ JSON template on iOS?
No. The DynamsoftMRZScannerBundle.framework ships mrz-mobile.json as an internal
resource. The camera controller and the plugin locate it at runtime through
Bundle.allFrameworks and pass the path to initSettingsFromFile:
if let template = Bundle.allFrameworks.lazy
.compactMap({ $0.path(forResource: "mrz-mobile", ofType: "json") }).first {
try? cvr.initSettingsFromFile(template)
}
The Android bundle works the same way with the built-in
mrzscanner-mobile-templates.json.
Why does the result page also show the deskewed card image?
The ReadPassportAndId template runs document detection before it parses the MRZ zone. Once
the card boundary is found the SDK deskews the page and the ProcessedDocumentResult exposes
its first DeskewedImageResultItem. The native plugin ships that JPEG to the web layer as
documentImageBase64, so the result page can display the perspective-corrected card right
above the parsed fields. No extra SDK call, no extra capture pass — the deskewing is a side
effect of finding the MRZ in the first place.
Prerequisites
- Node.js 20+ and npm
- Android Studio with the Android SDK 36 platform installed
- Xcode 16+ (or newer) for iOS
- A physical device — the camera does not work in the iOS simulator or the Android emulator
- A Dynamsoft Capture Vision trial license (already embedded in
android/app/src/main/res/values/strings.xmland mirrored in the iOS plugin source)
Get a 30-day free trial license
Step 1: Scaffold the project
git clone https://github.com/yushulx/android-camera-barcode-mrz-document-scanner.git
cd android-camera-barcode-mrz-document-scanner/examples/ionic-id-card-scanner
npm install
npm run build # Vite + vue-tsc produce dist/
npx cap sync # copies dist/ into android/ and ios/ Capacitor assets
npx cap open android # or `npx cap open ios`
The Vite + Vue 3 + Ionic 9 build outputs a SPA in dist/. Capacitor copies that directory into
android/app/src/main/assets/public/ and ios/App/App/public/ so the WebView can load it
without a network round-trip.
Step 2: The web layer
The web layer is intentionally thin. There are only two source files: a registerPlugin
definition that describes the contract, and a HomePage that renders the two data sources.
// src/scanner/IdScannerNative.ts
import { registerPlugin } from '@capacitor/core';
export interface IdFields {
documentType: string;
name: string;
sex: string;
age: string;
documentNumber: string;
issuingState: string;
nationality: string;
dateOfBirth: string;
dateOfExpiry: string;
}
export interface IdScanResult {
fields: IdFields;
portraitBase64?: string;
documentImageBase64?: string;
}
export interface IdScannerNativePlugin {
initLicense(): Promise<{ success: boolean; message: string }>;
startScan(): Promise<IdScanResult>;
scanFromGallery(): Promise<IdScanResult>;
scanFile(options: { uri: string }): Promise<IdScanResult>;
}
export const IdScannerNative = registerPlugin<IdScannerNativePlugin>('IdScannerNative');
HomePage.vue shows two clearly separated cards — Camera and File — and a spinner while the
native scanner is on screen. The result is stashed in a tiny reactive store (scanStore) and
the app navigates to ResultPage.vue for the parsed field list, portrait photo and (for the
file data source) the deskewed document image.
The ResultPage iterates over the field map and renders each pair as an ion-item. The
portrait is shown above the field list when present, otherwise the deskewed document image is
displayed so the user can confirm which side of the card was processed.
Step 3: The native plugin on Android
The in-app plugin lives at
android/app/src/main/java/com/dynamsoft/ionic/idscanner/. MainActivity registers the
plugin class with the Capacitor bridge before super.onCreate is called.
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(IdScannerNativePlugin.class);
super.onCreate(savedInstanceState);
}
}
IdScannerEngine is a thread-safe singleton that owns a single CaptureVisionRouter and the
license state. startScan launches IdScanActivity, which renders the live preview with a
com.dynamsoft.dce.CameraView and registers a CapturedResultReceiver plus five intermediate
result receivers so that IdentityProcessor.findPortraitZone can locate the portrait.
public synchronized void ensureTemplates() throws ScannerException {
if (templateReady) return;
try {
router.initSettingsFromFile("mrzscanner-mobile-templates.json");
templateReady = true;
} catch (CaptureVisionRouterException e) {
throw new ScannerException("Failed to load MRZ templates: " + e.getMessage());
}
}
public ParsedResultItem parseFile(String path) throws ScannerException {
return unwrap(router.capture(path, template()));
}
IdScanActivity combines the parsed ParsedResultItem, the detected document quad and the
high-confidence portrait auxiliary region. When the user confirms the frame, the activity
serializes the fields and a base64 JPEG of the cropped portrait into the result intent and
finishes. IdScannerNativePlugin resolves the Capacitor call with that JSON.
The file data source uses the same engine through engine.parseFile /
engine.parseBitmap. The result also surfaces the deskewed document image so the user can see
which page was processed when scanning a still photo.
The app/build.gradle only needs the mrzscannerbundle dependency and the
packagingOptions that keep the bundled native libraries intact.
dependencies {
// Dynamsoft MRZ Scanner native SDK
implementation 'com.dynamsoft:mrzscannerbundle:3.4.1300'
}
packagingOptions {
doNotStrip "**/*.so"
pickFirst "**/libc++_shared.so"
}
The build.gradle at the project root adds the Dynamsoft Maven repository so the AAR can be
resolved.
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://download2.dynamsoft.com/maven/aar' }
}
}
AndroidManifest.xml adds the IdScanActivity declaration, the CAMERA permission and the
READ_MEDIA_IMAGES permission required for the file picker on Android 13+.
IdScanActivity confirms the scan once the MRZ zone and the portrait zone are both stable.
It also reads the first DeskewedImageResultItem from the ProcessedDocumentResult so the
JSON payload that goes back to the web layer includes the perspective-corrected card image
next to the cropped portrait.
private void confirm() {
if (confirmed || pendingFields == null) {
return;
}
confirmed = true;
router.stopCapturing();
try {
JSONObject json = new JSONObject();
json.put("fields", new JSONObject(pendingFields));
if (pendingPortrait != null) {
json.put("portraitBase64", toBase64(pendingPortrait));
}
if (pendingDocument != null) {
json.put("documentImageBase64", toBase64(pendingDocument));
}
setResult(RESULT_OK, ScanResultContract.ok(json.toString()));
} catch (Exception e) {
Log.e(TAG, "Failed to serialize result", e);
setResult(RESULT_CANCELED, ScanResultContract.error("Failed to serialize result: " + e.getMessage()));
}
finish();
}
The plugin’s handleScanResult and the single-file buildResult both call.resolve(new JSObject(json)),
so the bridge returns the same shape the TypeScript contract expects — no extra wrapper that
the web layer would have to unwrap.
Step 4: The native plugin on iOS
The iOS side mirrors the Java structure. Swift files in ios/App/App/ include the plugin
(IdScannerNativePlugin.swift), the camera view controller (IdCameraScanViewController.swift),
a small formatter helper (IdResultFormatter.swift) and a SharedImagePicker wrapper around
UIImagePickerController (it requests photo-library access before presenting) that is shared
with the QR scanner example.
The plugin class is registered automatically because it is listed in
ios/App/App/capacitor.config.json. Note that on Capacitor 7+ the Swift class must also
conform to CAPBridgedPlugin (declare identifier, jsName and pluginMethods) — a bare
@objc subclass of CAPPlugin is silently skipped, and every web call then resolves to
“plugin is not implemented”:
{
"appId": "com.dynamsoft.ionic.idscanner",
"packageClassList": ["IdScannerNativePlugin"]
}
The camera view controller uses CameraView + CameraEnhancer + CaptureVisionRouter
from the DynamsoftCaptureVisionBundle CocoaPods framework and the same intermediate
result receivers as the Java activity so that IdentityProcessor().findPortraitZone works
identically. The MRZ template ships inside DynamsoftMRZScannerBundle.framework as
mrz-mobile.json; the controller finds it through Bundle.allFrameworks before
startCapturing.
let dce = CameraEnhancer()
let cvr = CaptureVisionRouter()
if let template = Bundle.allFrameworks.lazy
.compactMap({ $0.path(forResource: "mrz-mobile", ofType: "json") }).first {
try? cvr.initSettingsFromFile(template)
}
try? cvr.setInput(dce)
cvr.getIntermediateResultManager().addResultReceiver(self)
cvr.addResultReceiver(self)
Two iOS-specific details decide whether the portrait pipeline works or is a silent no-op:
1. The intermediate result callbacks carry an info: parameter. Every method of the
IntermediateResultReceiver protocol is @optional, so Swift happily compiles a
single-parameter onDetectedQuadsReceived(_ unit:) — but the SDK then never calls it
(the selector does not match), the cached units stay nil and findPortraitZone never
runs. The correct signatures all take two parameters:
func onScaledColourImageUnitReceived(_ unit: ScaledColourImageUnit,
info: IntermediateResultExtraInfo) {
scaledColourImageUnit = unit
}
// onLocalizedTextLinesReceived, onRecognizedTextLinesReceived,
// onDetectedQuadsReceived and onDeskewedImageReceived follow the same pattern
2. Gate the zone, then crop with the SDK’s own processor. The pipeline only trusts a
PortraitZone auxiliary region with confidence > 60 that sits inside the detected document
quad (area ratio >= 3). It then perspective-corrects the region out of the original video
frame with ImageProcessor — a hand-written affine transform would distort the face:
var portraitZone = findPortraitZone() // gated on "PortraitZone" confidence > 60
// Keep the zone only when it sits inside the detected document.
if let zone = portraitZone, let docRegion = documentQuad {
let inside = zone.points.allSatisfy { docRegion.contains($0.cgPointValue) }
let ratioOk = zone.area > 0 ? docRegion.area / zone.area >= 3 : false
if !inside || !ratioOk { portraitZone = nil }
}
if let zone = portraitZone,
let original = cvr.getIntermediateResultManager()
.getOriginalImage(result.originalImageHashId) {
let cropped = try? ImageProcessor().cropAndDeskewImage(original, quad: zone)
portrait = cropped.flatMap { IdResultFormatter.image(from: $0) }
}
The same controller also draws a live overlay through the SDK drawing layers — the document
quad on the preset DDN layer, the MRZ text lines on the preset DLR layer, and the portrait
zone on a custom cyan-styled layer. Preset layers only need visible = true; the custom
layer gets a style from DrawingStyleManager:
private func configureDrawingLayers() {
cameraView.getDrawingLayer(DrawingLayerId.DDN.rawValue)?.visible = true // document quad
cameraView.getDrawingLayer(DrawingLayerId.DLR.rawValue)?.visible = true // MRZ text lines
let portraitStyle = DrawingStyleManager.createDrawingStyle(
.cyan, strokeWidth: 3,
fill: UIColor.cyan.withAlphaComponent(0.1),
textColor: .white, font: .systemFont(ofSize: 12))
let portraitLayer = cameraView.createDrawingLayer()
portraitLayer.visible = true
portraitLayer.setDefaultStyle(portraitStyle)
portraitLayerId = portraitLayer.layerId
}
Every QuadDrawingItem takes the quadrilateral in video-frame coordinates and the drawing
layer maps it onto the preview automatically.
The Swift SDK modules are integrated with CocoaPods (not Swift Package Manager). The
ios/App/Podfile of the project looks like this:
target 'App' do
capacitor_pods
pod 'DynamsoftMRZScannerBundle', '3.4.1300' # MRZ template + parser; pulls in the Capture Vision bundle
end
DynamsoftMRZScannerBundle transitively pulls in DynamsoftCaptureVisionBundle, and that
framework is self-contained: it embeds the Core, License and Camera Enhancer modules,
and its binary declares no load dependency on any other Dynamsoft framework. Do not add
separate DynamsoftCore / DynamsoftLicense pods — they register dozens of duplicate
Objective-C classes at launch, and dyld then picks one implementation at random
(Class DSxxx is implemented in both ... One of the two will be used in the console), which
breaks the native pipeline in hard-to-debug ways. Run pod install once before opening
App.xcworkspace:
cd ios/App
pod install
open App.xcworkspace # use the workspace, not the .xcodeproj
The Info.plist already declares NSCameraUsageDescription.
Step 5: Run it
# Android
npx cap run android --target=<device-id>
# iOS
npx cap run ios
On Android you should see the camera permission prompt the first time, the live preview
highlights the document as soon as the MRZ zone is stable, and the portrait becomes enabled
when the auxiliary region confidence crosses the threshold. Tapping Confirm dismisses the
native page and the ResultPage lists every parsed field together with the cropped portrait
photo and the deskewed document image.

The result page is a plain v-if swap driven by a ref<'home' | 'result'> (no vue-router
or <ion-router-outlet>), because the Capacitor 8 + Android WebView combination occasionally
leaves the router outlet empty after launch. The hardware back button is handled through
@capacitor/app:
import { App as CapApp } from '@capacitor/app';
// inside onMounted:
backListener = await CapApp.addListener('backButton', () => {
if (view.value === 'result') {
view.value = 'home';
} else {
void CapApp.exitApp();
}
});
On iOS, the same workflow runs through the AVFoundation-backed CameraEnhancer. The file data
source requests photo-library access (PHPhotoLibrary.requestAuthorization(for: .readWrite))
before presenting the picker, and reads the selected image from a temporary JPEG file.
Common Issues & Edge Cases
- “No MRZ was found in the image.” Make sure the Machine-Readable Zone is fully visible and lit. Glare and extreme angles are the most common cause. Move the camera back a few centimetres and try again.
- The portrait box never turns on. The portrait auxiliary region is only reported when the SDK is confident (confidence > 60). If the region does not appear, the scan will still return the parsed fields — the user can re-take the photo to improve the portrait.
- iOS template not found.
mrz-mobile.jsonships insideDynamsoftMRZScannerBundle.framework; the controller locates it throughBundle.allFrameworks, so there is nothing to add to the App target. If the lookup fails, runpod install— without the framework there is no template to find. - The portrait box never appears on iOS even though the MRZ parses. Almost always the
intermediate result receiver methods were declared without the required
info: IntermediateResultExtraInfoparameter (see Step 4). The protocol methods are@optional, so the code compiles, but the SDK never calls the mismatched selectors andfindPortraitZonestarves. Class DSxxx is implemented in both ...in the iOS console. Two copies of the SDK are linked — typically standaloneDynamsoftCore/DynamsoftLicensepods next to the bundle pod. Remove them (see Step 4); duplicate class registration makes dyld pick one implementation at random.- The Capacitor plugin is “not implemented” on the web. That is intentional. Wrap the call
in
Capacitor.isNativePlatform()if you need a development-mode fallback that uses the same Ionic UI for browser testing.
Source Code
Get the complete sample project source code on GitHub:
examples/ionic-id-card-scanner