Ionic Document Scanner in React
Dynamic Web TWAIN has a new scanDocument API since v17.2, which makes it easy to capture documents using mobile cameras with its document border detection feature. Along with the RemoteScan feature that connects physical document scanners, we can now build a decent mobile document scanner with web technologies.
In this article, we are going to create a mobile document scanning app using Ionic React. Ionic is a cross-platform framework for developing mobile apps with web technologies. An Ionic app can not only run as a web app in browsers but also run as a native app on Android and iOS.
Screenshot of the final result:
An online demo running on netlify: link.
What You Should Know About Dynamic Web TWAIN
Build an Ionic Document Scanner
Let’s build the app in steps.
New project
First, install Ionic according to its guide.
After installation, create a new project:
ionic start documentScanner tabs --type react
We can run ionic serve
to start a test server.
Install Dynamic Web TWAIN
-
Install the npm package
mobile-web-capture
:npm install mobile-web-capture
-
Download Dynamic Web TWAIN, install it and put its
Resources
folder in the project’spublic
folder.
PS: The version of mobile-web-capture
for this article is 17.2.5.
Create a Home Page and a Settings Page
We need two pages. A home page which displays scanned documents and provides all kinds of operations and a settings page where we can configure RemoteScan.
-
Create
Home.tsx
andSettings.tsx
underpages
with the following template content:import Home from './pages/Home'; import Settings from './pages/Settings'; const Home: React.FC<RouteComponentProps> = (props:RouteComponentProps) => { return ( <IonPage> <IonHeader> <IonToolbar> <IonTitle slot="start">Docs Scan</IonTitle> </IonToolbar> </IonHeader> <IonContent> </IonContent> </IonPage> ); } export default Home;
-
Manage the navigation in
App.tsx
:const App: React.FC = () => ( <IonApp> <IonReactRouter> <IonRouterOutlet> <Route path="/" component={Home} exact={true} /> <Route path="/settings" component={Settings} exact={true} /> </IonRouterOutlet> </IonReactRouter> </IonApp> );
Create a Scanner Component and Add it to the Home Page
We need to create a component to use Dynamic Web TWAIN in React.
Create Scanner.tsx
under components
with the following content:
import { useEffect, useRef } from "react";
import Dynamsoft from 'mobile-web-capture';
import { WebTwain } from "mobile-web-capture/dist/types/WebTwain";
import { ThumbnailViewer } from "mobile-web-capture/dist/types/WebTwain.Viewer";
interface props {
license?:string;
width?: string|number;
height?: string|number;
}
let DWObject:WebTwain | undefined;
let thumbnail:ThumbnailViewer | undefined;
const Scanner: React.FC<props> = (props: props) => {
const containerID = "dwtcontrolContainer";
const container = useRef<HTMLDivElement>(null);
const OnWebTWAINReady = () => {
DWObject = Dynamsoft.DWT.GetWebTwain(containerID);
if (container.current) {
if (props.height) {
DWObject.Viewer.height = props.height;
container.current.style.height = props.height as string;
}
if (props.width) {
DWObject.Viewer.width = props.width;
container.current.style.width = props.width as string;
}
}
let thumbnailViewerSettings = {
location: 'left',
size: '100%',
columns: 2,
rows: 3,
scrollDirection: 'vertical', // 'horizontal'
pageMargin: 10,
background: "rgb(255, 255, 255)",
border: '',
allowKeyboardControl: true,
allowPageDragging: true,
allowResizing: false,
showPageNumber: true,
pageBackground: "transparent",
pageBorder: "1px solid rgb(238, 238, 238)",
hoverBackground: "rgb(239, 246, 253)",
hoverPageBorder: "1px solid rgb(238, 238, 238)",
placeholderBackground: "rgb(251, 236, 136)",
selectedPageBorder: "1px solid rgb(125,162,206)",
selectedPageBackground: "rgb(199, 222, 252)"
};
thumbnail = DWObject.Viewer.createThumbnailViewer(thumbnailViewerSettings);
thumbnail.show();
}
useEffect(() => {
console.log("on mount");
Dynamsoft.DWT.RegisterEvent('OnWebTwainReady', () => {
OnWebTWAINReady();
});
if (props.license) {
Dynamsoft.DWT.ProductKey = props.license;
}
Dynamsoft.DWT.UseLocalService = false;
Dynamsoft.DWT.Containers = [{
WebTwainId: 'dwtObject',
ContainerId: containerID,
Width: '300px',
Height: '400px'
}];
Dynamsoft.DWT.Load();
}, []);
return (
<div ref={container} id={containerID}></div>
);
}
export default Scanner;
In the above code, we load Dynamic Web TWAIN and display a viewer after it is ready to use. Here, ThumbnailViewer is used since it supports long tapping to reorder and multiple selection, which is suitable for mobile devices.
We can now use the component in the Home page. You may need to apply for a license to use Dynamic Web TWAIN.
const Home: React.FC<RouteComponentProps> = (props:RouteComponentProps) => {
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle slot="start">Docs Scan</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
+ <Scanner
+ width={"100%"}
+ height={"100%"}
+ license="your license key"
+ />
</IonContent>
</IonPage>
);
}
export default Home;
Scan Documents with Camera
-
Add a
scan
prop to theScanner
component.interface props { license?:string; width?: string|number; height?: string|number; + scan?: boolean; }
-
When the scan property is set to true, start document scanning with cameras.
useEffect(() => { if (props.scan == true) { if (DWObject) { let cameraContainer = document.createElement("div"); cameraContainer.className = "fullscreen"; document.body.appendChild(cameraContainer); const funcConfirmExit = (bExistImage:boolean):boolean => { cameraContainer.remove(); return true; } let showVideoConfigs:ScanConfiguration = { element: cameraContainer, scannerViewer:{ autoDetect:{ enableAutoDetect: false }, funcConfirmExit: funcConfirmExit, continuousScan:{ visibility: false, enableContinuousScan: false, } }, filterViewer: { exitDocumentScanAfterSave: false } }; DWObject.Addon.Camera.scanDocument(showVideoConfigs).then( function(){ console.log("OK"); }, function(error){ console.log(error.message); }); } } }, [props.scan]);
A full-screen container is appended to body and the camera view will be bound to it. When the camera is closed by the users, the container will be removed.
Scanner.css
understyles
:.fullscreen { position: absolute; left:0; top:0; width: 100%; height: 100%; }
-
Create a floating action button in Home page to start scan and import the CSS.
Added content to
Home.tsx
:import "../styles/Scanner.css"; //... const [scan,setScan] = useState(false); const resetScanStateDelayed = () => { const reset = () => { setScan(false); } setTimeout(reset,1000); } //... return ( <IonPage> <IonHeader> <IonToolbar> <IonTitle slot="start">Docs Scan</IonTitle> </IonToolbar> </IonHeader> <IonContent> <Scanner scan={scan} width={"100%"} height={"100%"} license="your license key" /> <IonFab style= vertical="bottom" horizontal="start" slot="fixed"> <IonFabButton onClick={() => { setScan(true); resetScanStateDelayed(); }} > <IonIcon icon={cameraOutline} /> </IonFabButton> </IonFab> </IonContent> </IonPage> );
We can now test scanning documents with cameras. Please note that camera access needs localhost or https.
Scan Documents with RemoteScan
RemoteScan makes it possible to scan documents from physical document scanners.
We need to have a desktop device which runs Dynamsoft Service and make it accessible via an Intranet. Then, we can scan documents on other devices by communicating with that device. You can learn about how to configure it here.
Let’s add RemoteScan
to the app.
-
Add seven props to the
Scanner
component.interface props { license?:string; width?: string|number; height?: string|number; scan?: boolean; + deviceConfig?: DeviceConfiguration; + remoteScan?: boolean; + remoteIP?: string; + onWebTWAINReady?: (dwt:WebTwain) => void; + onScannerListLoaded?: (list:string[]) => void; + onScanned?: (success:boolean) => void; + onRemoteServiceConnected?: (success:boolean) => void; }
-
After Dynamic Web TWAIN is loaded and the remote IP address is set, create a new
DWObjectRemote
and bind it withDWObject
.const OnWebTWAINReady = () => { DWObject = Dynamsoft.DWT.GetWebTwain(containerID); if (props.onWebTWAINReady) { props.onWebTWAINReady(DWObject); } //... }; const initializeDWObjectRemote = () => { Dynamsoft.DWT.DeleteDWTObject("remoteScan"); DWObjectRemote = undefined; if (props.remoteIP == "") { return; } if (props.remoteIP) { console.log("initializing"); var dwtConfig = { WebTwainId: "remoteScan", Host: props.remoteIP, Port: "18622", PortSSL: "18623", UseLocalService: "true", }; Dynamsoft.DWT.CreateDWTObjectEx( dwtConfig, function (dwt) { DWObjectRemote = dwt; bindDWObjects(); console.log("service connected!"); // List the available scanners DWObjectRemote.GetSourceNamesAsync(false).then( function (devices) { let scanners:string[] = []; for (let i = 0; i < devices.length; i++) { scanners.push(devices[i].toString()); } if (props.onScannerListLoaded){ props.onScannerListLoaded(scanners); } }, function (error){ console.log(error); } ); }, function (error) { console.log(error); } ); } } const bindDWObjects = () => { if (DWObjectRemote && DWObject) { DWObjectRemote.RegisterEvent("OnPostTransferAsync", function (outputInfo) { DWObjectRemote!.ConvertToBlob( [DWObjectRemote!.ImageIDToIndex(outputInfo.imageId)], Dynamsoft.DWT.EnumDWT_ImageType.IT_PNG, function (result, indices, type) { DWObject!.LoadImageFromBinary( result, function () { console.log("LoadImageFromBinary success"); DWObjectRemote!.RemoveImage( DWObjectRemote!.ImageIDToIndex(outputInfo.imageId) ); }, function (errorCode, errorString) { console.log(errorString); } ); }, function (errorCode, errorString) { console.log(errorString); } ); }); } } useEffect(() => { initializeDWObjectRemote(); }, [props.remoteIP]);
DWObjectRemote
communicates with a remote Dynamsoft Service to scan documents. After it fetched an image, it will pass the image toDWObject
and display the image in the viewer. The image stored in the remote Dynamsoft Service will be deleted after the scanning process is completed.When the
remoteScan
prop is set to true, start remote scan:useEffect(() => { if (props.remoteScan == true) { if (DWObjectRemote) { const OnAcquireImageSuccess = function () { if (props.onScanned) { props.onScanned(true); } DWObjectRemote!.CloseSource(); }; const OnAcquireImageFailure = function () { if (props.onScanned) { props.onScanned(false); } DWObjectRemote!.CloseSource(); }; let deviceConfiguration:DeviceConfiguration; if (props.deviceConfig) { deviceConfiguration = props.deviceConfig; }else{ deviceConfiguration = { SelectSourceByIndex: 0, IfShowUI: false, PixelType: Dynamsoft.DWT.EnumDWT_PixelType.TWPT_RGB, Resolution: 300, IfFeederEnabled: false, IfDuplexEnabled: false, IfDisableSourceAfterAcquire: true, RemoteScan: true, ShowRemoteScanUI: false, }; } DWObjectRemote.AcquireImage( deviceConfiguration, OnAcquireImageSuccess, OnAcquireImageFailure ); } else { if (props.onScanned) { props.onScanned(false); } } } }, [props.remoteScan]);
-
In Home page, add the newly added props to the Scanner component:
const [remoteScan,setRemoteScan] = useState(false); const [remoteIP,setRemoteIP] = useState(""); // leave the value empty const [deviceConfiguration, setDeviceConfiguration] = useState<DeviceConfiguration|undefined>(undefined); //...... <Scanner scan={scan} remoteScan={remoteScan} width={"100%"} height={"100%"} license="your license key" remoteIP={remoteIP} deviceConfig={deviceConfiguration} onWebTWAINReady={(dwt) =>{ DWObject = dwt; loadSettings(); }} onScannerListLoaded={onScannerListLoaded} onRemoteServiceConnected={(success) =>{ if (success == false) { localStorage.removeItem("IP"); } }} onScanned={(success) => { if (success == false) { alert("Failed. Please check your settings."); } }} />
After Web TWAIN is ready, load settings.
const loadSettings = () => { const IP = localStorage.getItem("IP"); if (IP) { setRemoteIP(IP); } }
Currently, the only setting is the remote IP. We will add more settings like which scanner to use later.
If the remote service is not available, remove the
remoteIP
item stored in local storage.onRemoteServiceConnected={(success) =>{ if (success == false) { localStorage.removeItem("IP"); } }}
-
In Home page, add a floating action button to start remote scan:
<IonFabButton style= onClick={() => { setRemoteScan(true); resetScanStateDelayed(); }} > <IonIcon icon={documentOutline} /> </IonFabButton>
We can now set a remote IP and have a test:
window.localStorage.setItem("IP","192.168.8.65");
Settings
In the settings page, add options related to remote scan.
The ScanSettings
interface has keys like resolution, pixel type, which scanner to use, whether to show a configuration UI, whether to enable duplex scan and auto document feeder.
export interface ScanSettings{
selectedIndex: number;
showUI: boolean;
autoFeeder: boolean;
duplex: boolean;
resolution: number;
pixelType: number;
}
If the save button is clicked, save the settings and return to the Home page. The scan settings and remote IP are stored in local storage.
const save = () =>{
const selectedIndex = Math.max(0, scanners.indexOf(selectedScanner));
let scanSettings: ScanSettings = {
selectedIndex: selectedIndex,
showUI: showUI,
autoFeeder: autoFeeder,
duplex: duplex,
resolution: resolution,
pixelType: pixelType
}
localStorage.setItem("settings",JSON.stringify(scanSettings));
if (IP) {
localStorage.setItem("IP",IP);
}
props.history.replace({state:{settingsSaved:true}});
props.history.goBack();
};
In the Home page, load the settings if the settings are changed:
useEffect(() => {
const state = props.location.state as { settingsSaved:boolean };
if (state && state.settingsSaved == true) {
loadSettings();
}
}, [props.location.state]);
const loadSettings = () => {
const settingsAsJSON = localStorage.getItem("settings");
if (settingsAsJSON) {
let settings:ScanSettings = JSON.parse(settingsAsJSON);
let deviceConfig:DeviceConfiguration = {
SelectSourceByIndex: settings.selectedIndex,
ShowRemoteScanUI: settings.showUI,
IfShowUI: settings.showUI,
IfFeederEnabled: settings.autoFeeder,
IfDuplexEnabled: settings.duplex,
PixelType: settings.pixelType,
Resolution: settings.resolution,
RemoteScan: true
}
setDeviceConfiguration(deviceConfig);
}
const IP = localStorage.getItem("IP");
if (IP) {
setRemoteIP(IP);
}
}
Edit Scanned Documents
Documents can be edited after scanning like cropping, rotation and deleting.
Show Image Editor
-
Add a
showEditor
prop toScanner
.interface props { + showEditor?: boolean; }
-
Show image editor if
props.showEditor
is true.useEffect(() => { if (props.showEditor == true) { if (DWObject) { let settings:EditorSettings = {}; let editorContainer = document.createElement("div"); editorContainer.className = "fullscreen"; document.body.appendChild(editorContainer); settings.element = editorContainer as HTMLDivElement; settings.width = "100%"; settings.height = "100%"; let imageEditor = DWObject.Viewer.createImageEditor(settings); imageEditor.show(); const onImageEditorUIClosed = () => { editorContainer.remove(); }; DWObject.RegisterEvent('CloseImageEditorUI', onImageEditorUIClosed); } } }, [props.showEditor]);
-
In Home page, add a floating action button to show an action sheet:
<IonFab style= vertical="bottom" horizontal="end" slot="fixed"> <IonFabButton onClick={showImageActionSheet}> <IonIcon icon={ellipsisVerticalOutline} /> </IonFabButton> </IonFab>
-
Users can select which action to do using the sheet:
const showImageActionSheet = () => { const deleteSelected = () => { if (DWObject) { DWObject.RemoveAllSelectedImages(); } } const editSelected = () => { if (DWObject) { setShowEditor(true); } const reset = () => { setShowEditor(false); } setTimeout(reset,1000); } present({ buttons: [{ text: 'Edit selected', handler: editSelected }, { text: 'Delete selected', handler: deleteSelected }, { text: 'Cancel' } ], header: 'Select an action' }) }
The image editor:
Multiple Selection
We can let the thumbnail viewer show checkboxes so that users can select multiple images on mobile devices.
-
Add a
showCheckbox
prop toScanner
.interface props { + showCheckbox?: boolean; }
-
Update the thumbnail viewer’s
showCheckbox
property whenprops.showCheckbox
is changed.useEffect(() => { if (thumbnail && props.showCheckbox != undefined) { thumbnail.showCheckbox = props.showCheckbox; } }, [props.showCheckbox]);
-
Add a toggle selection item to the image editing action sheet.
const [showCheckbox,setShowCheckbox] = useState(false); //... const toggleMultipleSelection = () => { setShowCheckbox(!showCheckbox); } present({ buttons: [{ text: 'Toggle multiple selection', handler: toggleMultipleSelection }, { text: 'Delete selected', handler: deleteSelected }, { text: 'Edit selected', handler: editSelected }, { text: 'Cancel' } ], header: 'Select an action' })
Screenshot when checkboxes are shown:
Export Scanned Documents
After documents are scanned and edited, we can export them.
We can save them as a PDF file and download it or share it.
-
In Home page, add a share button on the toolbar.
<IonButton onClick={showShareActionSheet} color="secondary"> <IonIcon slot="icon-only" icon={shareOutline} /> </IonButton>
-
The
showShareActionSheet
function:const showShareActionSheet = () => { const save = () => { if (DWObject) { const OnSuccess = () => { console.log('successful'); } const OnFailure = () => { console.log('error'); } DWObject.SaveAllAsPDF("Scanned.pdf",OnSuccess,OnFailure); } } const share = () => { console.log("share"); const success = async (result:Blob, indices:number[], type:number) => { let pdf:File = new File([result],"scanned.pdf"); const data:ShareData = {files:[pdf]}; await navigator.share(data); } const failure = (errorCode:number, errorString:string) => { console.log(errorString); } if (DWObject) { DWObject.ConvertToBlob(getImageIndices(),Dynamsoft.DWT.EnumDWT_ImageType.IT_PDF,success,failure) } } const getImageIndices = () => { var indices = []; if (DWObject) { for (var i=0;i<DWObject.HowManyImagesInBuffer;i++){ indices.push(i) } } return indices; } present({ buttons: [{ text: 'Save as PDF', handler:save }, { text: 'Export to PDF and share', handler:share }, { text: 'Cancel' } ], header: 'Select an action' }) }
Load Existing Documents
We can also load previously scanned documents to the current viewer using the LoadImageEx API.
We can add a floating action button to call this API. It can load PDF files as well as images. We can use an action sheet for users to select the desired file format.
-
Add the floating action button in JSX:
<IonFabButton onClick={loadFile} > <IonIcon icon={imageOutline} /> </IonFabButton>
-
The
loadFile
function:const loadFile = () => { if (DWObject) { present({ buttons: [{ text: 'PDF', handler: () => { DWObject.LoadImageEx("", Dynamsoft.DWT.EnumDWT_ImageType.IT_PDF); } }, { text: 'Image', handler: () => { DWObject.LoadImageEx("", Dynamsoft.DWT.EnumDWT_ImageType.IT_ALL); }}, { text: 'Cancel' } ], header: 'Select file type' }) } }
All right, we’ve finished building the ionic document scanner.
Turn the Document Scanner to Android/iOS Native Apps
It is also possible to make the scanner run as a native mobile app using Ionic.
Setup Android and iOS Platforms
-
Add Android and iOS projects:
ionic capacitor add android ionic capacitor add ios
-
Add permissions in project files:
For Android, add the following to
android\app\src\mainAndroidManifest.xml
:<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
For iOS, add the following to
ios\App\App\Info.plist
:<key>NSCameraUsageDescription</key> <string>For document scanning</string>
-
Request camera permission for Android when the app is started:
useEffect(() => { console.log("on mount"); if (isPlatform("android")) { checkAndRequestCameraPermission(); } }, []); const checkAndRequestCameraPermission = async () => { let result = await AndroidPermissions.checkPermission(AndroidPermissions.PERMISSION.CAMERA); if (result.hasPermission == false) { let response = await AndroidPermissions.requestPermission(AndroidPermissions.PERMISSION.CAMERA); console.log(response.hasPermission); } }
-
Set
Resources
path for iOS.Web TWAIN uses WebAssembly for all kinds of operations. WebAssembly needs
HTTP
protocol while Capacitor for iOS uses thecapacitor://
protocol. We need to set theResources
path to useHTTP
.In
Scanner.tsx
, add the following:+ const RemoteResourcesPath = "https://unpkg.com/dwt@17.2.5/dist"; + if (isPlatform("ios") == true) { + Dynamsoft.DWT.ResourcesPath = RemoteResourcesPath; + } Dynamsoft.DWT.Load();
-
Set scheme and cleartext so that RemoteScan works on Android.
{ "appId": "io.ionic.starter", "appName": "document-scanner", "webDir": "build", "server":{ "hostname": "localhost", + "androidScheme": "http", + "cleartext": true }, "bundledWebRuntime": false }
On Android, all cleartext traffic is disabled by default as of API 28. Because the remote service is accessible via http://ip:18622 or https://ip:18623 and the latter cannot use a valid certificate, we need to use unencrypted HTTP traffic.
-
Update
styles\Scanner.css
to avoid blocking the status bar:.fullscreen { position: absolute; left:0; top:0; + top: env(safe-area-inset-top); width: 100%; height: 100%; + height: calc(100% - env(safe-area-inset-top)); }
-
Run with Capacitor:
ionic capacitor run android ionic capacitor run ios
Use Native Plugins for Export
Saving and sharing are not handled correctly in WebView. We need to use native plugins to do these.
For saving as a PDF file, we convert the documents to a PDF file and save it to the external directory.
if (Capacitor.isNativePlatform()) {
const OnSuccess = async (result:Base64Result, indices:number[], type:number) => {
console.log('successful');
let writingResult = await Filesystem.writeFile({
path: getFormattedDate()+".pdf",
data: result.getData(0,result.getLength()),
directory: Directory.External
})
await Toast.show({
text: "File is written to "+writingResult.uri,
duration: "long"
});
}
const OnFailure = () => {
console.log('error');
}
DWObject.ConvertToBase64(getImageIndices(),Dynamsoft.DWT.EnumDWT_ImageType.IT_PDF,OnSuccess,OnFailure)
}
For sharing, we convert the documents to a PDF file, save it to the cache directory and then share it:
if (Capacitor.isNativePlatform()) {
const success = async (result:Base64Result, indices:number[], type:number) => {
let fileName = getFormattedDate()+".pdf";
let writingResult = await Filesystem.writeFile({
path: fileName,
data: result.getData(0,result.getLength()),
directory: Directory.Cache
});
Share.share({
title: fileName,
text: fileName,
url: writingResult.uri,
});
}
const failure = (errorCode:number, errorString:string) => {
console.log(errorString);
}
if (DWObject) {
DWObject.ConvertToBase64(getImageIndices(),Dynamsoft.DWT.EnumDWT_ImageType.IT_PDF,success,failure)
}
}