How to Scan and Digitize Documents in Blazor WebAssembly with C# and .NET

Last week, we developed a RESTful service using .NET C# and Twain.Wia.Sane.Scanner library to scan documents from TWAIN, WIA, SANE, and eSCL scanners. This week, we will repurpose the C# code to construct a Blazor WebAssembly application that digitizes documents directly from a web browser, all without the need for JavaScript.

What you’ll build: A Blazor WebAssembly app that scans and digitizes documents from TWAIN, WIA, SANE, and eSCL scanners entirely in C#—no JavaScript required.

Key Takeaways

  • Blazor WebAssembly allows you to build a browser-based document scanner using only C# and the Twain.Wia.Sane.Scanner NuGet package, eliminating the need for JavaScript interop.
  • The Dynamic Web TWAIN Service exposes a RESTful API on the local machine, enabling any web app to communicate with physical scanners over HTTP.
  • Scanned images are streamed as byte[], converted to Base64, and displayed inline in the browser using Blazor’s data-binding syntax.
  • This approach works cross-platform (Windows, macOS, Linux) and supports TWAIN, WIA, SANE, and eSCL scanner protocols.

Common Developer Questions

How do I scan documents from a web browser using Blazor WebAssembly and C#?

Reference the Twain.Wia.Sane.Scanner package in the Blazor WebAssembly project, point it at the Dynamic Web TWAIN Service host, and call its C# methods to enumerate scanners and create scan jobs. The browser UI stays in Razor components while the actual scanner communication happens through the local REST service.

Can Blazor WebAssembly access TWAIN or WIA scanners without JavaScript?

Yes. In this approach, Blazor never calls native scanner drivers directly. It talks to the Dynamic Web TWAIN Service over HTTP through a C# wrapper library, so the app can stay in C# without JavaScript scanner interop.

How do I display scanned document images in a Blazor app?

Read the scanned page bytes from the service, convert them to Base64, and bind the result to an image data URL in the Razor component. That lets Blazor render the scanned document inline with normal data binding.

Prerequisites

  1. Install the Dynamic Web TWAIN Service on a machine that is connected to scanners and hosts a web service.
  2. Modify the host IP address to make it publicly accessible.

    dynamsoft-service-config

    You can test it by visiting http://[host-ip]:18626/DWTAPI/Scanners in a browser. If you see the following page, it means the Dynamic Web TWAIN Service is successfully installed and running.

    dynamsoft-service-scanner-list

  3. Get a 30-day free trial license for Dynamic Web TWAIN Service.

Build a Web-Based Document Scanner with Blazor WebAssembly

In the following paragraphs, we will demonstrate the process of constructing a web-based document digitization application from scratch. For clarity, we will start by establishing an empty Blazor WebAssembly project, which will only encompass an Index.razor file.

Step 1: Set Up the Blazor WebAssembly Project

  1. Create the Blazor project using Visual Studio or the command line:

     dotnet new blazorwasm-empty -o blazorwasm-empty-project
    
  2. Install the NuGet package:

     dotnet add package Twain.Wia.Sane.Scanner --version 1.1.0
    

Step 2: Build the Document Scanner UI in HTML

We construct the app UI as follow:

<div id="loading-indicator" class="loading-indicator">
    <div class="spinner"></div>
</div>

<div class="card">
    <h2>Web TWAIN REST API</h2>
    <p class="card-note">Acquire scans through the Dynamsoft Service REST API.</p>

    <div class="connection">
        <div class="control-row">
            <label for="license">License key</label>
            <input type="text" id="license">
        </div>
        <div class="control-row">
            <label for="host">Host address</label>
            <input type="text" id="host">
        </div>
    </div>

    <div class="control-row">
        <button onclick="getDevices()">Get Devices</button>
        <select id="sources">
        </select>
        <button class="btn-primary" onclick="acquireImage()">Scan Documents</button>
    </div>

    <p class="card-note error" id="status"></p>

    <div class="full-img">
        <img id="scanner-image">
    </div>

    <div class="thumb-bar" id="thumb-bar">
        <div class="thumb-box" id="thumb-box">
        </div>
    </div>
</div>

The loading-indicator shows the loading animation. The card hosts the REST controls: the license key and host inputs, the scanner picker, and the scanned-page preview with its thumbnail strip. Scanning through the service needs a license key the local service can validate — see Common Issues and Edge Cases.

Step 3: Convert HTML to Razor Syntax

Razor is a markup syntax that enables developers to embed C# code within HTML, facilitating the creation of dynamic web pages using C#. In the following sections, we will convert the above HTML code to comply with the Razor syntax.

How to import a .NET library into a Blazor project?

Import the Twain.Wia.Sane.Scanner namespace in the Index.razor file to use the ScannerController class:

@page "/"
@using Twain.Wia.Sane.Scanner

@code {
    private ScannerController scannerController = new ScannerController();
}

How to append options to select element in Blazor?

To append options to a select element in a Blazor app using C#, we typically use the Blazor rendering system, rather than manipulating the DOM directly with JavaScript.

<button @onclick="GetDevices">Get Devices</button>
<select id="sources" @bind="selectedValue">
    @foreach (var device in devices)
    {
        <option value="@device["name"].ToString()">@device["name"].ToString()</option>
    }
</select>

@code {
    private bool isLoading = false;

    private string host = "http://127.0.0.1:18622"; 
    private List<Dictionary<string, object>> devices = new List<Dictionary<string, object>>();
    private string licenseKey = "LICENSE-KEY";
    
    private string jobId = "";
    private string selectedValue { get; set; } = string.Empty;
    
    private List<string> imageUrls { get; set; } = new List<string>();
    public async Task GetDevices()
    {
        isLoading = true;

        try
        {
            var scannerInfo = await scannerController.GetDevices(host);
            try
            {
                var scanners = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(scannerInfo);
                for (int i = 0; i < scanners.Count; i++)
                {
                    var scanner = scanners[i];
                    devices.Add(scanner);
                    Console.WriteLine($"\nIndex: {i}, Name: {scanner["name"]}");
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Error: {ex.Message}");
            }
            if (devices.Count > 0)
            {
                selectedValue = devices[0]["name"].ToString();
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }

        isLoading = false;
    }
}
  • host points at the local Dynamsoft service; licenseKey must be a key the service itself can validate (an unbound key such as a Dynamic Web TWAIN trial license).
  • The List<Dictionary<string, object>> devices stores the options for the select element.
  • The @foreach directive is used to render each option in the devices list.
  • The @bind directive is used for two-way data binding. It bind the selected value to the selectedValue property.

How to show the loading indicator?

To show a loading indicator when pressing the button and hide it once the function completes, we use a boolean flag that tracks whether data is being loaded. Based on the value of this flag, the loading indicator can be rendered conditionally.

<div id="loading-indicator" class="loading-indicator" style="@(isLoading ? "display: flex;" : "display: none;")">
    <div class="spinner"></div>
</div>

How to acquire the image stream and display it in the browser?

The image stream is of type byte[]. To display such a byte[] image stream in an <img> element in Blazor, it can be converted to a Base64 string. Using the data: URL scheme, this string can be embedded directly into the src attribute of the <img> element. All image URLs are stored in the imageUrls list. By clicking on a thumbnail, the corresponding image will be displayed in the <img> element.


<button @onclick="AcquireImage">Scan Documents</button>

<div class="full-img">
    <img id="scanner-image" src="@imageDataUrl">
</div>

<div class="thumb-bar" id="thumb-bar">
    <div class="thumb-box" id="thumb-box">
        @foreach (var url in imageUrls)
        {
            <img src="@url" @onclick="() => OnImageClick(url)" />
        }
    </div>
</div>

@code {
    private string statusMessage = "";

    public async Task AcquireImage()
    {
        if (devices.Count == 0)
        {
            statusMessage = "Select a scanner first — click \"Get Devices\".";
            return;
        }
    
        int selectedIndex = devices.FindIndex(device => device["name"].ToString() == selectedValue);
        if (selectedIndex < 0)
        {
            statusMessage = "The selected scanner is no longer available. Click \"Get Devices\" again.";
            return;
        }
        var parameters = new Dictionary<string, object>
                {
                    {"license", licenseKey},
                    {"device", devices[selectedIndex]["device"]},
                    {"autoRun", true}
                };
    
        parameters["config"] = new Dictionary<string, object>
                {
                    {"IfShowUI", false},
                    {"PixelType", 2},
                    {"Resolution", 200},
                    {"IfFeederEnabled", false},
                    {"IfDuplexEnabled", false}
                };

        try
        {
            var jobInfo = await scannerController.CreateJob(host, parameters);

            // The service reports failures (an expired or invalid license, for
            // example) as a JSON error object without a job id. Surface that
            // message instead of failing later on the missing "jobuid" key.
            Dictionary<string, object>? job = null;
            try
            {
                job = JsonConvert.DeserializeObject<Dictionary<string, object>>(jobInfo);
            }
            catch (Exception ex)
            {
                statusMessage = "Unexpected response from the local service: " + jobInfo + " (" + ex.Message + ")";
                return;
            }

            if (job == null || !job.TryGetValue("jobuid", out var uid) || string.IsNullOrEmpty(uid?.ToString()))
            {
                statusMessage = "Failed to create the scan job. The service said: " + jobInfo;
                return;
            }

            string jobId = uid!.ToString()!;
            byte[] bytes = await scannerController.GetImageStream(host, jobId);
            imageDataUrl = $"data:image/png;base64,{Convert.ToBase64String(bytes)}";
            AddImageUrl(imageDataUrl);
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex);
            statusMessage = "Scan failed: " + ex.Message;
        }
    }
    
    private void AddImageUrl(string url)
    {
        imageUrls.Add(url);
    }
    
    private void OnImageClick(string url)
    {
        imageDataUrl = url;
    }
}

Deploy the Blazor App to GitHub Pages

After completing the Blazor project, we can deploy it to GitHub Pages.

  1. Create a new repository on GitHub.
  2. Push the Blazor project to the repository.
  3. Navigate to the repository: Settings > Actions > General > Workflow permissions and enable Read and write permissions.
  4. Create a new workflow file named main.yml in the .github/workflows folder. Replace dotnet-blazor-digitize-document with your own repository name.

     name: blazorwasm
     on:
       push:
         branches: [ main ]
       pull_request:
         branches: [ main ]
        
       workflow_dispatch:
        
     jobs:
       build:
         runs-on: ubuntu-latest
        
         steps:
           - uses: actions/checkout@v3
              
           - name: Setup .NET Core SDK
             uses: actions/setup-dotnet@v2
             with:
               dotnet-version: '7.0.x'
               include-prerelease: true
                  
           - name: Publish .NET Core Project
             run: dotnet publish blazorwasm-empty-project.csproj -c Release -o release --nologo 
                
           - name: Change base-tag in index.html from / to dotnet-blazor-digitize-document
             run: sed -i 's/<base href="\/" \/>/<base href="\/dotnet-blazor-digitize-document\/" \/>/g' release/wwwroot/index.html
                
           - name: copy index.html to 404.html
             run: cp release/wwwroot/index.html release/wwwroot/404.html
                
           - name: Add .nojekyll file
             run: touch release/wwwroot/.nojekyll
              
           - name: Commit wwwroot to GitHub Pages
             uses: JamesIves/github-pages-deploy-action@3.7.1
             with:
               GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
               BRANCH: gh-pages
               FOLDER: release/wwwroot
    
  5. To test your local Dynamic Web TWAIN Service on a GitHub page, install ngrok and run ngrok http 18622 to expose the local port 18622 to the internet.

Try the Online Demo

Visit Free Online Blazor Barcode, MRZ and Document Scanner and open the Web TWAIN REST API example (needs the local Dynamic Web TWAIN service). This example scans through the service’s REST API, so it asks for a license key the service itself can validate — a Dynamic Web TWAIN trial license works. The other examples on that page activate automatically.

blazor-digitize-document

Common Issues and Edge Cases

  • Dynamic Web TWAIN Service not detected: If visiting http://127.0.0.1:18626/DWTAPI/Scanners returns no response, confirm the service is running and the host IP in the service configuration matches your machine’s address. On Linux, check that the service has proper permissions.
  • CORS errors when connecting from Blazor: When the Blazor app runs on a different origin (e.g., GitHub Pages) than the Dynamic Web TWAIN Service, you must expose the service via a tunnel such as ngrok. Direct cross-origin requests to localhost will be blocked by the browser.
  • Scanned image not rendering: If the <img> element remains blank after scanning, verify that the jobId returned by CreateJob is valid and that GetImageStream returns a non-empty byte[].
  • “License is invalid” (HTTP 403) when scanning: CreateJob returns 403 when the service rejects the key. The service performs its own license handshake and identifies as DynamicWebTWAINService, so a key bound to a website domain is refused with “Mismatch in AppDomain for handshake” — use an unbound key such as a Dynamic Web TWAIN trial license. Expired 24-hour demo keys return the same 403. The example surfaces the service’s exact message under the toolbar instead of failing on the missing job id.

Source Code

Get the complete sample project source code on GitHub