Last week, we developed a desktop document scanner application using .NET MAUI and the Dynamic Web TWAIN REST API. We encountered an issue where the app’s UI, constructed with MAUI controls, behaved inconsistently on Windows and macOS. For instance, the Picker control functioned well on Windows but not on macOS. Considering .NET MAUI Blazor as an alternative, we decided to give it a try. The .NET MAUI Blazor template embeds a Blazor WebView control within the MAUI app, enabling us to build the UI with HTML and CSS, while handling logic with C# and JavaScript.
Prerequisites
Install Dynamsoft Service: This service is necessary for communicating with TWAIN, SANE, ICA, ESCL, and WIA scanners on Windows and macOS.
Windows: Dynamsoft-Service-Setup.msi
macOS: Dynamsoft-Service-Setup.pkg
Request a Free Trial License: Obtain a 30-day free trial license for Dynamic Web TWAIN to get started.
Step 1: Create a .NET MAUI Blazor Project
Start by scaffolding a new .NET MAUI Blazor project in Visual Studio 2022 (for Windows) or Visual Studio Code (for macOS). After creating the project, you’ll find the BlazorWebView control in the MainPage.xaml file:
<ContentPage xmlns=”http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=”http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:local=”clr-namespace:MauiBlazor”
x:Class=”MauiBlazor.MainPage”
BackgroundColor=”{DynamicResource PageBackgroundColor}”>
<BlazorWebView x:Name=”blazorWebView” HostPage=”wwwroot/index.html”>
<BlazorWebView.RootComponents>
<RootComponent Selector=”#app” ComponentType=”{x:Type local:Components.Routes}” />
</BlazorWebView.RootComponents>
</BlazorWebView>
</ContentPage>
Compared to a standard .NET MAUI project, a .NET MAUI Blazor project includes a wwwroot folder for static files (such as HTML, CSS, and JavaScript) and a Components folder for Blazor components.
Step 2: Construct the Document Scanner UI with HTML and CSS
Navigate to the Components/Pages/Home.razor file and replace the default content with the following HTML and CSS code:
<div class=“row”>
<div>
<label>
Get a License key from <a href=“https://www.dynamsoft.com/customer/license/trialLicense?product=dwt”
target=“_blank”>here</a>.
</label>
<input type=“text” placeholder=“@licenseKey” @bind=“licenseKey”>
</div>
</div>
<div class=“container”>
<div class=“image-tool”>
<h3>Acquire Image</h3>
<button class=“btn btn-primary” @onclick=“GetDevices”>Get Devices</button>
<label for=“sourceSelect”>Select Source</label>
<select id=“@sourceSelectId” class=“form-control”></select>
<label for=“pixelTypeSelect”>Pixel Type</label>
<select id=“@pixelTypeSelectId” class=“form-control”>
<option>B & W</option>
<option>Gray</option>
<option>Color</option>
</select>
<label for=“resolutionSelect”>Resolution</label>
<select id=“@resolutionSelectId” class=“form-control”>
<option>100</option>
<option>150</option>
<option>200</option>
<option>300</option>
</select>
<div class=“form-check”>
<input class=“form-check-input” type=“checkbox” id=“@showUICheckId”>
<label class=“form-check-label” for=“showUICheck”>Show UI</label>
</div>
<div class=“form-check”>
<input class=“form-check-input” type=“checkbox” id=“@adfCheckId”>
<label class=“form-check-label” for=“adfCheck”>ADF</label>
</div>
<div class=“form-check”>
<input class=“form-check-input” type=“checkbox” id=“@duplexCheckId”>
<label class=“form-check-label” for=“duplexCheck”>Duplex</label>
</div>
<button class=“btn btn-primary mt-3” @onclick=“ScanNow”>Scan Now</button>
<button class=“btn btn-primary mt-2” @onclick=“Save”>Save</button>
<h3>Image Tools</h3>
<div class=“image-tools”>
<button @onclick=“OnDeleteButtonClick” style=“border:none; background:none; padding:0;”>
<img src=“images/delete.png” alt=“Click Me” style=“width: 64px; height: 64px;” />
</button>
<button @onclick=“OnRotateLeftButtonClick” style=“border:none; background:none; padding:0;”>
<img src=“images/rotate_left.png” alt=“Click Me” style=“width: 64px; height: 64px;” />
</button>
<button @onclick=“OnRotateRightButtonClick” style=“border:none; background:none; padding:0;”>
<img src=“images/rotate_right.png” alt=“Click Me” style=“width: 64px; height: 64px;” />
</button>
</div>
</div>
<div class=“image-display”>
<div class=“full-img”>
<img id=“@imageId” src=“@imageDataUrl” class=“scanned-image”>
</div>
<div class=“row”>
<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>
</div>
</div>
</div>
Explanation
License Key Input: The input element is used to bind the license key, required for scanning documents.
Device Selection: The select elements display scanner devices, pixel types, and resolutions.
Scanning Options: Checkboxes set the scanning options, such as showing the UI, using the ADF, and enabling duplex scanning.
Action Buttons: Buttons trigger scanning, saving, deleting, and rotating images.
Image Display: The image element displays the scanned image.
Thumbnail Display: Thumbnail images are displayed in the thumb-box div element, with click events bound to handle image selection.
Step 3: Implement the Document Scanner Logic in JavaScript and C
For performance reasons, it’s better to implement the document scanning logic in JavaScript, while using C# to invoke the JavaScript functions and system APIs.
JavaScript Code
First, create an interop.js file in the wwwroot folder to define the JavaScript functions.
getDevices: async function (host, scannerType, selectId) {
…
},
scanDocument: async function (host, licenseKey, sourceSelectId, pixelTypeSelectId, resolutionSelectId, showUICheckId, adfCheckId, duplexCheckId, timeout = 30) {
…
},
fetchImageAsBase64: async function (url) {
…
},
displayAlert: function(message) {
…
},
rotateImage: async function (imageId, angle) {
…s
}
};
Explanation
getDevices: Fetches scanner devices and populates a select element in JavaScript.
getDevices: async function (host, scannerType, selectId) {
let select = document.getElementById(selectId);
select.innerHTML = ”;
try {
let url = host + ‘/DWTAPI/Scanners‘;
if (scannerType != null || scannerType !== ”) {
url += ‘?type=‘ + scannerType;
}
let response = await fetch(url);
if (response.ok) {
let devices = await response.json();
for (let i = 0; i < devices.length; i++) {
let device = devices[i];
let option = document.createElement(“option“);
option.text = device[‘name‘];
option.value = JSON.stringify(device);
select.add(option);
};
return devices;
}
else {
return “”;
}
} catch (error) {
alert(error);
return “”;
}
},
scanDocument: Scans documents and fetches the scanned images as array buffers, converting them to URLs.
scanDocument: async function (host, licenseKey, sourceSelectId, pixelTypeSelectId, resolutionSelectId, showUICheckId, adfCheckId, duplexCheckId, timeout = 30) {
let select = document.getElementById(sourceSelectId);
let scanner = select.value;
if (scanner == null || scanner.length == 0) {
alert(‘Please select a scanner.‘);
return;
}
if (licenseKey == null || licenseKey.length == 0) {
alert(‘Please input a valid license key.‘);
}
let showUICheck = document.getElementById(showUICheckId);
let pixelTypeSelect = document.getElementById(pixelTypeSelectId);
let resolutionSelect = document.getElementById(resolutionSelectId);
let adfCheck = document.getElementById(adfCheckId);
let duplexCheck = document.getElementById(duplexCheckId);
let parameters = {
license: licenseKey,
device: JSON.parse(scanner)[‘device‘],
};
parameters.config = {
IfShowUI: showUICheck.checked,
PixelType: pixelTypeSelect.selectedIndex,
Resolution: parseInt(resolutionSelect.value),
IfFeederEnabled: adfCheck.checked,
IfDuplexEnabled: duplexCheck.checked,
};
// REST endpoint to create a scan job
let url = host + ‘/DWTAPI/ScanJobs?timeout=‘ + timeout;
try {
let response = await fetch(url, {
method: ‘POST‘,
headers: {
‘Content-Type‘: ‘application/json‘
},
body: JSON.stringify(parameters)
});
if (response.ok) {
let jobId = await response.text();
let images = await getImages(host, jobId, ‘images‘);
return images;
}
else {
return [];
}
} catch (error) {
alert(error);
return [];
}
},
getImages: Retrieves the scanned images from the scan job.
async function getImages(host, jobId) {
let images = [];
let url = host + ‘/DWTAPI/ScanJobs/‘ + jobId + ‘/NextDocument‘;
while (true) {
try {
let response = await fetch(url);
if (response.status == 200) {
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: response.type });
const imageUrl = URL.createObjectURL(blob);
images.push(imageUrl);
}
else {
break;
}
} catch (error) {
console.error(‘No more images.‘);
break;
}
}
return images;
}
fetchImageAsBase64: Converts images as base64 strings for saving in C#.
fetchImageAsBase64: async function (url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(‘Network response was not ok‘);
}
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: response.type });
const reader = new FileReader();
return new Promise((resolve, reject) => {
reader.onloadend = () => resolve(reader.result.split(‘,‘)[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (error) {
console.error(‘Error fetching image:‘, error);
return null;
}
},
displayAlert: Displays an alert message.
displayAlert: function(message) {
alert(message);
},
rotateImage: Loads an image into a canvas, rotates it, and converts it back to a data URL.
rotateImage: async function (imageId, angle) {
const image = document.getElementById(imageId);
const canvas = document.createElement(‘canvas‘);
const context = canvas.getContext(‘2d‘);
const imageWidth = image.naturalWidth;
const imageHeight = image.naturalHeight;
// Calculate the new rotation
let rotation = 0;
rotation = (rotation + angle) % 360;
// Adjust canvas size for rotation
if (rotation === 90 || rotation === –270 || rotation === 270) {
canvas.width = imageHeight;
canvas.height = imageWidth;
} else if (rotation === 180 || rotation === –180) {
canvas.width = imageWidth;
canvas.height = imageHeight;
} else if (rotation === 270 || rotation === –90) {
canvas.width = imageHeight;
canvas.height = imageWidth;
} else {
canvas.width = imageWidth;
canvas.height = imageHeight;
}
// Clear the canvas
context.clearRect(0, 0, canvas.width, canvas.height);
// Draw the rotated image on the canvas
context.save();
if (rotation === 90 || rotation === –270) {
context.translate(canvas.width, 0);
context.rotate(90 * Math.PI / 180);
} else if (rotation === 180 || rotation === –180) {
context.translate(canvas.width, canvas.height);
context.rotate(180 * Math.PI / 180);
} else if (rotation === 270 || rotation === –90) {
context.translate(0, canvas.height);
context.rotate(270 * Math.PI / 180);
}
context.drawImage(image, 0, 0);
context.restore();
return canvas.toDataURL();
}
C# Code
Create a Models folder in the root directory and add a ScannerType.cs file to define the scanner types.
{
public static class ScannerType
{
public const int TWAINSCANNER = 0x10;
public const int WIASCANNER = 0x20;
public const int TWAINX64SCANNER = 0x40;
public const int ICASCANNER = 0x80;
public const int SANESCANNER = 0x100;
public const int ESCLSCANNER = 0x200;
public const int WIFIDIRECTSCANNER = 0x400;
public const int WIATWAINSCANNER = 0x800;
}
}
In the Home.razor file, add the following C# code to invoke the JavaScript functions.
@code {
private bool isLoading = false;
private string host = “http://127.0.0.1:18622”;
private string licenseKey = “LICENSE-KEY”;
private string jobId = “”;
private string imageDataUrl { get; set; } = string.Empty;
private string selectedValue { get; set; } = string.Empty;
private List<string> imageUrls { get; set; } = new List<string>();
private static string IP = “127.0.0.1”;
private string sourceSelectId = “sourceSelect”;
private string pixelTypeSelectId = “pixelTypeSelect”;
private string resolutionSelectId = “resolutionSelect”;
private string showUICheckId = “showUICheck”;
private string adfCheckId = “adfCheck”;
private string duplexCheckId = “duplexCheck”;
private string imageId = “document-image”;
private int currentIndex = 0;
private void OnDeleteButtonClick()
{
imageUrls.Clear();
imageDataUrl = string.Empty;
}
private async Task OnRotateLeftButtonClick()
{
if (string.IsNullOrEmpty(imageDataUrl)) return;
imageDataUrl = await JSRuntime.InvokeAsync<string>(“jsFunctions.rotateImage”, imageId, –90);
imageUrls[currentIndex] = imageDataUrl;
}
private async Task OnRotateRightButtonClick()
{
if (string.IsNullOrEmpty(imageDataUrl)) return;
imageDataUrl = await JSRuntime.InvokeAsync<string>(“jsFunctions.rotateImage”, imageId, 90);
imageUrls[currentIndex] = imageDataUrl;
}
public async Task GetDevices()
{
isLoading = true;
try
{
var json = await JSRuntime.InvokeAsync<IJSObjectReference>(“jsFunctions.getDevices”, host, ScannerType.TWAINSCANNER | ScannerType.TWAINX64SCANNER, sourceSelectId);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
isLoading = false;
}
private async Task ScanNow()
{
var images = await JSRuntime.InvokeAsync<string[]>(“jsFunctions.scanDocument”, host, licenseKey, sourceSelectId, pixelTypeSelectId, resolutionSelectId, showUICheckId, adfCheckId, duplexCheckId);
if (images != null && images.Length > 0)
{
foreach (var image in images)
{
imageUrls.Insert(0, image);
}
imageDataUrl = imageUrls[0];
}
}
private async Task Save()
{
if (string.IsNullOrEmpty(imageDataUrl))
{
await JSRuntime.InvokeVoidAsync(“jsFunctions.displayAlert”, “Please scan an image first.”);
return;
}
string base64String = await JSRuntime.InvokeAsync<string>(“jsFunctions.fetchImageAsBase64”, imageDataUrl);
if (!string.IsNullOrEmpty(base64String))
{
byte[] imageBytes = Convert.FromBase64String(base64String);
string filePath = Path.Combine(FileSystem.AppDataDirectory, GenerateFilename());
try
{
await File.WriteAllBytesAsync(filePath, imageBytes);
await JSRuntime.InvokeVoidAsync(“jsFunctions.displayAlert”, $”Image saved to {filePath}“);
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync(“jsFunctions.displayAlert”, $”Error saving image: {ex.Message}“);
}
}
else
{
Console.WriteLine(“Failed to fetch the image.”);
}
}
private void OnImageClick(string url)
{
imageDataUrl = url;
currentIndex = imageUrls.IndexOf(url);
}
private string GenerateFilename()
{
DateTime now = DateTime.Now;
string timestamp = now.ToString(“yyyyMMdd_HHmmss”);
return $”image_{timestamp}.png”;
}
}
Explanation
HTML element IDs are bound to corresponding C# properties.
The imageUrls list stores the scanned images.
The currentIndex property tracks the currently selected image index.
The Save method saves the scanned image to the local file system.
Step 4: Run the .NET MAUI Blazor Document Scanner on Windows and macOS
Press F5 in Visual Studio or Visual Studio Code to run the .NET document scanner application on Windows or macOS.
Windows
macOS
Source Code
https://github.com/yushulx/dotnet-twain-wia-sane-scanner/tree/main/examples/MauiBlazor