DEV Community

Xiao Ling
Xiao Ling

Posted on • Originally published at dynamsoft.com

How to Build a Cross-Platform Barcode, MRZ, and Document Scanner with .NET MAUI Blazor

Extracting structured data from barcodes, passports, and physical documents typically demands separate native implementations for every platform. The Dynamsoft Capture Vision SDK bundles barcode reading, MRZ recognition, and document detection into one JavaScript-callable API, and .NET MAUI Blazor lets you host that API inside a cross-platform BlazorWebView — giving you a single Razor codebase that runs on Android, iOS, macOS, and Windows.

What you'll build: A .NET MAUI Blazor app named "Vision Scanner" that scans barcodes, reads MRZ fields from passports and IDs, and detects document boundaries with a perspective-correction editor, running on all four major platforms using Dynamsoft Capture Vision SDK v3.2.5000.

Demo Video: .NET MAUI Blazor Vision Scanner in Action

Prerequisites

Get a 30-day free trial license at dynamsoft.com/customer/license/trialLicense

Step 1: Add the Dynamsoft Capture Vision Bundle to the Blazor Host Page

The SDK ships as a single CDN bundle. Add it to wwwroot/index.html before the Blazor framework script so that the Dynamsoft global is available as soon as the WebView loads.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
    <title>Vision Scanner</title>
    <base href="/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="BarcodeScanner.styles.css" rel="stylesheet" />
    <script src="https://cdn.jsdelivr.net/npm/dynamsoft-capture-vision-bundle@3.2.5000/dist/dcv.bundle.min.js"></script>
    <script src="jsInterop.js"></script>
</head>
<body>
    <div class="status-bar-safe-area"></div>
    <div id="app">Loading...</div>
    <script src="_framework/blazor.webview.js" autostart="false"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 2: Register Platform-Specific Services and Configure the Android WebView

The camera abstraction (ICameraService) is registered in MauiProgram.cs. On macOS, WKWebView does not support getUserMedia, so a native AVFoundation implementation is wired in instead. On Android, a custom BlazorWebViewHandler replaces the default WebChromeClient to grant the WebView camera permission at the OS level.

// MauiProgram.cs
using Microsoft.AspNetCore.Components.WebView.Maui;
using BarcodeScanner.Services;

#if ANDROID
using BarcodeScanner.Platforms.Android;
#endif

namespace BarcodeScanner;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            }).ConfigureMauiHandlers(handlers =>
            {
#if ANDROID
                handlers.AddHandler<BlazorWebView, MauiBlazorWebViewHandler>();
#endif
            });

        builder.Services.AddMauiBlazorWebView();
#if DEBUG
        builder.Services.AddBlazorWebViewDeveloperTools();
#endif

#if MACCATALYST
        builder.Services.AddSingleton<ICameraService, MacCameraService>();
#else
        builder.Services.AddSingleton<ICameraService, DefaultCameraService>();
#endif

        return builder.Build();
    }
}
Enter fullscreen mode Exit fullscreen mode

The Android-specific MyWebChromeClient grants all WebView permission requests — including camera — so that getUserMedia resolves inside the Blazor page:

// Platforms/Android/MyWebChromeClient.cs
public class MyWebChromeClient : WebChromeClient
{
    private MainActivity _activity;

    public MyWebChromeClient(Context context)
    {
        _activity = context as MainActivity;
    }

    public override void OnPermissionRequest(PermissionRequest request)
    {
        try
        {
            request.Grant(request.GetResources());
            base.OnPermissionRequest(request);
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex);
        }
    }

    public override bool OnShowFileChooser(global::Android.Webkit.WebView webView,
        IValueCallback filePathCallback, FileChooserParams fileChooserParams)
    {
        base.OnShowFileChooser(webView, filePathCallback, fileChooserParams);
        return _activity.ChooseFile(filePathCallback,
            fileChooserParams.CreateIntent(), fileChooserParams.Title);
    }
}
Enter fullscreen mode Exit fullscreen mode

This handler is injected via the custom MauiBlazorWebViewHandler:

// Platforms/Android/MauiBlazorWebViewHandler.cs
public class MauiBlazorWebViewHandler : BlazorWebViewHandler
{
    protected override global::Android.Webkit.WebView CreatePlatformView()
    {
        var view = base.CreatePlatformView();
        view.SetWebChromeClient(new MyWebChromeClient(this.Context));
        return view;
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Initialize the SDK with a License Key

The Index.razor page presents a license activation screen. When the user taps Activate, Blazor calls the JavaScript initSDK function via IJSRuntime. The function initializes the license, pre-loads the WASM modules for barcode (DBR), label recognition (DLR), and document detection (DDN), loads the MRZ character-recognition models, and creates a CaptureVisionRouter instance.

@* Index.razor — Activate button handler *@
private async Task Activate()
{
    if (string.IsNullOrWhiteSpace(licenseKey)) return;
    isActivating = true;
    statusMessage = "";
    StateHasChanged();

    try
    {
        isActivated = await JSRuntime.InvokeAsync<bool>(
            "jsFunctions.initSDK", objRef, licenseKey);
        statusMessage = isActivated
            ? "SDK activated successfully!"
            : "SDK activation failed.";
    }
    catch (Exception ex)
    {
        statusMessage = "Error: " + ex.Message;
    }

    isActivating = false;
    StateHasChanged();
}
Enter fullscreen mode Exit fullscreen mode

The corresponding JavaScript receives the call and boots the SDK:

// wwwroot/jsInterop.js — initSDK
initSDK: async function (dotnetRef, licenseKey) {
    dotnetHelper = dotnetRef;
    toggleLoading(true);
    try {
        await Dynamsoft.License.LicenseManager.initLicense(licenseKey, true);
        Dynamsoft.Core.CoreModule.loadWasm(["DBR", "DLR", "DDN"]);

        parser = await Dynamsoft.DCP.CodeParser.createInstance();
        await Dynamsoft.DCP.CodeParserModule.loadSpec("MRTD_TD1_ID");
        await Dynamsoft.DCP.CodeParserModule.loadSpec("MRTD_TD2_FRENCH_ID");
        await Dynamsoft.DCP.CodeParserModule.loadSpec("MRTD_TD2_ID");
        await Dynamsoft.DCP.CodeParserModule.loadSpec("MRTD_TD2_VISA");
        await Dynamsoft.DCP.CodeParserModule.loadSpec("MRTD_TD3_PASSPORT");
        await Dynamsoft.DCP.CodeParserModule.loadSpec("MRTD_TD3_VISA");
        await Dynamsoft.CVR.CaptureVisionRouter.appendDLModelBuffer("MRZCharRecognition");
        await Dynamsoft.CVR.CaptureVisionRouter.appendDLModelBuffer("MRZTextLineRecognition");

        cvr = await Dynamsoft.CVR.CaptureVisionRouter.createInstance();

        isSDKReady = true;
        toggleLoading(false);
        return true;
    } catch (ex) {
        console.error(ex);
        toggleLoading(false);
        return false;
    }
},
Enter fullscreen mode Exit fullscreen mode

Step 4: Decode Barcodes, MRZ, and Documents from Image Files

The Reader.razor page lets users pick an image from the device file system. After the file is selected, C# calls jsFunctions.loadAndDecodeFile, passing the <input> element ID and the active mode string ("barcode", "mrz", or "document"). The JavaScript side reads the file directly from the DOM, decodes it via cvr.capture(), and returns a JSON object containing the result text and any detected geometry.

The active template is selected from the mode string at call time:

// wwwroot/jsInterop.js — getTemplateName
function getTemplateName() {
    if (currentMode === 'barcode') return 'ReadBarcodes_Default';
    if (currentMode === 'mrz') return 'ReadMRZ';
    if (currentMode === 'document') return 'DetectDocumentBoundaries_Default';
    return 'ReadBarcodes_Default';
}
Enter fullscreen mode Exit fullscreen mode

On the C# side, the Reader.razor component handles the file change event and parses the returned JSON to update the UI:

// Pages/Reader.razor — LoadImage
private async Task LoadImage(ChangeEventArgs e)
{
    result = "";
    showDocumentEditor = false;
    rectifiedImage = "";
    isLoading = true;
    StateHasChanged();

    try
    {
        // JS reads the file directly from the <input> element — no C# stream needed.
        var json = await JSRuntime.InvokeAsync<string>(
            "jsFunctions.loadAndDecodeFile", "reader_file_input", selectedMode);

        if (!string.IsNullOrEmpty(json))
        {
            using var doc = System.Text.Json.JsonDocument.Parse(json);
            var root = doc.RootElement;

            if (root.TryGetProperty("error", out var errProp))
            {
                result = "Error: " + errProp.GetString();
            }
            else
            {
                currentImageBase64 = root.TryGetProperty("image", out var imgProp)
                    ? imgProp.GetString() ?? "" : "";

                var decodeResult = root.TryGetProperty("result", out var resProp)
                    ? resProp.GetString() ?? "" : "";
Enter fullscreen mode Exit fullscreen mode

For MRZ results, the JavaScript parser extracts structured fields from the raw MRZ string before returning them to C#:

// wwwroot/jsInterop.js — extractMrzInfo
function extractMrzInfo(result) {
    const parseResultInfo = {};
    parseResultInfo['Document Type'] = JSON.parse(result.jsonString).CodeType;
    parseResultInfo['Issuing State'] = result.getFieldValue("issuingState");
    parseResultInfo['Surname'] = result.getFieldValue("primaryIdentifier");
    parseResultInfo['Given Name'] = result.getFieldValue("secondaryIdentifier");
    let type = result.getFieldValue("documentCode");
    parseResultInfo['Passport Number'] = type === "P"
        ? result.getFieldValue("passportNumber")
        : result.getFieldValue("documentNumber");
    parseResultInfo['Nationality'] = result.getFieldValue("nationality");
    parseResultInfo["Gender"] = result.getFieldValue("sex");
    // ... date-of-birth and expiry fields
    return parseResultInfo;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Stream the Live Camera Feed and Handle Platform Differences

.NET MAUI Blazor Vision Scanner powered by Dynamsoft

Scanner.razor checks the injected ICameraService.RequiresNativeCamera flag to decide whether to use the standard getUserMedia web path or the macOS native AVFoundation path.

// Pages/Scanner.razor — OnAfterRenderAsync (first render)
if (useNativeCamera)
{
    await InitNativeCamera();
}
else
{
    await InitWebCamera();
}
Enter fullscreen mode Exit fullscreen mode

Web camera path (Android, iOS, Windows): Blazor enumerates cameras via getUserMedia, opens the chosen device, and starts a requestAnimationFrame decode loop:

// Pages/Scanner.razor — InitWebCamera
private async Task InitWebCamera()
{
    cameraLabels = await JSRuntime.InvokeAsync<string[]>(
        "jsFunctions.initScanner", objRef, "camera_view", selectedMode);

    if (cameraLabels != null && cameraLabels.Length > 0)
    {
        StateHasChanged();
        await JSRuntime.InvokeVoidAsync("jsFunctions.openCamera", 0);
        await JSRuntime.InvokeVoidAsync("jsFunctions.startScanning", selectedMode);
    }
}
Enter fullscreen mode Exit fullscreen mode

Native camera path (macOS): MacCameraService opens an AVCaptureSession, captures pixel buffers via AVCaptureVideoDataOutput, converts every second frame to a base64 JPEG, and exposes it through GetLatestFrame(). The Blazor component polls this method via a [JSInvokable] callback that the JavaScript timer calls on an interval:

// Pages/Scanner.razor — GetLatestFrame (JS-invokable poll endpoint)
[JSInvokable]
public string? GetLatestFrame()
{
    if (_disposed || !useNativeCamera) return null;
    return CameraService.GetLatestFrame();
}
Enter fullscreen mode Exit fullscreen mode

Scan results from both paths flow back to C# through [JSInvokable] callbacks:

// wwwroot/jsInterop.js — processCameraResult (barcode branch)
if (currentMode === 'barcode' &&
    item.type === Dynamsoft.Core.EnumCapturedResultItemType.CRIT_BARCODE) {
    txts.push(item.text);
    globalPoints = item.location.points;
    if (overlayCtx) drawCameraOverlayQuad(overlayCtx, item.location.points, '#00ff00', 3);
}
// ...
if (currentMode === 'barcode' && txts.length > 0 && dotnetHelper) {
    dotnetHelper.invokeMethodAsync('OnScanResultReceived', txts.join('\n'));
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Rectify Captured Documents with a Browser-Side Homography

When Document mode is active and the user taps Capture, the frozen frame and the detected quad points are sent to C#. The Scanner.razor component opens an overlay editor where the user can drag the four corner handles to refine the boundary, then tap Rectify. The perspective warp is computed entirely in the browser using an inverse-mapping homography — no server round-trip.

// wwwroot/jsInterop.js — warpPerspective (inverse-mapping core)
function warpPerspective(srcCanvas, quadPts) {
    // ...
    const dstPts = [{ x: 0, y: 0 }, { x: W, y: 0 }, { x: W, y: H }, { x: 0, y: H }];
    const Hinv = buildHomography(dstPts, sp);   // inverse: dst → src

    for (let y = 0; y < H; y++) {
        for (let x = 0; x < W; x++) {
            const [sx, sy] = applyH(Hinv, x + 0.5, y + 0.5);
            // bilinear sample from source
            // ...
        }
    }
    dstCtx.putImageData(dstImg, 0, 0);
    return dstCanvas;
}
Enter fullscreen mode Exit fullscreen mode

After rectification, the corrected image is displayed inline. The user can re-open the quad editor by tapping Edit, or export the image via jsFunctions.saveDocument, which triggers the native OS share sheet on Android/iOS or a file-save dialog on Windows and macOS.

Source Code

https://github.com/yushulx/dotnet-maui-blazor-barcode-mrz-document-scanner

Top comments (0)