How to Build a Document Scanning Desktop App with Quasar and Electron

Quasar is a Vue.js-based framework, which allows web developers to quickly create responsive websites/apps. It can use Electron to build multi-platform desktop apps.

In this article, we are going to build a document scanning desktop app with Quasar and Electron. Dynamic Web TWAIN is used to provide the ability to interact with document scanners.

The final app looks like this:

Document Scanner

Build a Document Scanning App with Quasar

Let’s do this in steps.

New Project

Install quasar cli:

npm install -g @quasar/cli

Then, use it to create a new project:

npm init quasar

Here, we create a new project with TypeScript + Vite using composition API.

√ What would you like to build? » App with Quasar CLI, let's go!
√ Project folder: ... document-scanner
√ Pick Quasar version: » Quasar v2 (Vue 3 | latest and greatest)
√ Pick script type: » Typescript
√ Pick Quasar App CLI variant: » Quasar App CLI with Vite
√ Package name: ... document-scanner
√ Project product name: (must start with letter if building mobile apps) ... Document Scanner
√ Project description: ... A desktop document scanner
√ Author: ... Lihang Xu
√ Pick a Vue component style: » Composition API with <script setup>
√ Pick your CSS preprocessor: » Sass with SCSS syntax
√ Check the features needed for your project: » ESLint
√ Pick an ESLint preset: » Prettier

Install Dynamic Web TWAIN

  1. Install Dynamic Web TWAIN from npm:

    npm install dwt
    
  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 to add the following scripts:

    "build": "ncp node_modules/dwt/dist public/assets/dwt-resources && quasar build",
    "start": "ncp node_modules/dwt/dist public/assets/dwt-resources && quasar dev",
    

Modify the Default Files

  1. Remove the example components under src/components.
  2. Open src/layouts/MainLayout.vue and simplify its template with the following content:

    <template>
      <q-layout view="lHh Lpr lFf">
        <q-header elevated >
          <q-toolbar>
            <q-toolbar-title>
              Document Scanner
            </q-toolbar-title>
          </q-toolbar>
        </q-header>
        <q-page-container>
          <router-view />
        </q-page-container>
      </q-layout>
    </template>
    

Create a Document Viewer Component

Dynamic Web TWAIN provides a document viewer control and a bunch of APIs to scan and manage documents. We are going to wrap the viewer as a Vue component and expose the object of Dynamic Web TWAIN to call different APIs.

First, create a new component named DWT.vue.

In the template, add a div as the container of the viewer of Dynamic Web TWAIN.

<template>
  <div ref='viewer' 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. The Web TWAIN object for further actions is emitted using the onWebTWAINReady event.

<script setup lang='ts'>
import { onMounted, ref, watch } from 'vue';
import Dynamsoft from 'dwt';
import { WebTwain } from 'dwt/dist/types/WebTwain';

const emit = defineEmits(['onWebTWAINReady']);
const viewer = ref(null);
const ContainerId = 'dwtcontrolContainer';
let DWObject:WebTwain|undefined;

const initDWT = () => {
  Dynamsoft.DWT.RegisterEvent('OnWebTwainReady', () => {
    DWObject = Dynamsoft.DWT.GetWebTwain(ContainerId);
    emit('onWebTWAINReady',DWObject);
  });

  Dynamsoft.DWT.ResourcesPath = 'assets/dwt-resources';
  Dynamsoft.DWT.Containers = [{
      WebTwainId: 'dwtObject',
      ContainerId: ContainerId
  }];
  Dynamsoft.DWT.Load();
}

onMounted(async () => {
  initDWT();
});
</script>

There are some additional props we can add to the component:

  1. A license to activate Dynamic Web TWAIN. We can apply for a trial license here.

    const props = defineProps(['license']);
    const initDWT = () => {
      //...
      if (props.license) {
        Dynamsoft.DWT.ProductKey = props.license;
      }
      //...
    }
    
  2. Width and height for the viewer.

    const props = defineProps(['width','height','license']);
    const initDWT = () => {
      //...
      Dynamsoft.DWT.RegisterEvent('OnWebTwainReady', () => {
        DWObject.Viewer.width = "100%";
        DWObject.Viewer.height = "100%";
        resizeViewer();
      });
      //...
    }
       
    const resizeViewer = () => {
      if (viewer.value && DWObject) {
        let ele = viewer.value as HTMLElement;
        if (props.width) {
             
          ele.style.width = props.width;
        }
        if (props.height) {
          ele.style.height = props.height;
        }
      }
    }
       
    watch(() => [props.width,props.height], ([newWidth,oldWidth], [newHeight,oldHeight]) => {
      resizeViewer();
    });
    
  3. View mode for the viewer.

    const props = defineProps(['width','height','cols','rows','license']);
    const initDWT = () => {
      //...
      Dynamsoft.DWT.RegisterEvent('OnWebTwainReady', () => {
        updateViewMode();
      });
      //...
    }
    const updateViewMode = () => {
      if (props.cols && props.rows && DWObject) {
        DWObject.Viewer.setViewMode(parseInt(props.cols),parseInt(props.rows));
      }
    }
       
    watch(() => [props.cols,props.rows], ([newCols,oldCols], [newRows,oldRows]) => {
      updateViewMode();
    });
    

Layout Design

The app can be divided into three parts from left to right: the toolbar, the document viewer and the panels for operations.

Here is the basic template following this layout:

<template>
  <q-page class="index">
    <div class="row container">
      <div class="col-8" id="left">
        <div class="toolbar">
        </div>
        <div class="dwt">
        </div>
      </div>
      <div class="col-4" id="right">
      </div>
    </div>
  </q-page>
</template>

The CSS:

.index {
  height: calc(100% - 50px);
}

.toolbar {
  width: 50px;
  height: 100%;
  overflow: auto;
  padding: 5px;
  font-size: 21px;
  border: 1px solid #d9d9d9;
  border-radius: 2px;
}

.dwt {
  width: calc(100% - 50px);
  height: 100%;
}

#left {
  display: flex;
  height: 100%;
}

#right {
  height: 100%;
  overflow: auto;
}

Use the Component in the App

In IndexPage.vue, add the DWT component in the template:

<div class="dwt">
  <DWT
    width="100%" 
    height="100%" 
    cols="2"
    rows="2"
    @onWebTWAINReady="onWebTWAINReady"
  >
  </DWT>
</div>

Define an onWebTWAINReady function which saves the object of Web TWAIN:

let DWObject:WebTwain|undefined;
const onWebTWAINReady = (dwt:WebTwain) => {
  DWObject = dwt;
};

Next, we are going to implement the panels for operations.

Implement the Document Scanning Operations

Here, we use two expansion items as panels to hold relevant controls. One is for scanning and the other is for saving.

HTML:

<div class="col-4" id="right">
  <q-list bordered class="rounded-borders">
    <q-expansion-item
      :model-value="true"
      expand-separator
      label="Scan"
    >
      <div class="inner">
      </div>
    </div>
    </q-expansion-item>
    <q-expansion-item
      :model-value="true"
      expand-separator
      label="Save"
    >
      <div class="inner">
      </div>
    </q-expansion-item>
  </q-list>
</div>

Implement the Scan Panel

  1. Add a select component to list connected scanners and let the user select which scanner to use.

    The scanners list is loaded when Web TWAIN is ready. The first scanner is selected by default.

    HTML:

    <q-expansion-item
      :model-value="true"
      expand-separator
      label="Scan"
    >
      <div class="inner">
       <q-select v-model="selectedScanner" :options="scanners" label="Selected Scanner" />
      </div>
    </q-expansion-item>
    

    Script:

    const scanners = ref([] as string[]);
    const selectedScanner = ref("");
    const onWebTWAINReady = (dwt:WebTwain) => {
      DWObject = dwt;
      loadScanners();
    };
    
    const loadScanners = () => {
      if (DWObject) {
        let sourceNames = DWObject.GetSourceNames(false) as string[]; 
        scanners.value = sourceNames;
        if (sourceNames.length > 0 ) {
          selectedScanner.value = sourceNames[0];
        }
      }
    }
    
  2. Add three checkboxes for configuring the relevant scan settings: show the scanner’s setting UI, use auto document feeder and enable duplex scan.

    HTML:

    <q-checkbox left-label v-model="showUI" label="Show UI" />
    <q-checkbox left-label v-model="feederEnabled" label="Auto Feeder" />
    <q-checkbox left-label v-model="duplexEnabled" label="Duplex" />
    

    Script:

    const showUI = ref(false);
    const feederEnabled = ref(false);
    const duplexEnabled = ref(false);
    
  3. Add an input control for setting the resolution.

    HTML:

    <q-input
      v-model.number="resolution"
      type="number"
      style="max-width: 200px"
    >
      <template v-slot:before>
        <span style="font-size: 14px;color: black;">Resolution:</span>
      </template>
    </q-input>
    

    Script:

    const resolution = ref(200);
    
  4. Add radios for setting the pixel type of the scanned documents: black & white, gray or color.

    HTML:

    <div>
      <span>Pixel Type:</span>
      <q-radio v-model="pixelType" val="0" label="B&W" />
      <q-radio v-model="pixelType" val="1" label="Gray" />
      <q-radio v-model="pixelType" val="2" label="Color" />
    </div>
    

    Script:

    const pixelType = ref("0");
    
  5. Add a button to perform document scanning.

    HTML:

    <q-btn outline class="button" color="black" label="Scan" v-on:click="scan" />
    

    Script:

    const scan = () => {
      if (DWObject) {
        let selectedIndex = scanners.value.indexOf(selectedScanner.value);
        let deviceConfiguration:DeviceConfiguration = {};
          deviceConfiguration.IfShowUI = showUI.value;
          deviceConfiguration.IfFeederEnabled = feederEnabled.value;
          deviceConfiguration.IfDuplexEnabled = duplexEnabled.value;
          deviceConfiguration.SelectSourceByIndex = selectedIndex;
          deviceConfiguration.Resolution = resolution.value;
          deviceConfiguration.PixelType = pixelType.value;
          DWObject.AcquireImage(deviceConfiguration);
      }
    }
    

Implement the Save Panel

Add an input for setting the filename and add a button to save the scanned documents into a PDF file.

HTML:

<q-expansion-item
  :model-value="true"
  expand-separator
  label="Save"
>
  <div class="inner">
    <q-input outlined v-model="filename" label="Filename" />
    <q-btn outline class="button" color="black" label="Save as PDF" v-on:click="save" />
  </div>
</q-expansion-item>

Script:

const filename = ref("Scanned");
const save = () => {
  if (DWObject) {
    DWObject.SaveAllAsPDF(filename.value);
  }
}

Implement the Toolbar

The toolbar has five buttons. We can use them to remove, edit, rotate and move selected documents.

HTML:

<q-btn class="toolbar-btn" v-on:click="deleteSelected" icon="delete" />
<q-btn class="toolbar-btn" v-on:click="edit" icon="edit" />
<q-btn class="toolbar-btn" v-on:click="rotate" icon="rotate_right" />
<q-btn class="toolbar-btn" v-on:click="moveUp" icon="arrow_upward" />
<q-btn class="toolbar-btn" v-on:click="moveDown" icon="arrow_downward" />

Script:


const deleteSelected = () => {
  if (DWObject) {
    DWObject.RemoveAllSelectedImages();
  }
}

const edit = () => {
  if (DWObject) {
    let imageEditor = DWObject.Viewer.createImageEditor();
    imageEditor.show();
  }
}

const rotate = () => {
  if (DWObject) {
    DWObject.RotateRight(DWObject.CurrentImageIndexInBuffer);
  }
}

const moveUp = () => {
  if (DWObject) {
    DWObject.MoveImage(DWObject.CurrentImageIndexInBuffer,DWObject.CurrentImageIndexInBuffer-1);
  }
}

const moveDown = () => {
  if (DWObject) {
    DWObject.MoveImage(DWObject.CurrentImageIndexInBuffer,DWObject.CurrentImageIndexInBuffer+1);
  }
}

CSS:

.toolbar-btn {
  width: 100%;
}

All right, we’ve now finished building the document scanning app.

Make it Work as a Desktop App using Electron

It is easy to make a Quasar app a desktop app using Electron.

  1. Add the Electron mode:

    quasar mode add electron
    
  2. Start the app in development mode:

    quasar dev -m electron
    
  3. Build the app for production:

    quasar build -m electron
    

There are some extra steps we have to do to adapt the app as an Electron app and add native features.

Prompt the User to Install Dynamsoft Service

Dynamsoft Service is a local service for interacting with scanners. It has to be installed beforehand. The default behavior when the service is not found works okay in browsers but not in Electron. We have to override the default behavior to prompt the user to install Dynamsoft Service. The Dynamsoft Service installers are located in the dist folder of Web TWAIN’s resources folder.

  1. Add an onWebTWAINNotFound event for the DWT component.

    const emit = defineEmits(['onWebTWAINReady','onWebTWAINNotFound']);
    const initDWT = () => {
      //...
      const notfound = () => {
        emit('onWebTWAINNotFound');
      }
    
      let DynamsoftAny:any = Dynamsoft;
      //override the default behavior
      DynamsoftAny.OnWebTwainNotFoundOnWindowsCallback = notfound;
      DynamsoftAny.OnWebTwainNotFoundOnMacCallback = notfound;
      DynamsoftAny.OnWebTwainNotFoundOnLinuxCallback = notfound;
    }
    
  2. Add a reconnect function for the DWT component and expose it so that the parent component can call the function.

    The reconnect function can check whether the service is running. If it is running, initialize Dynamic Web TWAIN. Otherwise, trigger the not found event.

    const reconnect = () => {
      let dwtport = 18622;
      let DynamsoftAny:any = Dynamsoft;
      if (location.protocol == "https:") {
        dwtport = 18623;
      }
      DynamsoftAny.DWT.CheckConnectToTheService("127.0.0.1",
        dwtport, 
        function () {
          initDWT();
        }, 
        function () {
          emit('onWebTWAINNotFound');
        }
      );
    }
    
    defineExpose({
      reconnect
    })
    
  3. Create a reference object of the DWT component and add the onWebTWAINNotFound event in the page.

    HTML:

    <DWT
      ref="dwtElement" 
      width="100%" 
      height="100%" 
      cols="2"
      rows="2"
      @onWebTWAINReady="onWebTWAINReady"
      @onWebTWAINNotFound="onWebTWAINNotFound">
    </DWT>
    

    Script:

    const dwtElement = ref<any>();
    const onWebTWAINNotFound = () => {};
    
  4. In the page, when the onWebTWAINNotFound event is triggered, show a dialog with the links to the installer and a reconnect button. Since Dynamsoft Service has different installers for Windows, macOS and Linux, we also have to display the links based on the running platform.

    HTML:

    <q-dialog v-model="confirm" persistent>
      <q-card>
        <q-card-section>
          <div>Dynamsoft Service is not found. Please download and install it first.</div>
          <div v-for="(installer, index) in serviceInstallers" :key="index">
            <div><a :href='`assets/dwt-resources/dist/${installer}`'></a></div>
          </div>
        </q-card-section>
        <q-card-actions align="right">
          <q-btn flat label="Reconnect to the service" color="primary" v-close-popup v-on:click="reconnect" />
        </q-card-actions>
      </q-card>
    </q-dialog>
    

    Script:

    import { Platform } from 'quasar';
    const confirm = ref(false);
    const serviceInstallers = ref([] as string[]);
    const onWebTWAINNotFound = () => {
      const platform = Platform.is.platform;
      if (platform === 'win') {
        serviceInstallers.value = ['DynamsoftServiceSetup.msi'];
      }else if (platform === 'mac') {
        serviceInstallers.value = ['DynamsoftServiceSetup.pkg'];
      }else {
        serviceInstallers.value = ['DynamsoftServiceSetup.rpm','DynamsoftServiceSetup.deb','DynamsoftServiceSetup-arm64.deb']; 
      }
      confirm.value = true;
    }
       
    const reconnect = () => {
      if (dwtElement.value) {
        dwtElement.value.reconnect();
      }
    }
    

Add OCR Function

With Electron, we can use native APIs using node.js. Here, we can add a function to OCR the scanned document to demonstrate how to do it.

Here is some knowledge of Quasar and Electron we have to learn about:

In Electron, the process that runs package.json’s main script is called the main process. This is the script that runs in the main process and can display a GUI by initializing the renderer thread. This thread deals with your code in /src-electron/electron-main.[js|ts].

The preload script (/src-electron/electron-preload.[js|ts]) is a way for you to inject Node.js stuff into the renderer thread by using a bridge between it and the UI. You can expose APIs that you can then call from your UI.

In order to expose native features to our web app, we have to modify the preload script.

Here are the steps to add the OCR function.

  1. Install tesseractocr which calls tesseract via CLI to extract text.

    npm i tesseractocr
    
  2. In electron-preload.ts, expose two functions. One is to get the temp folder to save the scanned document image for OCR and the other is to recognize the image using tesseract.

    import { contextBridge } from 'electron'
    const tesseract = require('tesseractocr')
    const os = require('os');
    
    contextBridge.exposeInMainWorld('myAPI', {
      platform: () => process.platform,
      recognize: recognize,
      tmpDir: () => os.tmpdir()
    })
    
    async function recognize(path:string){
      return await tesseract.recognize(path);
    }
    
  3. In the web app, add an OCR button in the toolbar to get the text of a selected document using the exposed functions and display the text in a dialog. The button is only shown for the Electron platform.

    HTML:

    <div v-if="isElectron">
        <q-dialog v-model="showOCRResult">
          <q-card>
            <q-card-section>
              <div class="text-h6">Result</div>
            </q-card-section>
    
            <q-card-section class="q-pt-none">
              <pre>
                   
              </pre>
            </q-card-section>
    
            <q-card-actions align="right">
              <q-btn flat label="OK" color="primary" v-close-popup />
            </q-card-actions>
          </q-card>
        </q-dialog>
      </div>
      <q-btn v-if="isElectron" class="toolbar-btn" v-on:click="recognizeSelected">
        <img :src="OCR"/>
      </q-btn>
    </div>
    

    Script:

    const isElectron = ref(false);
    const OCRResult = ref("");
    const showOCRResult = ref(false);
    onMounted(async () => {
      if (Platform.is.electron) {
        isElectron.value = true;
      }
    });
       
    const recognizeSelected = async () => {
      if (DWObject) {
        DWObject.IfShowFileDialog = false;
        let windowAny = window as any;
        let imgPath = windowAny.myAPI.tmpDir() + "/out.jpg";
    
        //If the current image is B&W
        //1 is B&W, 8 is Gray, 24 is RGB
        if (DWObject.GetImageBitDepth(DWObject.CurrentImageIndexInBuffer) == 1) {
          //If so, convert the image to Gray
          DWObject.ConvertToGrayScale(DWObject.CurrentImageIndexInBuffer);
        }
        DWObject.SaveAsJPEG(imgPath,DWObject.CurrentImageIndexInBuffer,
        function() {
          const OCR = async () => {
            let text = await windowAny.myAPI.recognize(imgPath);
            OCRResult.value = text;
            showOCRResult.value = true;
          }
          OCR();
        },
        function() {
          console.log("failed");
        });
      }
    }
    

PS: in order to use third-party packages, we need to disable the sandbox.

Source Code

Get the source code of the demo to have a try:

https://github.com/tony-xlh/electron-quasar-document-scanner