Dynamsoft used to offer both the .NET TWAIN SDK and the Dynamic Web TWAIN SDK. However, the .NET TWAIN SDK is no longer maintained, and the focus has shifted to the web-based Dynamic Web TWAIN SDK. Despite this shift, you can still build a desktop document scanner application in .NET by leveraging the REST API provided by Dynamsoft Service. In this article, I will demonstrate how to create a desktop document scanner application for both Windows and macOS using .NET MAUI.
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.
-
Install the NuGet Package:
- https://www.nuget.org/packages/Twain.Wia.Sane.Scanner/. This package wraps the Dynamsoft Service RESTful API, facilitating .NET application development.
- SkiaSharp and SkiaSharp.Views.Maui.Controls. These packages are required for rendering images in .NET MAUI applications. Although, .NET MAUI offers a built-in Image control and a GraphicsView control for rendering images, they have some known issues with rendering and saving images.
Step 1: Create a .NET MAUI Project
- Create a New Project: Start a new .NET MAUI project in Visual Studio 2022 (for Windows) or Visual Studio Code (for macOS).
-
Add NuGet Packages: Open the terminal and add the following NuGet packages to your project:
dotnet add package Twain.Wia.Sane.Scanner dotnet add package SkiaSharp dotnet add package SkiaSharp.Views.Maui.Controls
-
Enable SkiaSharp: Modify the
MauiProgram.cs
file to enableSkiaSharp
:
using Microsoft.Extensions.Logging; using SkiaSharp.Views.Maui.Controls.Hosting; namespace MauiAppDocScan { public static class MauiProgram { public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); builder .UseMauiApp<App>().UseSkiaSharp() .ConfigureFonts(fonts => { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); }); #if DEBUG builder.Logging.AddDebug(); #endif return builder.Build(); } } }
Step 2: Construct the Document Scanner Page in XAML
-
Include SkiaSharp Namespace: Add the namespace for
SkiaSharp.Views.Maui.Controls
in theMainPage.xaml
file:
<?xml version="1.0" encoding="utf-8" ?> <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:skia="clr-namespace:SkiaSharp.Views.Maui.Controls;assembly=SkiaSharp.Views.Maui.Controls" x:Class="MauiAppDocScan.MainPage"> </ContentPage>
-
Create the UI Layout: Use
HorizontalStackLayout
andVerticalStackLayout
to design a simple user interface for the document scanner:
<?xml version="1.0" encoding="utf-8" ?> <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:skia="clr-namespace:SkiaSharp.Views.Maui.Controls;assembly=SkiaSharp.Views.Maui.Controls" x:Class="MauiAppDocScan.MainPage"> <HorizontalStackLayout HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand"> <VerticalStackLayout Margin="20" MaximumWidthRequest="400" WidthRequest="400" Spacing="20"> <StackLayout Padding="10" BackgroundColor="#f0f0f0" Spacing="5"> <Label Text="Acquire Image" FontAttributes="Bold" Margin="0,0,0,10" /> <Button x:Name="GetDeviceBtn" Text="Get Devices" Clicked="OnGetDeviceClicked"/> <Label Text="Select Source"/> <Picker x:Name="DevicePicker" ItemsSource="{Binding Items}"> </Picker> <Label Text="Pixel Type"/> <Picker x:Name="ColorPicker"> <Picker.ItemsSource> <x:Array Type="{x:Type x:String}"> <x:String>B & W</x:String> <x:String>Gray</x:String> <x:String>Color</x:String> </x:Array> </Picker.ItemsSource> </Picker> <Label Text="Resolution"/> <Picker x:Name="ResolutionPicker"> <Picker.ItemsSource> <x:Array Type="{x:Type x:Int32}"> <x:Int32>100</x:Int32> <x:Int32>150</x:Int32> <x:Int32>200</x:Int32> <x:Int32>300</x:Int32> </x:Array> </Picker.ItemsSource> </Picker> <StackLayout Orientation="Horizontal"> <CheckBox x:Name="showUICheckbox" /> <Label Text="Show UI" VerticalOptions="Center" /> </StackLayout> <StackLayout Orientation="Horizontal"> <CheckBox x:Name="adfCheckbox" /> <Label Text="ADF" VerticalOptions="Center" /> </StackLayout> <StackLayout Orientation="Horizontal"> <CheckBox x:Name="duplexCheckbox" /> <Label Text="Duplex" VerticalOptions="Center" /> </StackLayout> <Button x:Name="ScanBtn" Text="Scan Now" Clicked="OnScanClicked"/> <Button x:Name="SaveBtn" Text="Save" Clicked="OnSaveClicked"/> </StackLayout> <StackLayout Padding="10" BackgroundColor="#f0f0f0"> <Label Text="Image Tools" FontAttributes="Bold" Margin="0,0,0,10" /> <Grid RowDefinitions="auto, auto" ColumnDefinitions="auto, auto" RowSpacing="5" ColumnSpacing="5"> <ImageButton Source="delete.png" Clicked="OnDeleteAllClicked" HeightRequest="20" WidthRequest="20" VerticalOptions="Center" Grid.Row="0" Grid.Column="0" /> <ImageButton Source="rotate_left.png" Clicked="OnRotateLeftClicked" HeightRequest="20" WidthRequest="20" VerticalOptions="Center" Grid.Row="1" Grid.Column="0" /> <ImageButton Source="rotate_right.png" Clicked="OnRotateRightClicked" HeightRequest="20" WidthRequest="20" VerticalOptions="Center" Grid.Row="1" Grid.Column="1" /> </Grid> </StackLayout> </VerticalStackLayout> <ScrollView x:Name="ImageScrollView" WidthRequest="400" HeightRequest="800"> <StackLayout x:Name="ImageContainer" /> </ScrollView> <Grid WidthRequest="800" HeightRequest="800"> <Image Source="white.png" /> <skia:SKCanvasView x:Name="skiaView" PaintSurface="OnCanvasViewPaintSurface" /> </Grid> </HorizontalStackLayout> </ContentPage>
The UI consists of four main parts:
-
Document Scanner Settings: Select the scanner source, pixel type, resolution, and other settings. Note: If you set a title for a Picker, it won't normally work on macOS.
Image Tools: Delete all images, rotate images left or right.
Thumbnails: Display scanned images in a
ScrollView
control. Each image is displayed in anImage
control. Note: When an image stream is set to the Image control, the stream will be closed. This makes it inconvenient to save an image to the local file system.Image Display: Display a selected image in a
SKCanvasView
control.
-
Step 3: Implement the Document Scanner Logic in C
-
Initialize ScannerController: Set up the
ScannerController
object, host address of the Dynamsoft Service, and the license key in theMainPage.xaml.cs
file.
using SkiaSharp; using SkiaSharp.Views.Maui; using System.Collections.ObjectModel; using Twain.Wia.Sane.Scanner; using Microsoft.Maui.Graphics.Platform; using IImage = Microsoft.Maui.Graphics.IImage; using Microsoft.Maui.Controls; namespace MauiAppDocScan { public partial class MainPage : ContentPage { private static string licenseKey = "LICENSE-KEY"; private static ScannerController scannerController = new ScannerController(); private static string host = "http://127.0.0.1:18622"; } }
Explanation
- License Key: Set your Dynamic Web TWAIN license key.
- Scanner Controller: Initialize the ScannerController object to handle scanning operations.
- Host Address: The default host address and port are http://127.0.0.1:18622. You can change this IP by visiting http://127.0.0.1:18625/ in a browser and updating it to your LAN IP address for access by other devices on the same network.
-
Implement the OnGetDeviceClicked Method: Retrieve scanner devices and display them in the
DevicePicker
control.
public ObservableCollection<string> Items { get; set; } public MainPage() { InitializeComponent(); Items = new ObservableCollection<string> { }; BindingContext = this; ColorPicker.SelectedIndex = 0; ResolutionPicker.SelectedIndex = 0; } private async void OnGetDeviceClicked(object sender, EventArgs e) { var scanners = await scannerController.GetDevices(host, ScannerType.TWAINSCANNER | ScannerType.TWAINX64SCANNER); devices.Clear(); Items.Clear(); if (scanners.Count == 0) { await DisplayAlert("Error", "No scanner found", "OK"); return; } for (int i = 0; i < scanners.Count; i++) { var scanner = scanners[i]; devices.Add(scanner); var name = scanner["name"]; Items.Add(name.ToString()); } DevicePicker.SelectedIndex = 0; }
Explanation
-
ObservableCollection: Used for binding the list of scanner devices to the
DevicePicker
control. -
Initialization: Default selections are set for
ColorPicker
andResolutionPicker
in the constructor. -
Async Method: The
OnGetDeviceClicked
method asynchronously retrieves available scanners and populates theDevicePicker
. -
Scanner Type: The
ScannerType
enum specifies the types of scanners to retrieve. If not specified, retrieving all available scanners may take longer.
-
ObservableCollection: Used for binding the list of scanner devices to the
-
Configure Scanning Parameters and Display Scanned Images: Set up parameters for scanning documents and display the scanned images in the
ImageContainer
control.
private List<byte[]> _streams = new List<byte[]>(); private async void OnScanClicked(object sender, EventArgs e) { if (DevicePicker.SelectedIndex < 0) return; var parameters = new Dictionary<string, object> { {"license", licenseKey}, {"device", devices[DevicePicker.SelectedIndex]["device"]} }; parameters["config"] = new Dictionary<string, object> { {"IfShowUI", showUICheckbox.IsChecked}, {"PixelType", ColorPicker.SelectedIndex}, {"Resolution", (int)ResolutionPicker.SelectedItem}, {"IfFeederEnabled", adfCheckbox.IsChecked}, {"IfDuplexEnabled", duplexCheckbox.IsChecked} }; string jobId = await scannerController.ScanDocument(host, parameters); if (!string.IsNullOrEmpty(jobId)) { var images = await scannerController.GetImageStreams(host, jobId); int start = _streams.Count; for (int i = 0; i < images.Count; i++) { MemoryStream stream = new MemoryStream(images[i]); _streams.Add(images[i]); ImageSource imageStream = ImageSource.FromStream(() => stream); Image image = new Image { WidthRequest = 200, HeightRequest = 200, Aspect = Aspect.AspectFit, VerticalOptions = LayoutOptions.CenterAndExpand, HorizontalOptions = LayoutOptions.CenterAndExpand, Source = imageStream, BindingContext = i + start }; // Add the TapGestureRecognizer var tapGestureRecognizer = new TapGestureRecognizer(); tapGestureRecognizer.Tapped += OnImageTapped; image.GestureRecognizers.Add(tapGestureRecognizer); ImageContainer.Children.Add(image); } if (ImageContainer.Children.Count > 0) { selectedIndex = _streams.Count - 1; var lastImage = ImageContainer.Children.Last(); DrawImage(_streams[_streams.Count - 1]); await ImageScrollView.ScrollToAsync((Image)lastImage, ScrollToPosition.MakeVisible, true); } } } private async void OnImageTapped(object sender, EventArgs e) { if (sender is Image image && image.BindingContext is int index) { byte[] imageData = _streams[index]; DrawImage(imageData); selectedIndex = index; } }
Explanation
- License Key: A valid license key is required for scanning documents. Without it, the HTTP request will return an error.
-
Image Retrieval: The
GetImageStreams
method returns a list of byte arrays, each representing an image. These byte arrays can be converted toStream
objects and then toImageSource
objects. -
Image Display: The
Image
control is used to display each image and is added to theImageContainer
. ATapGestureRecognizer
is added to eachImage
control to handle theTapped
event for displaying the image in a larger view. -
Scrolling: After scanning, the view automatically scrolls to the last added image and displays it using the
SKCanvasView
control.
-
Display the Selected Image: Display a selected image in the
SKCanvasView
Control.
private void DrawImage(byte[] buffer) { try { if (bitmap != null) { bitmap.Dispose(); bitmap = null; } if (_streams.Count > 0) { bitmap = SKBitmap.Decode(buffer); skiaView.InvalidateSurface(); } } catch (Exception ex) { Console.WriteLine(ex); } } private void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs e) { SKCanvas canvas = e.Surface.Canvas; canvas.Clear(SKColors.White); if (bitmap != null) { // Calculate the aspect ratio float bitmapWidth = bitmap.Width; float bitmapHeight = bitmap.Height; float canvasWidth = e.Info.Width; float canvasHeight = e.Info.Height; float scale = Math.Min(canvasWidth / bitmapWidth, canvasHeight / bitmapHeight); float newWidth = scale * bitmapWidth; float newHeight = scale * bitmapHeight; float left = (canvasWidth - newWidth) / 2; float top = (canvasHeight - newHeight) / 2; SKRect destRect = new SKRect(left, top, left + newWidth, top + newHeight); canvas.DrawBitmap(bitmap, destRect); } }
Explanation
-
DrawImage Method: The
DrawImage
method decodes the byte array to anSKBitmap
object and then triggers theInvalidateSurface
event to redraw the image. -
OnCanvasViewPaintSurface Method: The
OnCanvasViewPaintSurface
method is called when thePaintSurface
event is triggered. It calculates the aspect ratio of the image and draws the image on theSKCanvasView
control.
-
DrawImage Method: The
-
Save an Image: Save the selected image to the local file system.
public static string GenerateFilename() { DateTime now = DateTime.Now; string timestamp = now.ToString("yyyyMMdd_HHmmss"); return $"image_{timestamp}.png"; } private async void OnSaveClicked(object sender, EventArgs e) { if (_streams.Count == 0) return; var status = await Permissions.RequestAsync<Permissions.StorageWrite>(); if (status != PermissionStatus.Granted) { // Handle the case where the user denies permission return; } if (bitmap != null) { //// Define the path where you want to save the images var filePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), GenerateFilename()); using SKImage image = SKImage.FromBitmap(bitmap); using SKData data = image.Encode(SKEncodedImageFormat.Jpeg, 100); using FileStream stream = File.OpenWrite(filePath); data.SaveTo(stream); DisplayAlert("Success", "Image saved to " + filePath, "OK"); } }
Explanation
-
MyDocuments: The
MyDocuments
folder is used to save the scanned images. You can change the path to another folder. -
SKImage and SKData: To save the
SkBitmap
object to a file, you need to convert it to anSKImage
object and then encode it to aSKData
object. Finally, save theSKData
object to a file stream.
-
MyDocuments: The
Step 4: Run the Document Scanner Application 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/MauiAppDocScan