DEV Community

Cover image for How to Build a Document Scanner with Expo
xulihang
xulihang

Posted on • Originally published at dynamsoft.com

How to Build a Document Scanner with Expo

Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React. It is basically a set of tools built on top of React Native, which makes it easy to develop and distribute apps.

In this article, we are going to build a document scanner with Expo. It can detect document boundaries and crop the document image via the camera and scan documents via physical document scanners.

Demo video

SDKs Used

New Project

  1. Create a new Expo project:

    npx create-expo-app DocumentScanner
    
  2. Add camera permissions in app.json:

    "ios": {
      "infoPlist": {
        "NSCameraUsageDescription": "This app uses the camera to scan barcodes."
      }
    },
    "android": {
      "permissions": ["android.permission.CAMERA","android.permission.INTERNET"]
    }
    
  3. Add dependencies:

    npx expo install react-native-webview expo-camera react-native-safe-area-context expo-sharing expo-file-system
    

Design the Home Page

Update App.js to add an image element to display the scanned image, a button to scan documents, a button to share the document image, a button to view the history and two selections for choosing the scanning device and the color mode.

There are three color modes: black & white, gray and color. The device array includes the camera and connected document scanners.

Document Scanner

import { StatusBar } from 'expo-status-bar';
import { useState,useEffect, useRef } from 'react';
import { StyleSheet, View, Image, Text } from 'react-native';
import Button from './components/Button';
import Select from './components/Select';
import { SafeAreaView, SafeAreaProvider  } from 'react-native-safe-area-context';

const PlaceholderImage = require('./assets/thumbnail.png');
const colorModes = ["Black&White","Gray","Color"];

export default function App() {
  const path = useRef("");
  const [devices,setDevices] = useState(["Camera"]);
  const [selectedDeviceIndex,setSelectedDeviceIndex] = useState(0);
  const [selectedColorMode,setSelectedColorMode] = useState("Color");
  const [image,setImage] = useState(PlaceholderImage);

  const renderBody = () => {
    return (
      <View style={styles.home}>
        <View style={styles.imageContainer}>
          <Image source={image} style={styles.image} />
        </View>
        <View style={styles.footerContainer}>
          <View style={styles.option}>
            <Text style={styles.label}>Device:</Text>
            <Select style={styles.select} label={devices[selectedDeviceIndex]}></Select>
          </View>
          <View style={styles.option}>
            <Text style={styles.label}>Color Mode:</Text>
            <Select style={styles.select} label={selectedColorMode} ></Select>
          </View>
          <View style={styles.buttons}>
            <View style={styles.buttonContainer}>
              <Button label="Scan" onPress={()=>scan()} />
            </View>
            <View style={styles.buttonContainer}>
              <Button style={styles.button} label="Share" onPress={()=>share()} />
            </View>
          </View>
          <View>
            <Button style={styles.button} label="History"/>
          </View>
        </View>
      </View>
    )
  }

  return (
    <SafeAreaProvider>
      <SafeAreaView style={styles.container}>
        {renderBody()}
        <StatusBar style="auto"/>
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  button:{
    marginBottom: 5,
  },
  buttons: {
    flexDirection:"row",
  },
  buttonContainer:{
    width:"50%",
  },
  home: {
    flex: 1,
    width: "100%",
    backgroundColor: '#25292e',
    alignItems: 'center',
  },
  footerContainer: {
    flex: 3 / 5,
    width: "100%",
  },
  option:{
    flexDirection:"row",
    alignItems:"center",
    marginHorizontal: 20,
    height: 40,
  },
  label:{
    flex: 3 / 7,
    color: 'white', 
    marginRight: 10,
  },
  select:{
    flex: 1,
  },
  imageContainer: {
    flex: 1,
    paddingTop: 20,
    alignItems:"center",
  },
  image: {
    width: 320,
    height: "95%",
    borderRadius: 18,
    resizeMode: "contain",
  },
});
Enter fullscreen mode Exit fullscreen mode

Two custom components are defined as well.

components/Button.js:

import { StyleSheet, View, Pressable, Text } from 'react-native';

export default function Button({ label, onPress }) {
  return (
    <View
      style={[styles.buttonContainer, { borderWidth: 3, borderColor: "#ffd33d", borderRadius: 18 }]}
      >
        <Pressable
          style={[styles.button, { backgroundColor: "#fff" }]}
          onPress={onPress}
        >
          <Text style={[styles.buttonLabel, { color: "#25292e" }]}>{label}</Text>
        </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  buttonContainer: {
    width: "auto",
    marginHorizontal: 10,
    height: 40,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 3,
    margin: 3,
  },
  button: {
    borderRadius: 10,
    width: '100%',
    height: '100%',
    alignItems: 'center',
    justifyContent: 'center',
    flexDirection: 'row',
  },
  buttonIcon: {
    paddingRight: 8,
  },
  buttonLabel: {
    color: '#fff',
    fontSize: 16,
  },
});
Enter fullscreen mode Exit fullscreen mode

components/Select.js:

import { StyleSheet, View, Pressable, Text } from 'react-native';

export default function Select({ label, onPress }) {
  return (
    <View
      style={[styles.container]}
      >
        <Pressable
          style={[styles.button]}
          onPress={onPress}
        >
          <Text ellipsizeMode="tail" numberOfLines={1} style={[styles.label]}>{label}</Text>
        </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex:1,
    borderWidth: 1, 
    borderColor: "white", 
    borderRadius: 10,
    height: 30,
    justifyContent:"center",
  },
  label:{
    marginLeft: 10,
    color: "white",
  }
});
Enter fullscreen mode Exit fullscreen mode

Define an Items Picker Component

Create a new component for picking an item. We can use it to pick which device to use, which color mode to use and which action to take, etc.

{% raw %}import { View, Text, Pressable, StyleSheet } from 'react-native';

export default function ItemsPicker({ items,onPress }) {
  return (
    <View style={styles.container}>
      <Text style={{color: "white"}}>Select an item:</Text>
      {items.map((item, idx) => (
        <Pressable key={idx} onPress={()=>onPress(item,idx)}>
          <Text style={styles.item}>{item}</Text>
        </Pressable>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex:1,
    paddingTop: 20,
    paddingLeft: 20,
    alignItems: 'flex-start',
    backgroundColor: 'black',
  },
  item: {
    color: "white",
    padding: 10,
    fontSize: 18,
    height: 44,
  },
});{% endraw %}
Enter fullscreen mode Exit fullscreen mode

Then, in App.js we can use the ItemsPicker to configure the scanning.

const [showDevicePicker,setShowDevicePicker] = useState(false);
const [showColorModePicker,setShowColorModePicker] = useState(false);
const renderBody = () => {
  if (showDevicePicker) {
    return (
      <ItemsPicker items={devices} onPress={(device,idx) => {
        console.log(device);
        setSelectedDeviceIndex(idx);
        setShowDevicePicker(false);
      }}></ItemsPicker>
    )
  }
  if (showColorModePicker) {
    return (
      <ItemsPicker items={colorModes} onPress={(mode) => {
        setSelectedColorMode(mode);
        setShowColorModePicker(false);
      }}></ItemsPicker>
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

Scan Documents from the Camera

Next, we are going the integrate scanning documents from the camera.

  1. Create a new component named DocumentScanner.js under components.

    import { StyleSheet, View } from 'react-native';
    import { useEffect,useState } from 'react';
    export default function DocumentScanner(props) {
        return <View/>
      }
    }
    
    const styles = StyleSheet.create({
    });
    
  2. Request camera permission with expo-camera when the component is mounted.

    const [hasPermission, setHasPermission] = useState(null);
    useEffect(() => {
      (async () => {
        const { status } = await Camera.requestCameraPermissionsAsync();
        setHasPermission(status === "granted");
      })();
    }, []);
    
  3. Use react-native-webview to load a document scanning web app built with Dynamsoft Camera Enhancer and Dynamsoft Document Normalizer if the camera permission is granted. The web app is built for use in react-native-webview. It can post the scanned document image as data URL to the react native app. We can pass URL params to configure its behaviors like the color mode and the license. A license is needed to use Dynamsoft Document Normalizer. You can apply for a license here.

    {----% raw %----}const getURI = () => {
      let URI = 'https://tony-xlh.github.io/Vanilla-JS-Document-Scanner-Demos/react-native/?autoStart=true';
      if (props.colorMode == "Black&White") {
        URI = URI + "&colorMode="+0;
      }else if (props.colorMode == "Gray"){
        URI = URI + "&colorMode="+1;
      }else{
        URI = URI + "&colorMode="+2;
      }
      if (props.license) {
        URI = URI + "&license="+props.license;
      }
      return URI;
    }
    if (hasPermission) {
      return (
        <WebView
          style={styles.webview}
          allowsInlineMediaPlayback={true}
          mediaPlaybackRequiresUserAction={false}
          onMessage={(event) => {
            if (!event.nativeEvent.data) {
              if (props.onClosed) {
                props.onClosed();
              }
            }else{
              if (props.onScanned) {
                const dataURL = event.nativeEvent.data;
                props.onScanned(dataURL);
              }
            }
          }}
          source={{ uri: getURI() }}
        />
      );
    }else{
      return <Text>No permission.</Text>
    }{----% endraw %----}
    
  4. Show the document scanner in the home page after the scan button is pressed and the selected device is camera. It will capture an image automatically if it detects three overlapped document regions consecutively. The image will be saved to the app's document directory and be displayed in the page.

    const path = useRef("");
    const [showScanner,setShowScanner] = useState(false);
    const onScanned = async (dataURL) => {
      const timestamp = new Date().getTime();
      path.current = FileSystem.documentDirectory + timestamp + ".png";
      const base64Code = removeDataURLHead(dataURL);
      await FileSystem.writeAsStringAsync(path.current, base64Code, {
        encoding: FileSystem.EncodingType.Base64,
      });
      setImage({uri: path.current});
      setShowScanner(false);
    }
    
    const removeDataURLHead = (dataURL) => {
      return dataURL.substring(dataURL.indexOf(",")+1,dataURL.length);
    }
    
    const renderBody = () => {
      if (showScanner) {
        return (
          <DocumentScanner 
            license="DLS2eyJoYW5kc2hha2VDb2RlIjoiMTAwMjI3NzYzLVRYbFhaV0pRY205cSIsIm1haW5TZXJ2ZXJVUkwiOiJodHRwczovL21sdHMuZHluYW1zb2Z0LmNvbSIsIm9yZ2FuaXphdGlvbklEIjoiMTAwMjI3NzYzIiwic3RhbmRieVNlcnZlclVSTCI6Imh0dHBzOi8vc2x0cy5keW5hbXNvZnQuY29tIiwiY2hlY2tDb2RlIjotMzg1NjA5MTcyfQ=="
            colorMode={selectedColorMode} 
            onScanned={(dataURL)=>onScanned(dataURL)}
          ></DocumentScanner>
        )
      }
    }
    

Screenshot:

camera

Here, we use react-native-webview to integrate the camera document scanning ability for ease of use as it can run in Expo Go. You can use the native module if you want to integrate the ability natively.

Scan Documents from Document Scanners

Next, we are going to scan documents from document scanners through Dynamsoft Service's REST API.

  1. Install Dynamsoft Service on a PC which is connected to document scanners and make it accessible in an intranet. You can configure its IP on the configuration page and you can find the download links here.
  2. Create a DynamsoftService class for getting the scanners list and acquiring images from scanners via the REST API.

    export class DynamsoftService {
      endpoint;
      license;
      constructor(endpoint,license) {
        this.endpoint = endpoint;
        this.license = license;
      }
    
      async getDevices(){
        const url = this.endpoint + "/DWTAPI/Scanners";
        const response = await fetch(url, {"method":"GET", "mode":"cors", "credentials":"include"});
        let scanners = await response.json();
        return scanners;
      }
    
      async acquireImage(device,pixelType){
        let url = this.endpoint + "/DWTAPI/ScanJobs";
        let scanParams = {
          license:this.license
        };
        if (device) {
          // optional. use the latest device.
          scanParams.device = device;
        }
        scanParams.config = {
          IfShowUI: false,
          Resolution: 200,
          IfFeederEnabled: false,
          IfDuplexEnabled: false,
        };
        scanParams.caps = {};
        scanParams.caps.exception = "ignore";
        scanParams.caps.capabilities = [
          {
            capability: 257, // pixel type
            curValue: pixelType
          }
        ]
        const response = await fetch(url, {"body": JSON.stringify(scanParams), "method":"POST", "mode":"cors", "credentials":"include"});
        if (response.status == 201)
        {
          curJobid = await response.text();
          return (await this.getImage(curJobid));
        }else{
          let message = await response.text();
          throw new Error(message);
        }
      }
    
      async getImage(jobid) {
        // get image.
        const url = this.endpoint + "/DWTAPI/ScanJobs/" + jobid + '/NextDocument';
        const response = await fetch(url, {"method":"GET", "mode":"cors", "credentials":"include"});
        if (response.status === 200)
        {
          const image = await response.blob();
          return this.blobToBase64(image);
        }
      }
    
      blobToBase64( blob ) {
        return new Promise((resolve, reject) => {
          const reader = new FileReader();
          reader.readAsDataURL(blob);
          reader.onloadend = () => {
            const uri = reader.result?.toString();
            resolve(uri);
          };  
        })
      }
    }
    
  3. In App.js, fetch the list of scanners via the REST API and add them to the device select. A license is needed to use it. You can apply for a license here.

    export default function App() {
      const service = useRef();
      const scanners = useRef();
      const [devices,setDevices] = useState(["Camera"]);
      useEffect(() => {
        service.current = new DynamsoftService("http://192.168.8.65:18622","LICENSE");
        fetchDevicesList();
      }, []);
      const fetchDevicesList = async () =>{
        scanners.current = await service.current.getDevices();
        let newDevices = ["Camera"];
        for (let index = 0; index < scanners.current.length; index++) {
          const scanner = scanners.current[index];
          newDevices.push(scanner.name);
        }
        setDevices(newDevices);
      }
    }
    
  4. Acquire a document image after the scan button is pressed.

    const scan = async () => {
      if (selectedDeviceIndex == 0) {
        setShowScanner(true);
      }else{
        setModalVisible(true);
        const selectedScanner = scanners.current[selectedDeviceIndex - 1];
        const pixelType = colorModes.indexOf(selectedColorMode);
        const image = await service.current.acquireImage(selectedScanner.device,pixelType);
        onScanned(image);
        setModalVisible(false);
      }
    }
    
  5. A modal will be shown during the scanning process.

    const [modalVisible, setModalVisible] = useState(false);
    return (
      <View style={styles.home}>
        <Modal
          transparent={true}
          visible={modalVisible}
          >
          <View style={styles.centeredView}>
            <View style={styles.modalView}>
              <Text>Scanning...</Text>
            </View>
          </View>
        </Modal>
      </View>
    )
    

Screenshot:

scanning

Manage History

Add a history browser component to manage scanned documents under components/HistoryBrowser.js.

{% raw %}import { Alert, StyleSheet, View, Text, FlatList, Image,Button,Pressable,Dimensions } from 'react-native';
import { useEffect,useState,useRef } from 'react';
import ItemsPicker from './ItemsPicker';
import * as Sharing from 'expo-sharing';
import * as FileSystem from 'expo-file-system';

const width = Dimensions.get('window').width;
const height = Dimensions.get('window').height;
const actions = ["Delete","Share","Get info","Cancel"];

export default function HistoryBrowser(props) {
  const [images,setImages] = useState([]);
  const [showActionPicker,setShowActionPicker] = useState(false);
  const pressedImageName = useRef("");
  useEffect(() => {
    console.log(width);
    console.log(height);
    readImagesList();
  }, []);

  const readImagesList = async () => {
    let newImages = [];
    const files = await FileSystem.readDirectoryAsync(FileSystem.documentDirectory);
    for (let index = 0; index < files.length; index++) {
      const file = files[index];
      if (file.toLowerCase().endsWith(".png")) {
        newImages.push(file);
      }
    }
    setImages(newImages);
  }

  const getURI = (filename) => {
    const uri = FileSystem.documentDirectory + filename;
    return uri;
  }

  const goBack = () => {
    if (props.onBack) {
      props.onBack();
    }
  }

  const deleteFile = async () => {
    if (pressedImageName.current != "") {
      setImages([]);
      const path = FileSystem.documentDirectory + pressedImageName.current;
      await FileSystem.deleteAsync(path);
      pressedImageName.current = "";
      readImagesList();
    }
  }

  const share = () => {
    if (pressedImageName.current != "") {
      const path = FileSystem.documentDirectory + pressedImageName.current;
      Sharing.shareAsync(path);
    }
  }

  const getInfo = async () => {
    if (pressedImageName.current != "") {
      const path = FileSystem.documentDirectory + pressedImageName.current;
      const info = await FileSystem.getInfoAsync(path);
      const time = new Date(info.modificationTime*1000);
      let message = "Time: " + time.toUTCString() + "\n";
      message = message + "Size: " + info.size/1000 + "KB";
      Alert.alert(pressedImageName.current,message);

    }

  }

  return (
    <View style={styles.container}>
      {showActionPicker && (
        <View style={styles.pickerContainer}>
          <ItemsPicker items={actions} onPress={(action) => {
            console.log(action);
            setShowActionPicker(false);
            if (action === "Delete") {
              deleteFile();
            }else if (action === "Share"){
              share();
            }else if (action === "Get info"){
              getInfo();
            }
          }}></ItemsPicker>
        </View>
      )
      }
      <View style={styles.backButtonContainer} >
        <Button title="< Back" onPress={goBack}></Button>  
      </View>
      <FlatList
        horizontal={true}
        style={styles.flat}
        data={images}
        renderItem={({item}) => 
          <Pressable onPress={()=>{
            pressedImageName.current = item;
            setShowActionPicker(true);
          }}>
            <Image style={styles.image} source={{
              uri: getURI(item),
            }}/>
          </Pressable>}
      />
    </View>
  )
}

const styles = StyleSheet.create({
  container:{
    flex: 1,
    backgroundColor: "#25292e",
    alignItems:"center",
  },
  image: {
    width: width*0.9,
    height: height*0.9,
    resizeMode: "contain",
  },
  pickerContainer:{
    position: "absolute",
    top: 0,
    left: 0,
    width: "100%",
    height: "100%",
    zIndex: 20,
  },
  backButtonContainer:{
    position:"absolute",
    top: 0,
    left: 0,
    zIndex: 10,
  }
});{% endraw %}
Enter fullscreen mode Exit fullscreen mode

Source Code

We've now completed the demo. Get the source code and have a try: https://github.com/tony-xlh/expo-document-scanner

Top comments (0)