Building a .NET TWAIN Document Scanner Application for Windows and macOS using MAUI
Tags: dotnet, maui, document, scanner
Published: 2024-07-09 02:45:18

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.

<video src="https://github.com/yushulx/dotnet-twain-wia-sane-scanner/assets/2202306/1046f5f4-2009-4905-95b5-c750195df715" controls="controls" muted="muted" style="max-height:640px; min-height: 200px; max-width: 100%;"></video>

## Prerequisites
1. **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](https://demo.dynamsoft.com/DWT/DWTResources/dist/DynamsoftServiceSetup.msi)
    - **macOS**: [Dynamsoft-Service-Setup.pkg](https://demo.dynamsoft.com/DWT/DWTResources/dist/DynamsoftServiceSetup.pkg)
2. **Request a Free Trial License**: Obtain a [30-day free trial license](https://www.dynamsoft.com/customer/license/trialLicense?product=dwt&source=codepool) for Dynamic Web TWAIN to get started.

3. **Install the NuGet Package**: 
    -  [https://www.nuget.org/packages/Twain.Wia.Sane.Scanner/](https://www.nuget.org/packages/Twain.Wia.Sane.Scanner/). This package wraps the Dynamsoft Service RESTful API, facilitating .NET application development.
    -  [SkiaSharp](https://www.nuget.org/packages/SkiaSharp/) and [SkiaSharp.Views.Maui.Controls](https://www.nuget.org/packages/SkiaSharp.Views.Maui.Controls). These packages are required for rendering images in .NET MAUI applications. Although, .NET MAUI offers a built-in [Image](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/image?view=net-maui-8.0) control and a [GraphicsView](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/graphicsview?view=net-maui-8.0) control for rendering images, they have some known issues with rendering and saving images.

## 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:
    
    ```bash
    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`:

    ```csharp
    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
    <?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
    <?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](https://www.dynamsoft.com/codepool/img/2024/07/dotnet-maui-macos-picker-issue.png)

        **Without Picker Title**
        ![.NET MAUI picker](https://www.dynamsoft.com/codepool/img/2024/07/dotnet-maui-macos-picker.png)
    - **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.

    ```csharp
    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.

    ```csharp
    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.

    ```csharp
    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.

    ```csharp
    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.

    ```csharp
    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](https://www.dynamsoft.com/codepool/img/2024/07/dotnet-maui-twain-document-scanner.png)

**macOS**

![.NET MAUI document scanner on macOS](https://www.dynamsoft.com/codepool/img/2024/07/dotnet-maui-twain-document-scanner-macos.png)

## Source Code
[https://github.com/yushulx/dotnet-twain-wia-sane-scanner/tree/main/examples/MauiAppDocScan](https://github.com/yushulx/dotnet-twain-wia-sane-scanner/tree/main/examples/MauiAppDocScan)