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

  1. Install the npm package mobile-web-capture:

     npm install mobile-web-capture
    
  2. 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

  1. Create a new DWT.vue file under src/components with the following template:

    <template>
    </template>
    
    <script lang="ts">
    import { defineComponent} from 'vue';
    
    export default defineComponent({
      name: 'DWT'
    });
    </script>
    
    <style scoped>
    </style>
    
  2. In the template, add a div as the container of the viewer of Dynamic Web TWAIN.

    <template>
      <div id="dwtcontrolContainer"></div>
    </template>
    
  3. 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
      }
    }
    
  4. 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);
      }
    }
    
  5. 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);
    }
    
  6. 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.

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.

Cropper

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.

Document editor

Scanned documents will be displayed in the viewer.

Home

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;
}

Action

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([]);
  }
}

Multiple selection

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.

  1. Run the following to install the PWA plugin for a Vue project:

    vue add pwa
    

    The plugin is based on Workbox’s webpack plugin.

  2. Import the generated registerServiceWorker.ts in main.ts.

    import './registerServiceWorker';
    
  3. By default, the plugin automatically generates the service worker file using the GenerateSW mode. We are going to use the other mode InjectManifest 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 under src. 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);
    
  4. 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.

PWA installation

Turn the App to an Android or iOS App

With Capacitor, we can turn the app into an Android or iOS app.

  1. Add platforms.

    ionic cap add android
    ionic cap add ios
    
  2. Sync files to native projects.

    ionic cap sync
    
  3. Run the app.

    ionic cap run android
    ionic cap run ios
    

Native Quirks

There are some native quirks we have to handle.

  1. 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.

    1. Install the cordova plugins.

      npm install @awesome-cordova-plugins/android-permissions
      npm install cordova-plugin-android-permissions
      
    2. 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>
    
  2. 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);
        }
      }
    }
    
  3. 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:

https://github.com/tony-xlh/Ionic-Vue-Document-Scanner