Ionic Document Scanner in React

In this article, we are going to create a mobile document scanning app using Ionic React. Ionic is a cross-platform framework for developing mobile apps with web technologies. An Ionic app can not only run as a web app in browsers but also run as a native app on Android and iOS.

The Dynamsoft Document Viewer SDK is used to provide a set of viewers for document scanning and the Dynamsoft Document Normalizer Capacitor plugin is used to detect the document boundaries.

Demo video:

An online demo running on netlify: link.

Build an Ionic Document Scanner

Let’s build the app in steps.

Overview of the App

It has several major interfaces:

  1. The home page which lists the scanned images.

    Home

    Actions

  2. The scanner where the document is detected and automatically captured.

    Scanner

  3. The cropper where we can adjust the detected polygon of the document.

    Cropper

  4. The editor where we can view and edit the scanned images.

    Editor

New project

First, install Ionic according to its guide.

After installation, create a new project:

ionic start documentScanner blank --type react

We can run ionic serve to start a test server.

Install Dependencies

  1. Install Dynamsoft Document Viewer

     npm install dynamsoft-document-viewer
    

    Update the scripts in package.json to copy the resources of Dynamsoft Document Viewer to the public folder. Remember to create the folder beforehand.

    1. Install ncp as a devDependency.

      npm install ncp --save-dev
      
    2. Update package.json to use ncp to copy the resources.

       "scripts": {
      -  "dev": "vite",
      -  "build": "tsc && vite build",
      +  "dev": "ncp node_modules/dynamsoft-document-viewer/dist public/assets/ddv-resources && vite",
      +  "build": "ncp node_modules/dynamsoft-document-viewer/dist public/assets/ddv-resources && tsc && vite build",
       }
      
  2. Install capacitor-plugin-camera to open the camera.

    npm install capacitor-plugin-camera
    
  3. Install capacitor-plugin-dynamsoft-document-normalizer to detect the documents.

    npm install capacitor-plugin-dynamsoft-document-normalizer
    
  4. Install @capacitor/filesystem and @capacitor/share to save the scanned document.

    npm install @capacitor/filesystem @capacitor/share
    

Create Components using Dynamsoft Document Viewer

Create DocumentBrowser.tsx, DocumentCropper.tsx and DocumentEditor.tsx respectively. They have a similar initialization login: create an instace of the viewer, bind it to a container, update its UI configuration and register related events.

They all have the docUid and groupUid props. We can specify them to open the document and sync changes across the viewers.

  1. DocumentBrowser.tsx:

    import { useEffect, useRef } from 'react'
    import { DDV, BrowseViewer, UiConfig } from 'dynamsoft-document-viewer';
    import "dynamsoft-document-viewer/dist/ddv.css";
    import "./DocumentBrowser.css";
    
    export interface DocumentBrowserProps {
      docUid:string;
      groupUid:string;
      show:boolean;
    }
    
    const DocumentBrowser: React.FC<DocumentBrowserProps> = (props:DocumentBrowserProps) => {
      const initializing = useRef(false);
      const browseViewer = useRef<BrowseViewer|undefined>();
      useEffect(()=>{
        if (initializing.current == false) {
          initializing.current = true;
          initBrowseViewer();
        }
        browseViewer.current?.show();
        return ()=>{
          if (browseViewer.current) {
            browseViewer.current.hide();
          }
        }
      },[])
    
      useEffect(() => {
        if (browseViewer.current) {
          if (props.show) {
            browseViewer.current.show();
          }else{
            browseViewer.current.hide();
          }
          window.dispatchEvent(new Event('resize'));
        }
      }, [props.show]);
    
      const initBrowseViewer = async () => {    
        browseViewer.current = new DDV.BrowseViewer({
          groupUid: props.groupUid,
          container: "browseViewer"
        });
        browseViewer.current.openDocument(props.docUid);
      }
    
      return (
        <div id="browseViewer"></div>
      )
    };
    
    export default DocumentBrowser;
    
  2. DocumentCropper.tsx:

    import { useEffect, useRef } from 'react'
    import { DDV, PerspectiveViewer, UiConfig } from 'dynamsoft-document-viewer';
    import "dynamsoft-document-viewer/dist/ddv.css";
    import "./DocumentCropper.css";
    
    export interface DocumentCropperProps {
      docUid:string;
      groupUid:string;
      show:boolean;
      onInitialized?: (perspectiveViewer:PerspectiveViewer) => void;
      onBack?: () => void;
    }
    
    const DocumentCropper: React.FC<DocumentCropperProps> = (props:DocumentCropperProps) => {
      const initializing = useRef(false);
      const perspectiveViewer = useRef<PerspectiveViewer|undefined>();
      useEffect(()=>{
        if (initializing.current == false) {
          initializing.current = true;
          initPerspectiveViewer();
        }
        perspectiveViewer.current?.show();
        return ()=>{
          if (perspectiveViewer.current) {
            perspectiveViewer.current.hide();
          }
        }
      },[])
    
      useEffect(() => {
        if (perspectiveViewer.current) {
          if (props.show) {
            perspectiveViewer.current.show();
          }else{
            perspectiveViewer.current.hide();
          }
          window.dispatchEvent(new Event('resize'));
        }
      }, [props.show]);
    
      const initPerspectiveViewer = async () => {    
        const uiConfig:UiConfig = {
            type: DDV.Elements.Layout,
            flexDirection: "column",
            children: [
                {
                    type: DDV.Elements.Layout,
                    className: "ddv-perspective-viewer-header-mobile",
                    children: [
                        {
                            type: DDV.Elements.Button,
                            className: "ddv-button-back",
                            events:{
                                click: "back"
                            }
                        },
                        DDV.Elements.Pagination,
                        {   
                            type: DDV.Elements.Button,
                            className: "ddv-button-done",
                            events:{
                                click: "apply"
                            }
                        },
                    ],
                },
                DDV.Elements.MainView,
                {
                    type: DDV.Elements.Layout,
                    className: "ddv-perspective-viewer-footer-mobile",
                    children: [
                        DDV.Elements.FullQuad,
                        DDV.Elements.RotateLeft,
                        DDV.Elements.RotateRight,
                        DDV.Elements.DeleteCurrent,
                        DDV.Elements.DeleteAll,
                    ],
                },
            ],
        };
        perspectiveViewer.current = new DDV.PerspectiveViewer({
          uiConfig: uiConfig,
          groupUid: props.groupUid,
          container: "perspectiveViewer"
        });
        perspectiveViewer.current.on("back" as any,() => {
          if (props.onBack) {
            props.onBack();
          }
        });
        perspectiveViewer.current.on("apply" as any,() => {
          let quad = perspectiveViewer.current?.getQuadSelection();
          if (quad) {
            perspectiveViewer.current?.applyPerspective(quad);
          }
          if (props.onBack) {
            props.onBack();
          }
        });
        perspectiveViewer.current.openDocument(props.docUid);
        perspectiveViewer.current.show();
        if (props.onInitialized) {
          props.onInitialized(perspectiveViewer.current);
        }
      }
    
      return (
        <div id="perspectiveViewer"></div>
      )
    };
    
    export default DocumentCropper;
    
  3. DocumentEditor.tsx:

    import { useEffect, useRef, useState } from 'react'
    import { DDV, EditViewer, UiConfig } from 'dynamsoft-document-viewer';
    import "dynamsoft-document-viewer/dist/ddv.css";
    import "./DocumentEditor.css";
    
    export interface DocumentEditorProps {
      docUid:string;
      groupUid:string;
      show:boolean;
      onBack?: () => void;
      onScanRequired?: () => void;
      onInitialized?: (editViewer:EditViewer) => void;
    }
    
    const DocumentEditor: React.FC<DocumentEditorProps> = (props:DocumentEditorProps) => {
      const initializing = useRef(false);
      const editViewer = useRef<EditViewer|undefined>();
      useEffect(()=>{
        if (initializing.current == false) {
          initializing.current = true;
          initEditViewer();
        }
        editViewer.current?.show();
        return ()=>{
          if (editViewer.current) {
            editViewer.current.hide();
          }
        }
      },[])
    
      useEffect(() => {
        if (editViewer.current) {
          if (props.show) {
            editViewer.current.show();
          }else{
            editViewer.current.hide();
          }
          window.dispatchEvent(new Event('resize'));
        }
      }, [props.show]);
    
      const initEditViewer = async () => {    
        const config:UiConfig = {
            type: DDV.Elements.Layout,
            flexDirection: "column",
            className: "ddv-edit-viewer-mobile",
            children: [
                {
                    type: DDV.Elements.Layout,
                    className: "ddv-edit-viewer-header-mobile",
                    children: [
                        {
                            // Add a "Back" buttom to header and bind click event to go back to the perspective viewer
                            // The event will be registered later.
                            type: DDV.Elements.Button,
                            className: "ddv-button-back",
                            events:{
                                click: "back"
                            }
                        },
                        DDV.Elements.Pagination,
                        {
                          // Add a "Back" buttom to header and bind click event to go back to the perspective viewer
                          // The event will be registered later.
                          type: DDV.Elements.Button,
                          className: "camera-icon",
                          events:{
                              click: "scan"
                          }
                      },
                    ],
                },
                DDV.Elements.MainView,
                {
                    type: DDV.Elements.Layout,
                    className: "ddv-edit-viewer-footer-mobile",
                    children: [
                        DDV.Elements.DisplayMode,
                        DDV.Elements.RotateLeft,
                        DDV.Elements.Crop,
                        DDV.Elements.Filter,
                        DDV.Elements.Undo,
                        DDV.Elements.Delete,
                        DDV.Elements.Load,
                    ],
                },
            ],
        };
        // Create an edit viewer
        editViewer.current = new DDV.EditViewer({
          container: "editViewer",
          groupUid: props.groupUid,
          uiConfig: config,
        });
        editViewer.current.on("back" as any,() => {
          if (props.onBack) {
            props.onBack();
          }
        });
        editViewer.current.on("scan" as any,() => {
          if (props.onScanRequired) {
            props.onScanRequired();
          }
        });
        editViewer.current.openDocument(props.docUid);
      }
    
      return (
        <div id="editViewer"></div>
      )
    };
    
    export default DocumentEditor;
    

Use the Viewers in the Home Page

  1. Initialize Dynamsoft Document Viewer with a license when it is mounted. You can apply for a license here.

    const [initialized,setInitialized] = useState(false);
    const initializing = useRef(false);
    useEffect(()=>{
      const init = async () => {
        try {
          DDV.Core.license = "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ=="; // Public trial license which is valid for 24 hours
          DDV.Core.engineResourcePath = "assets/ddv-resources/engine";// Lead to a folder containing the distributed WASM files
          await DDV.Core.loadWasm();
          await DDV.Core.init(); 
          // Configure image filter feature which is in edit viewer
          DDV.setProcessingHandler("imageFilter", new DDV.ImageFilter());
          doc.current = DDV.documentManager.createDocument(); // Create a document instance
          setInitialized(true);
        } catch (error) {
          alert(error);
        }
      }
      if (initializing.current === false) {
        initializing.current = true;
        init();
      }
    },[])
    
  2. Display the viewers based on the choice.

    const [mode,setMode] = useState<"browse"|"edit"|"crop">("browse");
    const renderViewers = () => {
      if (initialized && doc.current) {
        let uid = doc.current.uid;
        let displayEditor = false;
        let displayCropper = false;
        let displayBrowser = false;
        if (mode === "browse") {
          displayBrowser = true;
        }else if (mode === "crop") {
          displayCropper = true;
        }else if (mode === "edit") {
          displayEditor = true;
        }
        return (
          <>
            <div className={"editor fullscreen" + ((displayEditor && !scanning)?"":" hidden")}>
              <DocumentEditor 
                docUid={uid} 
                show={displayEditor} 
                groupUid={groupUid.current}
                onScanRequired={()=>{
                  stayInEditViewer.current = true;
                  startScanning();
                }}
                onBack={()=>{returnToPrevious(true)}}>
              </DocumentEditor>
            </div>
            <div className={"cropper fullscreen" + (displayCropper?"":" hidden")}>
              <DocumentCropper 
                docUid={uid}
                groupUid={groupUid.current}
                show={displayCropper} 
                onBack={returnToPrevious} 
                onInitialized={(viewer:PerspectiveViewer)=>{perspectiveViewer.current = viewer;}}>
              </DocumentCropper>
            </div>
            <div className={"browser" + ((displayBrowser && !scanning)?"":" hidden")}>
              <DocumentBrowser 
                docUid={uid} 
                groupUid={groupUid.current}
                show={displayBrowser}>
              </DocumentBrowser>
            </div>
          </>
        )
      }
    }
    return (
        <IonPage>
          {displayHeader &&
            <>
              <IonHeader>
                <IonToolbar>
                  <IonTitle>Document Scanner</IonTitle>
                  <IonButtons slot="primary">
                    <IonButton onClick={()=>{setIsOpen(true)}}>
                      <IonIcon slot="icon-only" ios={ellipsisHorizontal} md={ellipsisVertical}></IonIcon>
                    </IonButton>
                  </IonButtons>
                </IonToolbar>
              </IonHeader>
            </>
          }
          <IonContent fullscreen>
            <IonLoading isOpen={!initialized} message="Loading..." />
            {renderViewers()}
            <IonActionSheet
              isOpen={isOpen}
              header="Actions"
              buttons={[
                {
                  text: 'Edit',
                  data: {
                    action: 'edit',
                  },
                },
                {
                  text: 'Crop',
                  data: {
                    action: 'crop',
                  },
                },
                {
                  text: 'Download as PDF',
                  data: {
                    action: 'download',
                  },
                },
                {
                  text: 'Cancel',
                  role: 'cancel',
                  data: {
                    action: 'cancel',
                  },
                },
              ]}
              onDidDismiss={({ detail }) => handleAction(detail)}
            ></IonActionSheet>
          </IonContent>
        </IonPage>
      );
    };
    
  3. An action sheet is used to perform actions. Related code:

    const handleAction = (detail:OverlayEventDetail) => {
      if (detail.data && detail.data.action != "cancel") {
        if (detail.data.action === "download") {
          downloadAsPDF();
        }else{
          stayInEditViewer.current = false;
          setDisplayHeader(false);
          setMode(detail.data.action);
        }
      }
      setIsOpen(false);
    }
    
    const downloadAsPDF = async () => {
      if (doc.current) {
        let blob = await doc.current.saveToPdf();
        if (Capacitor.isNativePlatform()) {
          let fileName = "scanned.pdf";
          let dataURL = await blobToDataURL(blob);
          let writingResult = await Filesystem.writeFile({
            path: fileName,
            data: dataURL,
            directory: Directory.Cache
          });
          Share.share({
            title: fileName,
            text: fileName,
            url: writingResult.uri,
          });
        }else{
          const imageURL = URL.createObjectURL(blob)
          const link = document.createElement('a')
          link.href = imageURL;
          link.download = 'scanned.pdf';
          document.body.appendChild(link);
          link.click();
          document.body.removeChild(link);
        }
      }
    }
    
    const blobToDataURL = (blob:Blob):Promise<string> => {
      return new Promise<string>((resolve) => {
        var reader = new FileReader();
        reader.onload = function(e) {
          resolve(e.target!.result as string);
        };
        reader.readAsDataURL(blob);
      })
    }
    

Create a Document Scanner Component

Next, let’s create a document scanner component (DocumentScanner.tsx) to open the camera, detect the document and take a photo.

import { MutableRefObject, useEffect, useRef, useState } from 'react';
import './DocumentScanner.css';
import { DocumentNormalizer, intersectionOverUnion } from 'capacitor-plugin-dynamsoft-document-normalizer';
import { CameraPreview } from 'capacitor-plugin-camera';
import { Capacitor, PluginListenerHandle } from '@capacitor/core';
import { DetectedQuadResultItem } from 'dynamsoft-document-normalizer'
import { IonFab, IonFabButton, IonIcon, IonFabList, IonLoading } from '@ionic/react';
import {
  chevronUpCircle,
  flashlight,
  stop,
  cameraReverse,
} from 'ionicons/icons';
import SVGOverlay from './SVGOverlay';

export interface DocumentScannerProps {
  onScanned?: (blob:Blob,detectionResults:DetectedQuadResultItem[]) => void;
  onStopped?: () => void;
}

const DocumentScanner: React.FC<DocumentScannerProps> = (props:DocumentScannerProps) => {
  const container:MutableRefObject<HTMLDivElement|null> = useRef(null);
  const torchOn = useRef(false);
  const detecting = useRef(false);
  const previousResults = useRef<DetectedQuadResultItem[]>([])
  const interval = useRef<any>();
  const onPlayedListener = useRef<PluginListenerHandle|undefined>();
  const [initialized,setInitialized] = useState(false);
  const initializedRef = useRef(false);
  const initializing = useRef(false);
  const [quadResultItem,setQuadResultItem] = useState<DetectedQuadResultItem|undefined>()
  const [viewBox,setViewBox] = useState("0 0 720 1280");
  useEffect(() => {
    const init = async () => {
      try {
        if (container.current && Capacitor.isNativePlatform() === false) {
          await CameraPreview.setElement(container.current);
        }
        await CameraPreview.initialize();
        await CameraPreview.requestCameraPermission();
        await DocumentNormalizer.initialize();
        if (onPlayedListener.current) {
          onPlayedListener.current.remove();
        }
        onPlayedListener.current = await CameraPreview.addListener("onPlayed", async () => {
          console.log("played");
          await updateViewBox();
          startScanning();
        });
        await CameraPreview.startCamera();
      } catch (error) {
        alert(error);
      }
      initializedRef.current = true;
      setInitialized(true);
    }
    
    if (initializing.current === false) {
      initializing.current = true;
      init();
    }
    
    return ()=> {
      console.log("unmount and stop scan");
      stopCamera(false);
    }
  }, []);

  const stopCamera = async (manual:boolean) => {
    if (onPlayedListener.current) {
      onPlayedListener.current.remove();
    }
    stopScanning();
    if (initializedRef.current || Capacitor.isNativePlatform()) {
      console.log("stop camera");
      await CameraPreview.stopCamera();
    }
    if (props.onStopped && manual) {
      props.onStopped();
    }
  }

  const startScanning = () => {
    stopScanning();
    if (!interval.current) {
      interval.current = setInterval(captureAndDetect,100);
    }
  }
  
  const stopScanning = () => {
    clearInterval(interval.current);
    interval.current = null;
  }
  
  const captureAndDetect = async () => {
    if (detecting.current === true) {
      return;
    }
    let results:DetectedQuadResultItem[] = [];
    detecting.current = true;
    try {
      if (Capacitor.isNativePlatform()) {
        await CameraPreview.saveFrame();
        results = (await DocumentNormalizer.detectBitmap({})).results;
      }else{
        if (container.current) {
          let video = container.current.getElementsByTagName("video")[0] as any;
          let response = await DocumentNormalizer.detect({source:video});
          results = response.results;
        }
      }
      if (results.length>0) {
        setQuadResultItem(results[0]);
        checkIfSteady(results);
      }else{
        setQuadResultItem(undefined);
      }
    } catch (error) {
      console.log(error);
    }
    detecting.current = false;
  }

  const takePhotoAndStop = async () => {
    stopScanning();
    let blob:Blob|undefined;
    let detectionResults:DetectedQuadResultItem[] = [];
    if (Capacitor.isNativePlatform()) {
      let photo = await CameraPreview.takePhoto({includeBase64:true});
      blob = await getBlobFromBase64(photo.base64!);
      detectionResults = (await DocumentNormalizer.detect({path:photo.path})).results;
      console.log(detectionResults);
    }else{
      let photo = await CameraPreview.takePhoto({});
      console.log(photo);
      if (photo.blob) {
        blob = photo.blob;
      }else if (photo.base64) {
        blob = await getBlobFromBase64(photo.base64);
      }
      let img = await loadBlobAsImage(blob!);
      console.log(img);
      detectionResults = (await DocumentNormalizer.detect({source:img})).results;
    }
    //await stopCamera(false);
    if (props.onScanned && blob && detectionResults) {
      props.onScanned(blob,detectionResults);
    }
  }

  const getBlobFromBase64 = async (base64:string):Promise<Blob> => {
    if (!base64.startsWith("data")) {
      base64 = "data:image/jpeg;base64," + base64;
    }
    const response = await fetch(base64);
    const blob = await response.blob();
    return blob;
  }

  const loadBlobAsImage = (blob:Blob):Promise<HTMLImageElement> => {
    return new Promise((resolve) => {
      let img = document.createElement("img");
      img.onload = function(){
        resolve(img);
      };
      img.src = URL.createObjectURL(blob);
    });
  }

  const checkIfSteady = (results:DetectedQuadResultItem[]) => {
    console.log(results);
    if (results.length>0) {
      let result = results[0];
      if (previousResults.current.length >= 3) {
        if (steady() == true) {
          console.log("steady");
          takePhotoAndStop();
        }else{
          console.log("shift and add result");
          previousResults.current.shift();
          previousResults.current.push(result);
        }
      }else{
        console.log("add result");
        previousResults.current.push(result);
      }
    }
  }

  const steady = () => {
    if (previousResults.current[0] && previousResults.current[1] && previousResults.current[2]) {
      let iou1 = intersectionOverUnion(previousResults.current[0].location.points,previousResults.current[1].location.points);
      let iou2 = intersectionOverUnion(previousResults.current[1].location.points,previousResults.current[2].location.points);
      let iou3 = intersectionOverUnion(previousResults.current[2].location.points,previousResults.current[0].location.points);
      if (iou1>0.9 && iou2>0.9 && iou3>0.9) {
        return true;
      }else{
        return false;
      }
    }
    return false;
  }
  


  const switchCamera = async () => {
    let currentCamera = (await CameraPreview.getSelectedCamera()).selectedCamera;
    let result = await CameraPreview.getAllCameras();
    let cameras = result.cameras;
    let currentCameraIndex = cameras.indexOf(currentCamera);
    let desiredIndex = 0
    if (currentCameraIndex < cameras.length - 1) {
      desiredIndex = currentCameraIndex + 1;
    }
    await CameraPreview.selectCamera({cameraID:cameras[desiredIndex]});
  }

  const toggleTorch = () => {
    if (initialized) {
      torchOn.current = !torchOn.current;
      CameraPreview.toggleTorch({on:torchOn.current});
    }
  }

  const updateViewBox = async () => {
    let res = (await CameraPreview.getResolution()).resolution;
    let width = parseInt(res.split("x")[0]);
    let height = parseInt(res.split("x")[1]);
    let orientation = (await CameraPreview.getOrientation()).orientation;
    let box:string;
    if (orientation === "PORTRAIT") {
      if (!Capacitor.isNativePlatform()) {
        box = "0 0 "+width+" "+height;
      }else{
        box = "0 0 "+height+" "+width;
      }
    }else{
      box = "0 0 "+width+" "+height;
    }
    console.log(box);
    setViewBox(box);
  }
  
  return (
    <div className="container" ref={container}>
      <IonLoading isOpen={!initialized} message="Loading..." />
      <div className="dce-video-container"></div>
      {quadResultItem &&
        <SVGOverlay viewBox={viewBox} quad={quadResultItem}></SVGOverlay>
      }
      <IonFab slot="fixed" vertical="bottom" horizontal="end">
          <IonFabButton>
            <IonIcon icon={chevronUpCircle}></IonIcon>
          </IonFabButton>
          <IonFabList side="top">
            <IonFabButton onClick={()=>{stopCamera(true)}}>
              <IonIcon icon={stop}></IonIcon>
            </IonFabButton>
            <IonFabButton onClick={switchCamera}>
              <IonIcon icon={cameraReverse}></IonIcon>
            </IonFabButton>
            <IonFabButton onClick={toggleTorch}>
              <IonIcon icon={flashlight}></IonIcon>
            </IonFabButton>
          </IonFabList>
        </IonFab>
    </div>
  );
};

export default DocumentScanner;

An SVG overlay component is used to highlight the detected document:

import React from 'react';
import { DetectedQuadResultItem } from 'dynamsoft-document-normalizer'
import './SVGOverlay.css';
import { Capacitor } from '@capacitor/core';

export interface OverlayProps {
  quad:DetectedQuadResultItem;
  viewBox:string;
}

const SVGOverlay = (props:OverlayProps): React.ReactElement => {
  const getPointsData = () => {
    let points = props.quad.location.points;
    let pointsData = points[0].x+","+ points[0].y + " ";
    pointsData = pointsData+ points[1].x +"," + points[1].y + " ";
    pointsData = pointsData+ points[2].x +"," + points[2].y + " ";
    pointsData = pointsData+ points[3].x +"," + points[3].y;
    return pointsData;
  }
  
  return (
    <svg 
      id="overlay"
      className={Capacitor.isNativePlatform() ? "fixed" : "absolute"}
      preserveAspectRatio="xMidYMid slice"
      viewBox={props.viewBox}
      xmlns="<http://www.w3.org/2000/svg>">
        <polygon xmlns="<http://www.w3.org/2000/svg>"
          points={getPointsData()}
        />
    </svg>
  )
}

export default SVGOverlay;

Start Document Scanning in the Home Page

  1. Initialize Document Normalizer’s license when the page is mounted.

    await DocumentNormalizer.initLicense({license:"DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ=="});  
    
  2. Add the following in Home.tsx’s JSX to add the document scanner component.

    {scanning &&
      <div className="scanner fullscreen">
        <DocumentScanner onStopped={stopScanning} onScanned={onScanned} ></DocumentScanner>
      </div>
    }
    {!scanning &&
      <div className="footer">
        <button className="shutter-button round" onClick={startScanning}>Scan</button>
      </div>
    }
    
  3. Code to control the scanning. We have to set the background to transparent so that the webview will not be blocked by the camera view.

    const startScanning = () => {
      document.documentElement.style.setProperty('--ion-background-color', 'transparent');
      setDisplayHeader(false);
      setScanning(true);
    }
    
    const stopScanning = () => {
      document.documentElement.style.setProperty('--ion-background-color', ionBackground.current);
      if (stayInEditViewer.current === false) {
        setDisplayHeader(true);
      }
      setScanning(false);
    }
    
    const onScanned = async (blob:Blob,detectionResults:DetectedQuadResultItem[]) => {
      stopScanning();
      await doc.current?.loadSource(blob);
      showCropper(detectionResults); //use the cropper to confirm the detected document
    }
    
    const showCropper = (detectionResults?:DetectedQuadResultItem[]) => {
      setDisplayHeader(false);
      setMode("crop");
      console.log(detectionResults);
      if (detectionResults && perspectiveViewer.current && doc.current) {
        if (detectionResults.length>0) {
          let result = detectionResults[0];
          let points = result.location.points;
          let quad:Quad = [
            [points[0].x,points[0].y],
            [points[1].x,points[1].y],
            [points[2].x,points[2].y],
            [points[3].x,points[3].y]
          ];
          perspectiveViewer.current.goToPage(doc.current.pages.length - 1)
          perspectiveViewer.current.setQuadSelection(quad);
        }
      }
    }
    

All right, we’ve now completed the demo.

Turn the App to an Android or iOS App

With Capacitor, we can turn the app into an Android or iOS app.

  1. Add platforms.

    ionic cap add android
    ionic cap add ios
    
  2. Sync files to native projects.

    ionic cap sync
    
  3. Run the app.

    ionic cap run android
    ionic cap run ios
    

Native Quirks

There are some native quirks we have to handle.

  1. Camera permission.

    For Android, add the following to AndroidManifest.xml.

    <uses-permission android:name="android.permission.CAMERA" />
    

    For iOS, add the following to Info.plist.

    <key>NSCameraUsageDescription</key>
    <string>For document scanning</string>
    
  2. Safe area.

    The fullscreen scanner and cropper may be blocked by the status bar. We can set their top position to env(safe-area-inset-top).

    .fullscreen {
      position: absolute;
      left:0;
      top:0;
      top: env(safe-area-inset-top);
      width: 100%;
      height: 100%;
      height: calc(100% - env(safe-area-inset-top));
    }
    

Source Code

Get the source code of the demo to have a try:

https://github.com/xulihang/Ionic-React-Document-Scanner