In today's fast-paced digital environment, efficient document management is vital across industries such as finance, healthcare, legal, and education. Traditional paper handling methods are not only slow but also susceptible to errors and inefficiencies. Document PDF scanner apps offer a modern solution by streamlining workflows and significantly boosting productivity. This tutorial will show you how to quickly build a cross-platform Document PDF Scanner app for Windows, Android, and iOS using .NET MAUI Blazor and Dynamsoft Mobile Web Capture SDK.
.NET MAUI Blazor Document PDF Scanner
Prerequisites
Mobile Web Capture Online Demo
Experience the Dynamsoft Mobile Web Capture SDK in action by trying the online demo. It works seamlessly across desktop and mobile browsers.
Try the Demo
Getting Started with Mobile Web Capture Sample Code
Explore the Mobile Web Capture GitHub Repository for a hands-on introduction to the Dynamsoft Mobile Web Capture SDK. The repository features five sample projects designed to help you quickly grasp the SDK’s capabilities:
These samples are deployed on GitHub Pages for easy access:
Sample Name | Description | Live Demo |
---|---|---|
hello-world | Capture images directly from the camera. | Run |
detect-boundaries-on-existing-images | Detect, crop, and rectify documents in an image file. | Run |
review-adjust-detected-boundaries | Detect, crop and rectify a document captured from a camera stream. | Run |
capture-continuously-edit-result-images | Capture and edit multiple documents from a camera stream. | Run |
relatively-complete-doc-capturing-workflow | A comprehensive workflow demonstrating all supported functionalities. | Run |
In the following sections, we will integrate the relatively-complete-doc-capturing-workflow
sample code into a .NET MAUI Blazor project, demonstrating how to harness the power of this SDK in a cross-platform environment.
Setting Up a .NET MAUI Blazor Project with the Mobile Web Capture SDK
Scaffold a New Project
Start by creating a new project using the .NET MAUI Blazor Hybrid App template.
Add HTML, CSS, and JavaScript Files
Next, copy the source code and static resource files from the relatively-complete-doc-capturing-workflow sample into your project's wwwroot
folder.
The sample index.html
references JavaScript and CSS files hosted on a CDN:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dynamsoft-document-viewer@1.1.0/dist/ddv.css">
<script src="https://cdn.jsdelivr.net/npm/dynamsoft-core@3.0.30/dist/core.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dynamsoft-license@3.0.20/dist/license.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dynamsoft-document-normalizer@2.0.20/dist/ddn.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dynamsoft-capture-vision-router@2.0.30/dist/cvr.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dynamsoft-document-viewer@1.1.0/dist/ddv.js"></script>
To improve load times, download these files and reference them locally. Place the CSS files in the wwwroot/css
folder and the JavaScript files in the wwwroot/js
folder.
Update the index.html
file in your .NET MAUI Blazor project to reference these local files:
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>ScanToPDF</title>
<base href="/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="stylesheet" href="css/ddv.css">
<link rel="stylesheet" href="ScanToPDF.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<script src="js/dynamsoft-core/dist/core.js"></script>
<script src="js/dynamsoft-license/dist/license.js"></script>
<script src="js/dynamsoft-document-normalizer/dist/ddn.js"></script>
<script src="js/dynamsoft-capture-vision-router/dist/cvr.js"></script>
<script src="js/dynamsoft-document-viewer/dist/ddv.js"></script>
</head>
Create an interop.js File
For interoperation between C# and JavaScript, create an interop.js
file and include it in the index.html
file.
<script src="_framework/blazor.webview.js" autostart="false"></script>
<script type="module" src="interop.js"></script>
Move the JavaScript code from the sample index.html
into this interop.js
file.
var isInitialized = false;
import { isMobile, initDocDetectModule } from "./utils.js";
import {
mobileCaptureViewerUiConfig,
mobilePerspectiveUiConfig,
mobileEditViewerUiConfig,
pcCaptureViewerUiConfig,
pcPerspectiveUiConfig,
pcEditViewerUiConfig
} from "./uiConfig.js";
window.initSDK = async function (license) {
if (isInitialized) return true;
let result = true;
try {
Dynamsoft.DDV.Core.engineResourcePath = "/js/dynamsoft-document-viewer/dist/engine";
Dynamsoft.Core.CoreModule.loadWasm(["DDN"]);
Dynamsoft.DDV.Core.loadWasm();
await Dynamsoft.License.LicenseManager.initLicense(license, true);
isInitialized = true;
} catch (e) {
console.log(e);
result = false;
}
return result;
},
window.initializeCaptureViewer = async (dotnetRef) => {
try {
await Dynamsoft.DDV.Core.init();
await initDocDetectModule(Dynamsoft.DDV, Dynamsoft.CVR);
const captureViewer = new Dynamsoft.DDV.CaptureViewer({
container: "container",
uiConfig: isMobile() ? mobileCaptureViewerUiConfig : pcCaptureViewerUiConfig,
viewerConfig: {
acceptedPolygonConfidence: 60,
enableAutoDetect: true,
}
});
await captureViewer.play({ resolution: [1920, 1080] });
captureViewer.on("showPerspectiveViewer", () => switchViewer(0, 1, 0));
const perspectiveViewer = new Dynamsoft.DDV.PerspectiveViewer({
container: "container",
groupUid: captureViewer.groupUid,
uiConfig: isMobile() ? mobilePerspectiveUiConfig : pcPerspectiveUiConfig,
viewerConfig: { scrollToLatest: true }
});
perspectiveViewer.hide();
perspectiveViewer.on("backToCaptureViewer", () => {
switchViewer(1, 0, 0);
captureViewer.play();
});
perspectiveViewer.on("showEditViewer", () => switchViewer(0, 0, 1));
const editViewer = new Dynamsoft.DDV.EditViewer({
container: "container",
groupUid: captureViewer.groupUid,
uiConfig: isMobile() ? mobileEditViewerUiConfig : pcEditViewerUiConfig
});
editViewer.hide();
editViewer.on("backToPerspectiveViewer", () => switchViewer(0, 1, 0));
editViewer.on("save", async () => {
let blob = await editViewer.currentDocument.saveToPdf();
// convert blob to base64
let reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = function () {
let base64data = reader.result;
if (dotnetRef) {
dotnetRef.invokeMethodAsync('SavePdfFromBlob', base64data.split(',')[1])
}
}
});
const switchViewer = (c, p, e) => {
captureViewer.hide();
perspectiveViewer.hide();
editViewer.hide();
if (c) captureViewer.show();
else captureViewer.stop();
if (p) perspectiveViewer.show();
if (e) editViewer.show();
};
}
catch (e) {
alert(e);
}
};
window.displayAlert = function(message) {
alert(message);
}
Explanation
- Functions assigned to
window
are callable from C#. -
Dynamsoft.DDV.Core.engineResourcePath
sets the path to engine resources. -
The
save
button click event ineditViewer
triggers the save process, converting the document blob to a base64 string for use by the C# methodSavePdfFromBlob
. The event is defined in theuiConfig.js
file.
Dynamsoft.DDV.Elements.Load, { type: Dynamsoft.DDV.Elements.Button, className: "ddv-button ddv-button-download", events:{ click: "save", } },
Call
saveToPdf()
to get the document blob and convert it to a base64 string.
let blob = await editViewer.currentDocument.saveToPdf(); let reader = new FileReader(); reader.readAsDataURL(blob); reader.onloadend = function () { let base64data = reader.result; }
-
dotnetRef
is a reference to the C# code, allowing JavaScript to invoke C# methods. JavaScript code cannot access local files directly.
if (dotnetRef) { dotnetRef.invokeMethodAsync('SavePdfFromBlob', base64data.split(',')[1]) }
Add a Razor Component
Create a ScanToPdf.razor
file in the Pages
folder to contain the HTML and C# code for the document PDF scanner:
@page "/scantopdf"
@inject IJSRuntime JSRuntime
<div id="container"></div>
@code {
private DotNetObjectReference<ScanToPdf> objRef;
private bool isGranted = false;
protected override void OnInitialized()
{
objRef = DotNetObjectReference.Create(this);
}
}
Camera Access in .NET MAUI Blazor
Since a .NET MAUI Blazor app runs within a WebView and is packaged as a native app, it’s necessary to request camera access through native code. In the ScanToPdf.razor
file, you can use the following code to request camera access permission:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var status = await Permissions.CheckStatusAsync<Permissions.Camera>();
if (status == PermissionStatus.Granted)
{
isGranted = true;
}
else
{
status = await Permissions.RequestAsync<Permissions.Camera>();
if (status == PermissionStatus.Granted)
{
isGranted = true;
}
}
if (isGranted)
{
StateHasChanged();
await JSRuntime.InvokeVoidAsync("initializeCaptureViewer", objRef);
}
}
}
This shared code initiates the camera permission request but requires additional platform-specific implementations for Android and iOS to function correctly.
Android
-
Update AndroidManifest.xml: Add the camera permission to your
AndroidManifest.xml
file:
<uses-permission android:name="android.permission.CAMERA" />
-
Create MyWebChromeClient.cs: In the
Platforms/Android
folder, create aMyWebChromeClient.cs
file extendingWebChromeClient
to handle camera permission requests by overriding theOnPermissionRequest
method.
using Android.Content; using Android.Webkit; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using static Android.Webkit.WebChromeClient; namespace ScanToPDF.Platforms.Android { public class MyWebChromeClient : WebChromeClient { private MainActivity _activity; public MyWebChromeClient(Context context) { _activity = context as MainActivity; } public override void OnPermissionRequest(PermissionRequest request) { try { request.Grant(request.GetResources()); base.OnPermissionRequest(request); } catch (Exception ex) { Console.WriteLine(ex); } } public override bool OnShowFileChooser(global::Android.Webkit.WebView webView, IValueCallback filePathCallback, FileChooserParams fileChooserParams) { base.OnShowFileChooser(webView, filePathCallback, fileChooserParams); return _activity.ChooseFile(filePathCallback, fileChooserParams.CreateIntent(), fileChooserParams.Title); } } }
The
OnShowFileChooser
method is responsible for handling file selection events triggered by the HTML input element within the WebView. To fully implement this functionality, you need to add the following code to theMainActivity.cs
file.
protected override void OnActivityResult(int requestCode, Result resultCode, Intent data) { if (_requestCode == requestCode) { if (_filePathCallback == null) return; Java.Lang.Object result = FileChooserParams.ParseResult((int)resultCode, data); _filePathCallback.OnReceiveValue(result); } } public bool ChooseFile(IValueCallback filePathCallback, Intent intent, string title) { _filePathCallback = filePathCallback; StartActivityForResult(Intent.CreateChooser(intent, title), _requestCode); return true; }
-
Create MauiBlazorWebViewHandler.cs: Implement a custom WebView handler for Android:
public class MauiBlazorWebViewHandler : BlazorWebViewHandler { protected override global::Android.Webkit.WebView CreatePlatformView() { var view = base.CreatePlatformView(); view.SetWebChromeClient(new MyWebChromeClient(this.Context)); return view; } }
-
Configure the WebView Handler: In
MauiProgram.cs
, configure the custom WebView handler for Android:
using Microsoft.AspNetCore.Components.WebView.Maui; using Microsoft.Extensions.Logging; #if ANDROID using ScanToPDF.Platforms.Android; #endif namespace ScanToPDF { public static class MauiProgram { public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); builder .UseMauiApp<App>() .ConfigureFonts(fonts => { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); }).ConfigureMauiHandlers(handlers => { #if ANDROID handlers.AddHandler<BlazorWebView, MauiBlazorWebViewHandler>(); #endif }); builder.Services.AddMauiBlazorWebView(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); builder.Logging.AddDebug(); #endif return builder.Build(); } } }
iOS
-
Update Info.plist: Add camera and microphone usage descriptions to your
Info.plist
:
<key>NSCameraUsageDescription</key> <string>This app is using the camera</string> <key>NSMicrophoneUsageDescription</key> <string>This app needs access to microphone for taking videos.</string>
-
Configure the WebView: In
MainPage.xaml.cs
, register theBlazorWebViewInitializing
event to configure the WebView on iOS:
using Microsoft.AspNetCore.Components.WebView; namespace ScanToPDF { public partial class MainPage : ContentPage { public MainPage() { InitializeComponent(); blazorWebView.BlazorWebViewInitializing += WebView_BlazorWebViewInitializing; } private void WebView_BlazorWebViewInitializing(object sender, BlazorWebViewInitializingEventArgs e) { #if IOS || MACCATALYST e.Configuration.AllowsInlineMediaPlayback = true; e.Configuration.MediaTypesRequiringUserActionForPlayback = WebKit.WKAudiovisualMediaTypes.None; #endif } } }
Saving Scanned Documents as PDFs to Local Storage
To save scanned documents as PDFs, create the SavePdfFromBlob
method in the ScanToPdf.razor
file. This method converts the base64 string from JavaScript into a PDF file and saves it locally:
[JSInvokable("SavePdfFromBlob")]
public async void SavePdfFromBlob(string base64String)
{
if (!string.IsNullOrEmpty(base64String))
{
byte[] imageBytes = Convert.FromBase64String(base64String);
#if WINDOWS
string folderPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
#elif ANDROID
string folderPath = FileSystem.AppDataDirectory;
#elif IOS || MACCATALYST
string folderPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));
#else
throw new PlatformNotSupportedException("Platform not supported");
#endif
string filePath = Path.Combine(folderPath, GenerateFilename());
try
{
await File.WriteAllBytesAsync(filePath, imageBytes);
await JSRuntime.InvokeVoidAsync("displayAlert", $"Image saved to {filePath}");
await Share.RequestAsync(new ShareFileRequest
{
Title = "Share PDF File",
File = new ShareFile(filePath)
});
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("displayAlert", $"Error saving image: {ex.Message}");
}
}
else
{
Console.WriteLine("Failed to fetch the image.");
}
}
private static string GenerateFilename()
{
DateTime now = DateTime.Now;
string timestamp = now.ToString("yyyyMMdd_HHmmss");
return $"{timestamp}.pdf";
}
This method determines the correct save directory based on the platform:
- Android:
FileSystem.AppDataDirectory
- iOS:
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
- Windows:
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
Running the Document PDF Scanner App on Windows, Android, and iOS
Windows
Android
iPadOS
Source Code
https://github.com/yushulx/web-twain-document-scan-management/tree/main/examples/camera_scan_pdf