How to Build a Document Scanner with Expo
Tags: reactnative, expo, javascript, tutorial
Published: 2023-11-10 02:53:09


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](https://user-images.githubusercontent.com/5462205/281339394-ff0c15f2-db86-4bd2-97b1-12645739e111.mp4)

## SDKs Used

* [Dynamsoft Document Normalizer](https://www.dynamsoft.com/document-normalizer/docs/introduction/) which provides the ability to detect document boundaries and perform perspective correction.
* Dynamsoft Service from [Dynamic Web TWAIN](https://www.dynamsoft.com/web-twain/overview) which provides [REST APIs](https://www.dynamsoft.com/blog/announcement/dynamsoft-service-restful-api/) for accessing document scanners.

## New Project

1. Create a new Expo project:

    ```bash
    npx create-expo-app DocumentScanner
    ```

2. Add camera permissions in `app.json`:

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

3. Add dependencies:

    ```bash
    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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lipwut10d8d9pkn8o4ht.jpg)

```jsx
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",
  },
});
```

Two custom components are defined as well.

`components/Button.js`:

```jsx
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,
  },
});
```

`components/Select.js`:

```jsx
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",
  }
});
```

## 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.

```jsx
{% 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 %}
```

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

```jsx
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>
    )
  }
}
```

## 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`.

    ```jsx
    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.

    ```jsx
    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](https://www.dynamsoft.com/customer/license/trialLicense?product=ddn).

    ```jsx
    {% 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.

    ```jsx
    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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5q5g4w5lktn8rkh2843k.jpg)
   
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](https://github.com/tony-xlh/vision-camera-dynamsoft-document-normalizer) 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](http://127.0.0.1:18625/) and you can find the download links [here](https://www.dynamsoft.com/codepool/java-twain-document-scanning.html#prerequisites).
2. Create a DynamsoftService class for getting the scanners list and acquiring images from scanners via the REST API.


    ```jsx
    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](https://www.dynamsoft.com/customer/license/trialLicense?product=dwt).

    ```jsx
    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.

    ```jsx
    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.


    ```jsx
    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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s23xwm0rb16x3s6qa50g.jpg)

## Manage History

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

```jsx
{% 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 %}
```

## Source Code

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