How to Build a PWA Document Scanner with Ionic Vue
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 and editing feature along with ready-to-use UIs.
In this article, we are going to create a mobile document scanning app using Ionic Vue. Ionic is a cross-platform framework for developing mobile apps with web technologies. An Ionic app can not only run as a progressive web app (PWA) in browsers but also run as a native app on Android and iOS.
An online demo running on netlify: link.
Build an Ionic Vue 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 blank --type vue
We can then run npm run serve
to have a test.
Install Dynamic Web TWAIN
-
Install the npm package
mobile-web-capture
:npm install mobile-web-capture
-
We also need to copy the resources of Dynamic Web TWAIN to the public folder.
Here, we use
ncp
to do this.npm install ncp --save-dev
Then modify
package.json
as the following:- "serve": "vue-cli-service serve", - "build": "vue-cli-service build", + "serve": "ncp node_modules/mobile-web-capture/dist public/assets/dwt-resources && vue-cli-service serve", + "build": "ncp node_modules/mobile-web-capture/dist public/assets/dwt-resources && vue-cli-service build",
Create a Component for Dynamic Web TWAIN
-
Create a new
DWT.vue
file undersrc/components
with the following template:<template> </template> <script lang="ts"> import { defineComponent} from 'vue'; export default defineComponent({ name: 'DWT' }); </script> <style scoped> </style>
-
In the template, add a
div
as the container of the viewer of Dynamic Web TWAIN.<template> <div id="dwtcontrolContainer"></div> </template>
-
When the component is mounted, load Dynamic Web TWAIN and register the
OnWebTwainReady
event. When Web TWAIN is ready, a document viewer will appear in the container.setup(props, ctx){ const containerID = "dwtcontrolContainer"; const OnWebTWAINReady = () => { } onMounted(async () => { console.log("on mounted"); Dynamsoft.DWT.RegisterEvent('OnWebTwainReady', () => { OnWebTWAINReady(); }); Dynamsoft.DWT.UseLocalService = false; //Dynamsoft.DWT.ProductKey = "<your license>" Dynamsoft.DWT.ResourcesPath = "assets/dwt-resources"; Dynamsoft.DWT.Containers = [{ WebTwainId: 'dwtObject', ContainerId: containerID, Width: '300px', Height: '400px' }]; Dynamsoft.DWT.Load(); }); // expose to template and other options API hooks return { container } }
-
Get the Web TWAIN object for further actions and emit it to the component’s parent.
emits: ['onWebTWAINReady'], setup(props, ctx){ const containerID = "dwtcontrolContainer"; const DWObject = ref(); const OnWebTWAINReady = () => { DWObject.value = Dynamsoft.DWT.GetWebTwain(containerID); ctx.emit("onWebTWAINReady",DWObject.value); } }
-
Use thumbnail viewer by default. The thumbnail viewer supports dragging and multiple selections. It is better for mobile devices than the default viewer. We can emit it along with the Web TWAIN object.
const thumbnail = ref(); const OnWebTWAINReady = () => { //... 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.value = DWObject.value.Viewer.createThumbnailViewer(thumbnailViewerSettings); thumbnail.value.show(); ctx.emit("onWebTWAINReady",DWObject.value,thumbnail.value); }
-
Add props to adjust the viewer’s size.
<template> <div id="dwtcontrolContainer" ref="container"></div> </template> <script lang="ts"> export default defineComponent({ props: ['width','height'], setup(props, ctx){ const container = ref(null); const OnWebTWAINReady = () => { if (container.value) { let el = container.value as HTMLElement; if (props.height) { DWObject.value.Viewer.height = props.height; el.style.height = props.height; } if (props.width) { DWObject.value.Viewer.width = props.width; el.style.width = props.width; } } } } }); </script>
All right, the component is ready. We can use it in the HomePage.vue
like the following.
<template>
<ion-page>
<ion-header :translucent="true">
<ion-toolbar>
<ion-title>Document Scanner</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<DWT width="100%" height="100%" @onWebTWAINReady="onWebTWAINReady"></DWT>
</ion-content>
</ion-page>
</template>
Implement the Home Page
Let’s add functions to the home page.
Scan Documents with the Camera
Add a floating action button to start document scanning with the scanDocument function.
Template:
<ion-content :fullscreen="true">
<ion-fab vertical="bottom" horizontal="start" slot="fixed">
<ion-fab-button @click="startScan">
<ion-icon name="camera-outline"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>
JavaScript:
import { IonFab, IonFabButton, IonIcon } from '@ionic/vue';
import { cameraOutline } from 'ionicons/icons';
import { addIcons } from 'ionicons';
export default defineComponent({
components: {
IonFab,
IonFabButton,
IonIcon,
DWT
},
setup() {
let DWObject: WebTwain|null;
let thumbnailViewer: ThumbnailViewer|null;
const onWebTWAINReady = (dwt:WebTwain,viewer:ThumbnailViewer) => {
DWObject = dwt;
thumbnailViewer = viewer;
};
addIcons({
'camera-outline': cameraOutline,
});
const startScan = () => {
if (DWObject) {
DWObject.Addon.Camera.scanDocument();
}
};
return {
onWebTWAINReady,
startScan
}
}
});
This will start a built-in document scanner.
It can automatically detect the border of documents, take a photo and run perspective transformation to get a normalized image.
After a photo is taken, the user is directed to a cropping page.
After the confirmation of the detected polygon, the user is directed to a document editor where the user can save, rotate, recrop or delete images and apply filters.
Scanned documents will be displayed in the viewer.
Saving and Editing
Add a button to the toolbar to show an action sheet. Users can choose to save the images as a PDF file or edit the scanned documents.
Template:
<ion-header :translucent="true">
<ion-toolbar>
<ion-title>Document Scanner</ion-title>
<ion-buttons slot="primary">
<ion-button @click="presentActionSheet">
<ion-icon ios="ellipsis-horizontal" md="ellipsis-vertical"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
JavaScript:
const presentActionSheet = async () => {
const actionSheet = await actionSheetController.create({
header: 'Action',
buttons: [
{
text: 'Save',
handler: save
},
{
text: 'Edit',
handler: showDocumentEditor
},
{
text: 'Cancel',
},
],
});
await actionSheet.present();
};
const save = () => {
if (DWObject) {
let filename = getFormattedDate()+".pdf";
const OnSuccess = () => {
console.log('successful');
}
const OnFailure = () => {
console.log('error');
}
DWObject.SaveAllAsPDF(getFormattedDate()+".pdf",OnSuccess,OnFailure);
}
}
const showDocumentEditor = () => {
if (DWObject) {
let documentEditor = DWObject.Viewer.createDocumentEditor();
documentEditor.show();
}
}
const getFormattedDate = () => {
let date = new Date();
let month = date.getMonth() + 1;
let day = date.getDate();
let hour = date.getHours();
let min = date.getMinutes();
let sec = date.getSeconds();
let monthStr = (month < 10 ? "0" : "") + month;
let dayStr = (day < 10 ? "0" : "") + day;
let hourStr = (hour < 10 ? "0" : "") + hour;
let minStr = (min < 10 ? "0" : "") + min;
let secStr = (sec < 10 ? "0" : "") + sec;
let str = date.getFullYear().toString() + monthStr + dayStr + hourStr + minStr + secStr;
return str;
}
const getImageIndices = () => {
let indices = [];
if (DWObject) {
for (let i=0;i<DWObject.HowManyImagesInBuffer;i++){
indices.push(i)
}
}
return indices;
}
Multiple Selection
Add a button to the toolbar to toggle the multiple selection mode.
If it is enabled, a toolbar will appear in the footer as well to perform actions like select/deselect all and remove selected.
Template:
<template>
<ion-page>
<ion-header :translucent="true">
<ion-toolbar>
<ion-title>Document Scanner</ion-title>
<ion-buttons slot="secondary">
<ion-button @click="toggleMultipleSelectionMode">
<ion-icon v-if="multipleSelectionMode" name="checkbox"></ion-icon>
<ion-icon v-else name="checkbox-outline"></ion-icon>
</ion-button>
</ion-buttons>
<ion-buttons slot="primary">
<ion-button @click="presentActionSheet">
<ion-icon ios="ellipsis-horizontal" md="ellipsis-vertical"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
</ion-content>
<ion-footer v-if="multipleSelectionMode">
<ion-toolbar>
<ion-buttons>
<ion-button @click="selectAll">
Select All
</ion-button>
<ion-button @click="deselectAll">
Deselct All
</ion-button>
<ion-button @click="removeSelected">
Remove Selected
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
</ion-page>
</template>
JavaScript:
const multipleSelectionMode = ref(false);
const toggleMultipleSelectionMode = () => {
multipleSelectionMode.value = !multipleSelectionMode.value;
if (thumbnailViewer) {
thumbnailViewer.showCheckbox = multipleSelectionMode.value;
}
};
const removeSelected = () => {
if (DWObject) {
DWObject.RemoveAllSelectedImages();
}
}
const selectAll = () => {
if (DWObject) {
DWObject.SelectAllImages();
}
}
const deselectAll = () => {
if (DWObject) {
DWObject.SelectImages([]);
}
}
All right, the home page is now implemented.
Add Progressive Web App Support
We can add Progressive Web App (PWA) support for the app so that we can install it like an app and use it offline.
We need to add a service worker and a Web manifest. While it’s possible to add both of these to an app manually, we can use the Vue CLI to conveniently add them.
-
Run the following to install the PWA plugin for a Vue project:
vue add pwa
The plugin is based on Workbox’s webpack plugin.
-
Import the generated
registerServiceWorker.ts
inmain.ts
.import './registerServiceWorker';
-
By default, the plugin automatically generates the service worker file using the
GenerateSW
mode. We are going to use the other modeInjectManifest
so that the generation starts with an existing service worker file which we can customize.To do this, add the following to
package.json
."vue": { "pwa": { "workboxPluginMode": "InjectManifest", "workboxOptions": { "swSrc": "./src/service-worker.js" } } },
Then, create
service-worker.js
undersrc
. In the service worker, we register routes to cache Web TWAIN’s resources based on the URL.import { clientsClaim } from "workbox-core"; import { precacheAndRoute } from "workbox-precaching"; import { registerRoute } from "workbox-routing"; import { StaleWhileRevalidate } from "workbox-strategies"; clientsClaim(); self.skipWaiting(); registerRoute( ({ url }) => url.href.indexOf("dwt-resources") != -1, new StaleWhileRevalidate({ cacheName: "dwt-resources", }) ); precacheAndRoute(self.__WB_MANIFEST);
-
Set
maximumFileSizeToCacheInBytes
to cache large files. Here, we set the size to 10MB."vue": { "pwa": { "workboxPluginMode": "InjectManifest", "workboxOptions": { "swSrc": "./src/service-worker.js" "maximumFileSizeToCacheInBytes": 1073741824 } } },
A PWA app should run under localhost or HTTPS. When it is enabled, we can install it like a native app for offline use.
Turn the App to an Android or iOS App
With Capacitor, we can turn the app into an Android or iOS app.
-
Add platforms.
ionic cap add android ionic cap add ios
-
Sync files to native projects.
ionic cap sync
-
Run the app.
ionic cap run android ionic cap run ios
Native Quirks
There are some native quirks we have to handle.
-
Camera permission.
For Android, add the following to
AndroidManifest.xml
.<uses-permission android:name="android.permission.CAMERA" />
We also have to request the camera permission with code.
-
Install the cordova plugins.
npm install @awesome-cordova-plugins/android-permissions npm install cordova-plugin-android-permissions
-
Request the camera permission in the home page.
onMounted(()=>{ if (Capacitor.getPlatform()==="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); } }
For iOS, add the following to
Info.plist
.<key>NSCameraUsageDescription</key> <string>For document scanning</string>
-
-
Saving files.
We cannot download a file like in a browser in a native app. We can use the Capacitor sharing plugin to share the PDF file.
Install the plugins:
npm install @capacitor/filesystem @capacitor/share
Then, use them in the
save
function.const save = () => { if (DWObject) { let filename = getFormattedDate()+".pdf"; if (Capacitor.isNativePlatform()) { const OnSuccess = (result:Base64Result, indices:number[], type:number) => { console.log('successful'); const share = async () => { 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, }); } share(); } const OnFailure = () => { console.log('error'); } DWObject.ConvertToBase64(getImageIndices(),Dynamsoft.DWT.EnumDWT_ImageType.IT_PDF,OnSuccess,OnFailure); }else{ const OnSuccess = () => { console.log('successful'); } const OnFailure = () => { console.log('error'); } DWObject.SaveAllAsPDF(getFormattedDate()+".pdf",OnSuccess,OnFailure); } } }
-
Safe area.
The fullscreen scanner and document editor may be blocked by the status bar. We can set their top position to
env(safe-area-inset-top)
.To do this, we have to bind the scanner and the document editor to a fullscreen container.
const startScan = () => { if (DWObject) { let container = document.createElement("div"); container.className = "fullscreen"; document.body.appendChild(container); const funcConfirmExit = (bExistImage:boolean):Promise<boolean> => { container.remove(); return Promise.resolve(true); } const funcConfirmExitAfterSave = () => { container.remove(); }; let showVideoConfigs:DocumentConfiguration = { scannerViewer:{ element: container, continuousScan: false, funcConfirmExit: funcConfirmExit, }, documentEditorSettings:{ element:container, funcConfirmExitAfterSave:funcConfirmExitAfterSave } }; DWObject.Addon.Camera.scanDocument(showVideoConfigs); } }; const showDocumentEditor = () => { if (DWObject) { let container = document.createElement("div"); container.className = "fullscreen"; document.body.appendChild(container); const funcConfirmExitAfterSave = () => { container.remove(); }; const funcConfirmExit = (bChanged: boolean, previousViewerName: string):Promise<number | DynamsoftEnumsDWT.EnumDWT_ConfirmExitType> => { container.remove(); return Promise.resolve(Dynamsoft.DWT.EnumDWT_ConfirmExitType.Exit); }; let config:DocumentConfiguration = { documentEditorSettings:{ element:container, funcConfirmExit:funcConfirmExit, funcConfirmExitAfterSave:funcConfirmExitAfterSave } }; let documentEditor = DWObject.Viewer.createDocumentEditor(config); documentEditor.show(); } }
CSS (imported in
main.ts
):.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)); }
Source Code
Check out the demo’s source code to have a try: