Creating A Cross-platform Barcode Reader App With Electron And Dynamsoft Barcode Reader

Electron, one of the most popular cross-platform desktop application frameworks, has been used in numerous famous apps. With web technology, developers could rapidly build a product for Windows, macOS, and Linux. In this article, we would use Electron to build a barcode decoding and scanning app with Dynamsoft Barcode Reader.

Installation

To create our app, we need the following software/packages/tools.

Node.js is the foundation of Electron and node package management. You must install Node.js in your host before continuing the development work. 

Dynamsoft Barcode Reader is an industry-leading barcode decoding SDK. It supports various formats of barcode and could be run on all the mainstream platforms.

We are going to import a C++ addon for our project. The node-gyp is the configure tool for compiling C++ programs as Node.js addons.

Create The Project

Initialize The Project

We use the official quick start sample to begin our work.

Firstly, let’s clone the sample project to our host.

git clone https://github.com/electron/electron-quick-start.git

Then, install the dependencies and start the project to test if we can run Electron projects properly.

cd electron-quick-start
npm install
npm start

If there is no error, you would see a popup window printing “Hello World!”.

Electron run successfully

Electron app run successfully.

In your terminal, entering the following command to install the required dependencies.

npm install --save-dev electron-rebuild
npm install -g node-gyp

Enabling Node.js Integration

By default, Node.js integration is disabled. We change it to true in order to use require while importing the module. In main.js, we specify this value in the option of BrowserWindow.

const mainWindow = new BrowserWindow({
    width: 1280,
    height: 1024,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: true // Change to true to use Node API
    }
  })

Import Node.js C++ Addons

Dynamsoft Barcode Reader provides both JavaScript and C/C++ barcode SDKs.To get better performance for the Electron app, we could write a Node.js barcode addon in C++. We can get the addon source code from Github.

We create a folder named libs under the project root directory, then cloning the node-barcode repository.

mkdir libs
cd libs
git clone https://github.com/Dynamsoft/nodejs-barcode

Once the cloning process has finished, we change the directory and build the library for our project using node-gyp.

cd nodejs-barcode
../../node_modules/.bin/electron-rebuild

The command electron-rebuild would detect the build environment and fetch corresponding headers to finish the build.

Rebuild the module with electron-rebuild

Rebuild the module with electron-rebuild

Once the build is done, several directories which include the dynamically linked library are created.

The headers and compiled binaries would be placed among three folders. The binary we want is in build folder.

The headers and compiled binaries would be placed among three folders

Since the library has correctly referred to the binary library, we just need to import the index.js file in our project.

Back to the project root and open the main.js file with an editor. The main.js is the starting point of the entire application. We import the barcode addon to do a quick test.

const dbr = require('dbr')

No errors generated after requiring the nodejs-barcode library

No errors generated after requiring the nodejs-barcode library

Decode Barcode From File

Before starting to code, let’s have a look at the APIs.

APIs of nodejs-barcode library

APIs of nodejs-barcode library

  • decodeBufferAsync: reading and decoding from original RGBA data.
  • decodeFileAsync: reading and decoding by reading an image file.
  • decodeFileStreamAsync: reading and decoding by reading a file stream.
  • decodeBase64Async: the image data is passed as base64 string
  • decodeYUYVAsync: the image data is YUV format other than RGBA

Create Decoding Services

We create two script files: background-services.js and foreground-services.js

The foreground-services.js is consumed by the web part, whereas the background-services.js invokes the C++ addon APIs and provides decoding services.

In the background-services.js, we import Node.js barcode addon.

const { ipcMain } = require('electron')
const dbr = require('./libs/nodejs-barcode/index')
const barcodeTypes = dbr.barcodeTypes
 
function decodeFileAsync(evt, filepath) {
}
 
function decodeBase64Async(evt, base64Str) {
}
 
function decodeBufferAsync(evt, imgData, width, height) {
}
 
function register() {
  ipcMain.on('decodeFileAsync', decodeFileAsync)
  ipcMain.on('decodeBase64Async', decodeBase64Async)
  ipcMain.on('decodeBufferAsync', decodeBufferAsync)
}
 
module.exports = {
  register
}

Since Node.js is using Event-Model, we make inter-process communication using event listeners. To register a listener on the main process, we use ipcMain.on to add the event name and corresponding handler. We put the registering process in the function register to control when to register these events.

In the foreground-services.js, we are now able to consume these services.

const { ipcRenderer } = require('electron')
 
const DEBUG = false
 
ipcRenderer.setMaxListeners(30)

const resultBuffer = {
  lastUpdate: null,
  results: []
}

const frameBuffer = {
  lastUpdate: null,
  imgData: Uint8ClampedArray.from([]),
  width: 0,
  height: 0,
  channel: 0,
  decoding: false
}

function setFrameBuffer(img, width, height, channel) {
  console.log('frame buffer to update')
  frameBuffer.imgData = img
  frameBuffer.width = width
  frameBuffer.height = height
  frameBuffer.channel = channel
  frameBuffer.lastUpdate = Date.now()
}

function startVideoDecode() {
  frameBuffer.decoding = true
  videoDecode()
}

function stopVideoDecode() {
  frameBuffer.decoding = false
}

function videoDecode() {
  ipcRenderer.send('videoDecode', frameBuffer.imgData, frameBuffer.width, frameBuffer.height)
}

ipcRenderer.on('videoDecode-next', (evt, msg) => {
  updateResultBuffer(msg)
  if (frameBuffer.decoding)
    videoDecode()
})

function decodeFileAsync(filepath) {
  if (DEBUG)
    console.log('sending decodeFileAsync from renderer process with args: ' + filepath)
  ipcRenderer.send('decodeFileAsync', filepath)
}

function decodeBase64Async(base64Str) {
  if (DEBUG)
    console.log('sending decodeBase64Async from renderer process')
  ipcRenderer.send('decodeBase64Async', base64Str)
}

function decodeBufferAsync(imgData, width, height) {
  if (DEBUG)
    console.log('sending decodeBufferAsync from renderer process')
  ipcRenderer.send('decodeBufferAsync', imgData, width, height )
}

function updateResultBuffer(msg) {
  resultBuffer.lastUpdate = Date.now()
  resultBuffer.results = msg
}

ipcRenderer.on('decodeBufferAsync-done', (evt, msg) => {
  updateResultBuffer(msg)
})

ipcRenderer.on('decodeFileAsync-done', (evt, msg) => {
  updateResultBuffer(msg)
})

ipcRenderer.on('decodeBase64Async-done', (evt, msg) => {
  updateResultBuffer(msg)
})

  module.exports = {
    decodeFileAsync,
    decodeBase64Async,
    decodeBufferAsync,
    setFrameBuffer,
    startVideoDecode,
    stopVideoDecode,
    resultBuffer
  }

Consuming Services

We have prepared the services. Now we turn our attention to the “frontend”.

To select a file in the web application, the HTML input element is the one usually used.

<input id="file-selector" type="file">

To send the file path to the decoding service after the file is selected, we can register the onchange event. Don’t forget to register **onclick** at the same time, otherwise it would not decode the same image for a second time.

document.getElementById('file-selector').onchange = handleFileChange
document.getElementById('file-selector').onclick = evt => {
  evt.target.value = ''
}
 
async function handleFileChange(evt) {
  const file = evt.target.files[0]
  const results = await services.decodeFileAsync(file.path)
  updateResults(results)
}
async function updateResults(results) {
  // Remove existing results
  const container = document.getElementById('result-box')
  container.innerHTML = ''
  const nodes = []
  results.forEach(result => {
    nodes.push(`<div class="result-card"> \
                  <p>Format: ${result.format}</p> \
                  <p>Text: ${result.value}</p> \
                </div>`
              )

Showing the decoding results

Showing the decoding results

Real-Time Decoding With Camera

Access Camera

Obviously, our first step is to access the camera. Similar to activating the camera in the browser, we use navigator.mediaDevices.getUserMediato apply for camera access.

function initCamera() {
  const video = document.querySelector('video') || document.createElement("video")
  const navigator = window.navigator
  const stream = navigator.mediaDevices.getUserMedia({
    video: { facingMode: 'user', width: 1280, height: 720 }
  })
  stream.then(stream => {
    video.srcObject = stream
  })
}

The getUserMedia returns a Promise which would be resolved with a video stream. We add the subsequent statement to specify the srcObject as the resolved stream for the video element.

In the index.html, we add a button and specify the onclick event handler with initCamera.

<button id="video-capture-btn" class="btn-secondary">Capture</button>
document.getElementById('video-capture-btn').onclick = initCamera

Retrieve Image Data

We use canvas to get image data.

function getFrame(videoElement) {
  const cvs = new OffscreenCanvas(640, 360)
  const ctx = cvs.getContext('2d')
  ctx.drawImage(videoElement, 0, 0, cvs.width, cvs.height)
  const imgData = ctx.getImageData(0, 0, cvs.width, cvs.height)
  decodeFromFrame(imgData)
}

The canvas could accept a video element and draw the graph with the video frame. Simply specifying the video element and the area to draw, we could get the frame data afterward.

Each time we retrieve a video frame, we dispatch it to the service for decoding.

async function decodeFromFrame(frame) {
  const res = await services.decodeBufferAsync(frame.data, frame.width, frame.height)
  updateResults(res.data)
}

Finally, the decoding process needs an event to fire. In initCamera, we register the onplay handler with an interval action which periodically executes the getFrame function to read and decode the image data.

Dispatch Image Data

Once we have the image data, we can send it to the decoding services. The ipcMain and ipcRenderer are the inter-processes communication objects for Main Process and Renderer Process respectively. By registering event listeners, each process could listen for specific messages. We register the following events and their corresponding finish message protocols.

Sender Receiver Event Name Message Protocol
Renderer Main decodeFileAsync File path
Main Renderer decodeFileAsync-done Results
Renderer Main decodeBufferAsync Image Data, width, height
Main Renderer decodeBufferAsync-done Results
Renderer Main decodeBase64Async Base64 String
Main Renderer decodeBase64Async-done Results
Main Renderer decodeVideo Image Data, width, height
Renderer Main decodeVideo-next Results

In background-services, we register the following listeners,

ipcMain.on('decodeFileAsync', decodeFileAsync)
ipcMain.on('decodeBase64Async', decodeBase64Async)
ipcMain.on('decodeBufferAsync', decodeBufferAsync)
ipcMain.on('videoDecode', videoDecode)

and their handlers.

function videoDecode(evt, imgData, width, height) {
  if (DEBUG)
    console.log(`${new Date().toLocaleString()}/real-time decoding for video stream: ${imgData.length/height}, ${width}`)
  dbr.decodeBufferAsync(imgData, width, height, width*4, barcodeTypes, (err, msg) => {
    if (err)
      console.log(err)
    let results = [];
    for (index in msg) {
      let result = Object()
      let res = msg[index];
      result.format = res['format']
      result.value = res['value']
      results.push(result)
    }
    evt.reply('videoDecode-next', results)
    if (DEBUG)
      console.log('ipcMain: replied with ' + JSON.stringify(results))
  })
}

function decodeFileAsync(evt, filepath) {
  if (DEBUG)
    console.log('ipcMain: decodeFileAsync invoked: ' + filepath)
    dbr.decodeFileAsync(filepath, barcodeTypes, (err, msg) => {
      if (err)
        console.log(err)
      let results = [];
      for (index in msg) {
        let result = Object()
        let res = msg[index];
        result.format = res['format']
        result.value = res['value']
        results.push(result)
      }
      evt.reply('decodeFileAsync-done', results)
      if (DEBUG)
        console.log('ipcMain: replied with ' + JSON.stringify(results))
    })
}

function decodeBase64Async(evt, base64Str) {
  if (DEBUG)
    console.log('ipcMain: decodeBase64Async is invoked')
  dbr.decodeBase64Async(base64Str, barcodeTypes, (err, msg) => {
    if (err)
      console.error(err)
    let results = [];
    for (index in msg) {
      let result = Object()
      let res = msg[index];
      result.format = res['format']
      result.value = res['value']
      results.push(result)
    }
    evt.reply('decodeBase64Async-done', results)
    if (DEBUG)
        console.log('ipcMain: replied with ' + JSON.stringify(results))
  })
}

function decodeBufferAsync(evt, imgData, width, height) {
  if (DEBUG)
    console.log('ipcMain: decodeBufferAsync is invoked')
  console.log(imgData)
  dbr.decodeBufferAsync(imgData, width, height, width*4, barcodeTypes, (err, msg) => {
    if (err)
      console.error(err)
    let results = [];
    for (index in msg) {
      let result = Object()
      let res = msg[index];
      result.format = res['format']
      result.value = res['value']
      results.push(result)
    }
    evt.reply('decodeBufferAsync-done', results)
    if (DEBUG)
        console.log('ipcMain: replied with ' + JSON.stringify(results))
  })
}

Read The Barcode

We have implemented the features we want. Now it is time to run and test our project. Go to the project root path, type npm start in the terminal to launch the Electron barcode reader.

Note that it is compulsory to compile the nodejs-barcode library each time if the version of Electron or platform changed.

The first page after launching the app. An Open File and Capture button are shown on the top.

The first page after launching the app

Decoding a camera stream and showing the result

Decoding a camera stream and showing the result

Source Code

Github: Dynamsoft/dbr-electron-nodejs