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 ReadPassportAndId template 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 install inside ios/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. startScan drives a full-screen native scanner; scanFile and scanFromGallery feed 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 uses DynamsoftMRZScannerBundle 3.4.1300, both released as part of the Capture Vision 3.4 line.

Architecture

Ionic ID card scanner data flow

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.xml and 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.

Result page on a real device — deskewed Romanian ID card and the detected portrait, both rendered with `object-fit: contain` inside fixed aspect-ratio containers

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.json ships inside DynamsoftMRZScannerBundle.framework; the controller locates it through Bundle.allFrameworks, so there is nothing to add to the App target. If the lookup fails, run pod 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: IntermediateResultExtraInfo parameter (see Step 4). The protocol methods are @optional, so the code compiles, but the SDK never calls the mismatched selectors and findPortraitZone starves.
  • Class DSxxx is implemented in both ... in the iOS console. Two copies of the SDK are linked — typically standalone DynamsoftCore / DynamsoftLicense pods 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