How to Integrate Document Scanning into a Quasar Electron Desktop App

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

What you’ll build: A cross-platform desktop document scanning application using Quasar (Vue.js), Electron, and the Dynamic Web TWAIN SDK — capable of scanning, editing, rotating, and saving documents as PDF, with optional OCR text extraction.

Key Takeaways

  • Quasar’s Electron mode lets you package a Vue.js document scanning web app as a native desktop application for Windows, macOS, and Linux with minimal configuration changes.
  • Dynamic Web TWAIN provides a JavaScript API to discover connected scanners, configure scan settings (resolution, pixel type, duplex), and save results as multi-page PDF.
  • Electron’s preload script bridges Node.js APIs into the renderer process, enabling native features like Tesseract OCR on scanned images.
  • The Dynamic Web TWAIN Service must be installed locally for scanner communication; the tutorial includes platform-aware installer detection and a reconnect flow.

Common Developer Questions

  • How do I integrate a document scanner into a Quasar Electron desktop app?
  • Can I scan documents and save them as PDF using Vue.js and Dynamic Web TWAIN?
  • How do I add OCR text recognition to an Electron app with Quasar?

Prerequisites

  • Node.js 14 or later
  • Quasar CLI (npm install -g @quasar/cli)
  • A physical or virtual document scanner connected to your machine
  • Get a 30-day free trial license for Dynamic Web TWAIN

Step 1: Create a New Quasar 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

Step 2: Install the Dynamic Web TWAIN SDK

  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",
    

Step 3: Configure the Default Project 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>
    

Step 4: Create the 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();
    });
    

Step 5: Design the Three-Panel App Layout

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

Step 6: Integrate the Viewer Component into the Page

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.

Step 7: Implement Scanning and Saving Controls

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>

Build the Scanner Configuration 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);
      }
    }
    

Build the PDF 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);
  }
}

Step 8: Add the Document Management 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.

Step 9: Package as a Desktop App with 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.

Handle Dynamic Web TWAIN Service Installation

Dynamic Web TWAIN 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 Dynamic Web TWAIN Service. The Dynamic Web TWAIN 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 Dynamic Web TWAIN 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>Dynamic Web TWAIN 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 Text Recognition via Electron’s Node.js Bridge

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.

Common Issues and Edge Cases

  • Dynamic Web TWAIN Service not detected in Electron: The default browser-based service detection does not work inside Electron’s renderer process. Override the OnWebTwainNotFoundOnWindowsCallback, OnWebTwainNotFoundOnMacCallback, and OnWebTwainNotFoundOnLinuxCallback callbacks and use CheckConnectToTheService to verify connectivity before initializing.
  • Scanned images appear black when saving as JPEG for OCR: If the scanned document has a bit depth of 1 (black and white), JPEG encoding produces a solid black image. Convert the image to grayscale using ConvertToGrayScale before saving.
  • Electron sandbox blocks Node.js modules in the preload script: Third-party packages like tesseractocr require Node.js APIs that are blocked when Electron’s sandbox is enabled. Disable the sandbox in your BrowserWindow configuration or use Electron’s nodeIntegration with appropriate security considerations.

Source Code

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

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