How to Build a Document Scanner with Expo
xulihang
Posted on November 10, 2023
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.
SDKs Used
- Dynamsoft Document Normalizer which provides the ability to detect document boundaries and perform perspective correction.
- Dynamsoft Service from Dynamic Web TWAIN which provides REST APIs for accessing document scanners.
New Project
-
Create a new Expo project:
npx create-expo-app DocumentScanner
-
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"] }
-
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.
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
:
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
:
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.
{% 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.
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.
-
Create a new component named
DocumentScanner.js
undercomponents
.
import { StyleSheet, View } from 'react-native'; import { useEffect,useState } from 'react'; export default function DocumentScanner(props) { return <View/> } } const styles = StyleSheet.create({ });
-
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"); })(); }, []);
-
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 %----}
-
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:
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.
- 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.
-
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); }; }) } }
-
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); } }
-
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); } }
-
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:
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 %}
Source Code
We've now completed the demo. Get the source code and have a try: https://github.com/tony-xlh/expo-document-scanner
Posted on November 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.