DEV Community

Xiao Ling
Xiao Ling

Posted on • Originally published at dynamsoft.com

Building a .NET TWAIN Document Scanner Application for Windows and macOS using MAUI

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

  1. Install Dynamsoft Service: This service is necessary for communicating with TWAIN, SANE, ICA, ESCL, and WIA scanners on Windows and macOS.
  2. Request a Free Trial License: Obtain a 30-day free trial license for Dynamic Web TWAIN to get started.

  3. Install the NuGet Package:

Step 1: Create a .NET MAUI Project

  1. Create a New Project: Start a new .NET MAUI project in Visual Studio 2022 (for Windows) or Visual Studio Code (for macOS).
  2. 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
    
  3. Enable SkiaSharp: Modify the MauiProgram.cs file to enable SkiaSharp:

    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

  1. Include SkiaSharp Namespace: Add the namespace for SkiaSharp.Views.Maui.Controls in the MainPage.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>
    
  2. Create the UI Layout: Use HorizontalStackLayout and VerticalStackLayout 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 &amp; 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.

      With Picker Title
      .NET MAUI picker issue

      Without Picker Title
      .NET MAUI picker

    • Image Tools: Delete all images, rotate images left or right.

    • Thumbnails: Display scanned images in a ScrollView control. Each image is displayed in an Image 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

  1. Initialize ScannerController: Set up the ScannerController object, host address of the Dynamsoft Service, and the license key in the MainPage.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.
  2. 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 and ResolutionPicker in the constructor.
    • Async Method: The OnGetDeviceClicked method asynchronously retrieves available scanners and populates the DevicePicker.
    • Scanner Type: The ScannerType enum specifies the types of scanners to retrieve. If not specified, retrieving all available scanners may take longer.
  3. 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 to Stream objects and then to ImageSource objects.
    • Image Display: The Image control is used to display each image and is added to the ImageContainer. A TapGestureRecognizer is added to each Image control to handle the Tapped 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.
  4. 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 an SKBitmap object and then triggers the InvalidateSurface event to redraw the image.
    • OnCanvasViewPaintSurface Method: The OnCanvasViewPaintSurface method is called when the PaintSurface event is triggered. It calculates the aspect ratio of the image and draws the image on the SKCanvasView control.
  5. 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 an SKImage object and then encode it to a SKData object. Finally, save the SKData object to a file stream.

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

.NET MAUI document scanner on Windows

macOS

.NET MAUI document scanner on macOS

Source Code

https://github.com/yushulx/dotnet-twain-wia-sane-scanner/tree/main/examples/MauiAppDocScan

Top comments (0)