How to Build a Document Scanning Desktop App with Tauri

Tauri is a framework for building desktop apps for all major platforms. Developers can integrate any front-end framework that compiles to HTML, JS and CSS for building their user interface. The backend of the application is a Rust-sourced binary with an API that the front-end can interact with.

Different from Electron which bundles a complete Chrome, Tauri uses the system’s built-in webview. The installer’s size can be very small.

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

The final app looks like this:

Document Scanner

Getting Started With Dynamic Web TWAIN

Build a Document Scanning Desktop App with Tauri

Let’s do this in steps.

New Project

npm create tauri-app
✔ Project name · Document Scanner
✔ Package name · document-scanner
✔ Choose your package manager · npm
✔ Choose your UI template · react-ts

Here we choose React with TypeScript as the template.

Install Dependencies

  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.

    Add the following script to package.json:

    "copy:resources": "npx ncp node_modules/dwt/dist public/dwt-resources && npx ncp public/dwt-resources/dist src-tauri/dist && npx del-cli public/dwt-resources/dist -f"
    

    Install used command line packages:

    npm install --save-dev ncp del-cli
    

    Then run the command:

    npm run copy:resources
    

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 React component and expose the object of Dynamic Web TWAIN to call different APIs.

Here is the basic content of the component:

interface props {
  onWebTWAINReady?: (dwt:WebTwain) => void;
}

const DocumentViewer: React.FC<props> = (props: props)  => {
  const containerID = "dwtcontrolContainer";
  const container = useRef<HTMLDivElement>(null);
  useEffect(()=>{
    Dynamsoft.DWT.RegisterEvent('OnWebTwainReady', () => {
      const DWObject = Dynamsoft.DWT.GetWebTwain(containerID);
      if (props.onWebTWAINReady) {
        props.onWebTWAINReady(DWObject); // expose the object of Dynamic Web TWAIN
      }
    });
    Dynamsoft.DWT.ResourcesPath = "/dwt-resources";
    Dynamsoft.DWT.Containers = [{
        WebTwainId: 'dwtObject',
        ContainerId: containerID
    }];
    Dynamsoft.DWT.Load();
  },[]);

  return (
    <div ref={container} id={containerID}></div>
  );
}

export default DocumentViewer;

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.

    interface props {
      license?:string;
    }
    useEffect(()=>{
      if (props.license) {
        Dynamsoft.DWT.ProductKey = props.license;
      }
    },[]);
    
  2. Width and height for the viewer.

    interface props {
      width?: string;
      height?: string;
    }
    useEffect(()=>{
      Dynamsoft.DWT.RegisterEvent('OnWebTwainReady', () => {
        const DWObject = Dynamsoft.DWT.GetWebTwain(containerID);
        DWObject.Viewer.width = "100%";
        DWObject.Viewer.height = "100%";
        if (props.width) {
          if (container.current) {
            container.current.style.width = props.width;
          }
        }
        if (props.height) {
          if (container.current) {
            container.current.style.height = props.height;
          }
        }
      });
    },[]);
    
  3. View mode for the viewer:

    interface props {
      viewMode?: {cols:number,rows:number};
    }
    useEffect(()=>{
      Dynamsoft.DWT.RegisterEvent('OnWebTwainReady', () => {
        const DWObject = Dynamsoft.DWT.GetWebTwain(containerID);
        if (props.viewMode) {
          DWObject.Viewer.setViewMode(props.viewMode.cols,props.viewMode.rows);
        }
      });
    },[]);
    
  4. An onWebTWAINNotFound event when the Dynamsoft Service has not been installed.

    interface props {
      license?:string;
    }
    useEffect(()=>{
      if (props.onWebTWAINNotFound){
        const notfound = () => {
          if (props.onWebTWAINNotFound){
            props.onWebTWAINNotFound();
          }
        }
        let DynamsoftAny:any = Dynamsoft; // use any here since the followings are not defined in TypeScript
        DynamsoftAny.OnWebTwainNotFoundOnWindowsCallback = notfound;
        DynamsoftAny.OnWebTwainNotFoundOnMacCallback = notfound;
        DynamsoftAny.OnWebTwainNotFoundOnLinuxCallback = notfound;
      }
    },[]);
    

    PS: Dynamsoft Service is a local service for interacting with scanners.

Use the Component in the App

Next, we are going to use the component in the app.

Here, we use Ant Design’s UI components.

The viewer is placed in the left and a sider with panels is placed in the right for operations.

import "./App.css";
import { Button, Layout, Collapse, Checkbox, Radio, RadioChangeEvent, InputNumber  } from 'antd';
const { Panel } = Collapse;
const { Content, Sider } = Layout;
function App() {
  const dwt = React.useRef<WebTwain>();
  const [viewMode, setViewMode] = React.useState({cols:2,rows:2});
  const onWebTWAINReady = (instance:WebTwain) => {
    dwt.current = instance;
  }
  const onWebTWAINNotFound = async () => {
    console.log("Dynamsoft Service not installed");
  }
  return (
    <Layout hasSider>
      <Layout style={{ marginRight: 300 }}>
        <Content className="content">
            <DocumentViewer
              width="100%"
              height="100%"
              onWebTWAINReady={onWebTWAINReady}
              onWebTWAINNotFound={onWebTWAINNotFound}
              viewMode={viewMode}
            ></DocumentViewer>
        </Content>
      </Layout>
      <Sider 
        width='300px'
        theme='light'
        style={{
          overflow: 'auto',
          height: '100vh',
          position: 'fixed',
          right: 0,
          top: 0,
          bottom: 0,
        }}
      >
        <Collapse className="controls" defaultActiveKey={['1']} onChange={onChange}>
          <Panel header="SCAN" key="1">
          </Panel>
          <Panel header="VIEWER" key="2">
          </Panel>
          <Panel header="SAVE" key="3">
          </Panel>
        </Collapse>
      </Sider>
  </Layout>
  );
}

export default App;

The CSS:

@import 'antd/dist/antd.css';

.content {
  height: calc(100vh - 16px);
  margin-left: 8px;
  margin-top: 8px;
  margin-bottom: 8px;
  width: "100%";
}

.controls {
  margin: 8px;
}

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

Implement the Scan Panel

  1. Add a select component to select which scanner to use.

    const [scanners,setScanners] = React.useState([] as string[]);
    const [selectedScanner,setSelectedScanner] = React.useState("");
    const onSelectedScannerChange = (value:string) => {
      setSelectedScanner(value);
    }
    //...
    <Panel header="SCAN" key="1">
      Select Scanner:
      <Select 
        onChange={onSelectedScannerChange}
        value={selectedScanner}
        style={{width:"100%"}}>
        {scanners.map(scanner => 
          <Option 
            key={scanner} 
            value={scanner}
          >{scanner}</Option>
        )}
      </Select>
    </Panel>
    

    The scanners list is loaded when Web TWAIN is ready.

    const onWebTWAINReady = (instance:WebTwain) => {
      dwt.current = instance;
      loadScannersList();
    }
       
    const loadScannersList = () => {
      const DWObject = dwt.current;
      if (DWObject) {
        const names = DWObject.GetSourceNames(false) as string[];
        setScanners(names);
        if (names.length>0) {
          setSelectedScanner(names[0]);
        }
      }
    }
    
  2. Add an auto feeder (ADF) checkbox.

    const [ADF,setADF] = React.useState(false);
    //...
    <Checkbox checked={ADF} onChange={()=>{setADF(!ADF)}}>ADF</Checkbox>
    
  3. Add a show UI checkbox. If it is enabled, a scanning configuration window will appear when we start scanning.

    const [showUI,setShowUI] = React.useState(false);
    //...
    <Checkbox checked={showUI} onChange={()=>{setShowUI(!showUI)}}>Show UI</Checkbox>
    
  4. Add three radios for pixel type selection. We can use this to set whether the scanned document’s pixel type is black & white, gray or color.

    const [selectedPixelType, setSelectedPixelType] = React.useState(0);
    //...
    Pixel Type:
    <Radio.Group onChange={(e:RadioChangeEvent)=>{setSelectedPixelType(e.target.value)}} value={selectedPixelType}>
      <Radio value={0}>B&W</Radio>
      <Radio value={1}>Gray</Radio>
      <Radio value={2}>Color</Radio>
    </Radio.Group>
    
  5. Add a resolution select.

    const [selectedResolution, setSelectedResolution] = React.useState(100);
    //...
    Resolution:
    <Select 
      style={{width:"100%"}}
      onChange={(value)=>{setSelectedResolution(value)}}
      value={selectedResolution}>
        <Option value="100">100</Option>
        <Option value="200">200</Option>
        <Option value="300">300</Option>
    </Select>
    
  6. Add a scan button to start scanning.

    const scan = () => {
      const DWObject = dwt.current;
      if (DWObject) {
        let deviceConfiguration:DeviceConfiguration = {};
        deviceConfiguration.IfShowUI = showUI;
        deviceConfiguration.IfFeederEnabled = ADF;
        deviceConfiguration.SelectSourceByIndex = scanners.indexOf(selectedScanner);
        deviceConfiguration.PixelType = selectedPixelType;
        deviceConfiguration.Resolution = selectedResolution;
        DWObject.AcquireImage(deviceConfiguration);
      }
    }
    //...
    <Button onClick={scan}>Scan</Button>
    

Implement the Save Panel

In the save panel, add a button to save all the documents as a PDF file.

<Panel header="SAVE" key="3">
  <Button onClick={save}>Save as PDF</Button>
</Panel>

The save function:

const save = () => {
  const DWObject = dwt.current;
  if (DWObject) {
    const onSuccess = () => {
      alert("success");
    }
    const onFailure = () => {
      alert("failed");
    }
    DWObject.SaveAllAsPDF("Documents.pdf",onSuccess,onFailure);
  }
}

Implement the Viewer Panel

  1. Add two number input to set up the view mode of the viewer.

    Viewer Mode:
    <div>
      <InputNumber min={-1} max={5} value={viewMode.cols} onChange={(value)=>{setViewMode({cols:value,rows:viewMode.rows})}} />
      x
      <InputNumber min={-1} max={5} value={viewMode.rows} onChange={(value)=>{setViewMode({cols:viewMode.cols,rows:value})}} />
    </div>
    
  2. Add a button to display the image editor.

    const showImageEditor = () => {
      const DWObject = dwt.current;
      if (DWObject) {
        let imageEditor = DWObject.Viewer.createImageEditor();
        imageEditor.show();
      }
    }
    //...
    <Button onClick={showImageEditor}>Show Image Editor</Button>  
    

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

Ask the User to Install Dynamsoft Service

Dynamsoft Service is required for the app to work. In the installing dependencies step, we’ve put the installers under src-tauri/dist.

We are going to set that folder as resources in tauri.conf.json so that the app will bundle them as resources.

- "resources": [],
+ "resources": ["dist"],

Meanwhile, in the onWebTWAINNotFound event, display a dialog and open the folder of the installers to let the user install the service.

import { resourceDir, join } from '@tauri-apps/api/path';
import { open } from '@tauri-apps/api/shell';
import { message } from '@tauri-apps/api/dialog';
const onWebTWAINNotFound = async () => {
  await message('Dynamsoft Service has not been installed. Please install it and then restart the program.', { title: 'Document Scanner', type: 'warning' });
  const resourceDirPath = await resourceDir();
  const distPath = await join(resourceDirPath, 'dist');
  await open(distPath);
}

Several @tauri-apps/api packages are used for native functionalities.

Source Code

https://github.com/tony-xlh/Tauri-Document-Scanner