How to Scan and Crop Multiple Documents on a Flatbed

Dynamic Web TWAIN is an SDK to enable scanning documents from browsers and Dynamsoft Document Normalizer is an SDK to detect document borders and perform perspective transformation. The two can be used together so that we can automatically crop and straighten documents. A use case is to use a flatbed scanner to get the images of multiple documents in one scan.

In this article, we are going to build a web app in steps to show how to use the two SDKs to scan and crop documents.

Online demo

Build a Document Scanning Web App

  1. Create a new HTML file with the following template:

    <!DOCTYPE html>
    <html>
    <head>
      <title>Scan and Crop Documents</title>
      <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0" />
      <style>
      </style>
    </head>
    <body>
      <script type="text/javascript">
      </script>
    </body>
    </html>
    
  2. Build the layout. The app has a navigation bar, a left panel to select scanning options and perform scanning, a right panel to view the scanned documents with a lower actions panel to perform actions like editing, cropping and saving.

    Layout

    HTML:

    <nav class="navbar">
      <a class="title" href="#">Scan and Crop</a>
    </nav>
    <div class="container">
      <div class="scanner">
        <div class="options">
          <div class="section">
            <div>
              <label>
                Scanner:
                <select id="select-scanner"></select>
              </label>
            </div>
            <div>
              <label>
                Resolution:
                <select id="select-resolution">
                  <option value="100">100</option>
                  <option value="200">200</option>
                  <option value="300" selected>300</option>
                </select>
              </label>
            </div>
            <div>
              <label>
                Pixel Type:
                <select id="select-pixeltype">
                  <option>Black & White</option>
                  <option>Gray</option>
                  <option selected>Color</option>
                </select>
              </label>
            </div>
            <div>
              <input type="checkbox" id="showUI"/>
              <label for="showUI">
                Show Scanner UI
              </label>
            </div>
            <div>
              <input type="checkbox" id="useADF"/>
              <label for="useADF">
                Auto Document Feeder
              </label>
            </div>
            <div>
              <input type="checkbox" id="useDuplex"/>
              <label for="useDuplex">
                2-side Scan
              </label>
            </div>
            <div>
              <a onclick="scan();" class="d-primary-btn fullwidth" style="padding-top:5px;padding-bottom:5px;">Scan</a>
              <a onclick="loadImages();" class="d-secondary-btn fullwidth" style="margin-top:5px;">Import Local Image</a>
            </div>
            <div style="margin-top:2em;">
              Based on <a target="_blank" href="https://www.dynamsoft.com/web-twain/overview">Dynamic Web TWAIN</a>.
            </div>
          </div>
        </div>
        <div class="viewer">
          <div class="section" >
            <div id="dwtcontrolContainer"></div>
            <div class="actions">
              <a class="d-primary-btn" onclick="showEditor();">Edit</a>
              <a class="d-primary-btn" onclick="showCropper();">Crop</a>
              <a class="d-primary-btn" onclick="save();">Save</a>
              <label>
                Output Format:
                <select id="select-format">
                  <option>PDF</option>
                  <option>JPG</option>
                  <option>PNG</option>
                </select>
              </label>
            </div>
          </div>
        </div>
      </div>
    </div>
    

    CSS:

    .scanner {
      display: flex;
    }
    
    .options {
      flex-basis: 30%;
    }
    
    .viewer {
      flex-basis: 70%;
    }
       
    .navbar {
      display: flex;
      align-items: center;
      height: 50px;
      background: black;
      width: 100%;
    }
    
    body {
      margin: 0;
    }
    
    .fullwidth {
      width: 100%;
    }
    
    .title {
      margin-left: 8px;
      text-decoration: none;
      color: white;
      font-family: sans-serif;
      font-size: larger;
      text-transform: uppercase;
    }
    
    .options {
      background: #F0EDE9;
    }
    
    .scanner {
      height: calc(100vh - 50px);
    }
    
    .section {
      padding: 8px;
    }
    
    .viewer .section {
      height: calc(100% - 16px);
    }
    
    .options select {
      width: 100%;
      height: 30px;
    }
    
    .options div {
      margin-bottom: 10px;
    }
    
    .d-primary-btn {
      display: inline-block;
      background-color: #fe8e14;
      color: #fff;
      text-align: center;
      cursor: pointer;
      transition: ease-in .2s all;
      font-family: "sans-serif"
    }
    
    .d-primary-btn:hover {
      box-shadow:-4px 4px #000;
      transform: translate(4px,-4px)
    }
    
    .d-secondary-btn {
      display: inline-block;
      background-color: transparent;
      color: #fe8e14;
      text-align: center;
      cursor: pointer;
      font-family: "sans-serif"
    }
    
    .d-secondary-btn:hover {
      color: #fea543;
    }
    
    .actions {
      display: flex;
      justify-content: flex-start;
      align-items: center;
      height: 40px;
    }
    
    .actions .d-primary-btn {
      padding: 5px 10px;
      margin-right: 5px;
    }
    
    #dwtcontrolContainer {
      height: calc(100% - 40px);
    }
    
    @media screen and (max-device-width: 600px){
      .scanner {
        flex-wrap: wrap;
      }
      .options {
        flex-basis: 100%;
      }
      .viewer {
        flex-basis: 100%;
      }
    
      .scanner {
        height: auto;
      }
    
      #dwtcontrolContainer {
        height: 400px;
      }
    }
    
  3. Include the library of Dynamic Web TWAIN in the head.

    <script src="https://unpkg.com/dwt@18.3.0/dist/dynamsoft.webtwain.min.js"></script>
    
  4. Initialize Dynamic Web TWAIN and bind its viewer to #dwtcontrolContainer. You need a license to use Dynamic Web TWAIN. You can apply for a license here.

    let DWObject;
    Dynamsoft.DWT.AutoLoad = false;
    Dynamsoft.DWT.Containers = [];
    Dynamsoft.DWT.ResourcesPath = "https://unpkg.com/dwt@18.3.0/dist";
    Dynamsoft.DWT.ProductKey = "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ=="; //one-day trial
    init();
    
    function init(){
      Dynamsoft.DWT.CreateDWTObjectEx(
        {
          WebTwainId: 'dwtcontrol'
        },
        function(obj) {
          DWObject = obj;
          DWObject.Viewer.bind(document.getElementById('dwtcontrolContainer'));
          DWObject.Viewer.height = "100%";
          DWObject.Viewer.width = "100%";
          DWObject.Viewer.show();
          DWObject.Viewer.setViewMode(2,2);
        },
        function(err) {
          console.log(err);
        }
      );
    }
    
  5. Load scanners to the select after Web TWAIN is initialized.

    async function loadScanners(){
      scanners = await DWObject.GetDevicesAsync();
      console.log(scanners);
      let selScanners = document.getElementById("select-scanner");
      selScanners.innerHTML = "";
      for (let index = 0; index < scanners.length; index++) {
        const scanner = scanners[index];
        let option = new Option(scanner.displayName,index);
        selScanners.appendChild(option);
      }
    }
    
  6. Perform document scanning after the Scan button is pressed.

    async function scan(){
      const selectedIndex = document.getElementById("select-scanner").selectedIndex;
      const options = {
        IfShowUI:document.getElementById("showUI").checked,
        PixelType:document.getElementById("select-pixeltype").selectedIndex,
        Resolution:document.getElementById("select-resolution").selectedOptions[0].value,
        IfFeederEnabled:document.getElementById("useADF").checked,
        IfDuplexEnabled:document.getElementById("useDuplex").checked
      };
      await DWObject.SelectDeviceAsync(scanners[selectedIndex]);
      await DWObject.OpenSourceAsync();
      await DWObject.AcquireImageAsync(options);
      await DWObject.CloseSourceAsync();
    }
    
  7. Show the image editor if the Edit button is clicked. An image editor can perform actions like deleting, rotating and deskewing on images.

    function showEditor(){
      let imageEditor = DWObject.Viewer.createImageEditor();
      imageEditor.show();
    }
    
  8. Save the scanned document images to the desired format if the Save button is clicked.

    function save(){
      let selectedIndex = document.getElementById("select-format").selectedIndex;
      const onSuccess = () => {
        alert("Saved");
      };
      const onError = (errorCode,errorString) => {
        alert(errorString);
      };
      if (selectedIndex === 0) {
        DWObject.SaveAllAsPDF("Scanned.pdf",onSuccess,onError);
      }else if (selectedIndex === 1) {
        DWObject.SaveAsJPEG("Scanned.jpg", DWObject.CurrentImageIndexInBuffer,onSuccess,onError);
      }else if (selectedIndex === 2) {
        DWObject.SaveAsPNG("Scanned.png", DWObject.CurrentImageIndexInBuffer,onSuccess,onError);
      }
    }
    

All right, we’ve now finished the demo to scan documents.

Detect and Crop Documents

Next, we are going to add the image cropper web component. It uses Dynamsoft Document Normalizer to detect document borders and run perspective transformation to rectify documents. It also provides an easy-to-use UI to view and edit the detected document.

Cropper

  1. Include the libraries of the image cropper web component and Dynamsoft Document Normalizer in the head.

    <script type="module">
      import { defineCustomElements } from 'https://cdn.jsdelivr.net/npm/image-cropper-component@1.3.0/dist/esm/loader.js';
      defineCustomElements();
    </script>
    <script src="https://cdn.jsdelivr.net/npm/dynamsoft-document-normalizer@1.0.12/dist/ddn.js"></script>
    
  2. Add the component in the body with the same size and position as the container of Web TWAIN.

    HTML:

    <div id="dwtcontrolContainer"></div>
    <div id="cropper">
      <image-cropper style="--active-stroke: 5;--active-color:orange;--inactive-color:orange;"></image-cropper>
    </div>
    

    CSS:

    #dwtcontrolContainer {
      height: calc(100% - 40px);
    }
    
    #cropper {
      display: none;
      height: calc(100% - 40px);
    }
       
    @media screen and (max-device-width: 600px){
      #dwtcontrolContainer {
        height: 400px;
      }
    
      #cropper {
        height: 400px;
      }
    }
    
  3. Set up the cropper. We can define its event listeners and attributes. A license is needed to use Dynamsoft Document Normalizer. You can apply for one here.

    function setupCropper(){
      const imageCropper = document.querySelector("image-cropper");
      const canceled = () => {
        document.getElementById("cropper").style.display = "none";
        document.getElementById("dwtcontrolContainer").style.display = "";
      }
    
      const confirmed = async () => {
        document.getElementById("cropper").style.display = "none";
        document.getElementById("dwtcontrolContainer").style.display = "";
      }
    
      imageCropper.license = "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==";
      imageCropper.addEventListener("canceled",canceled);
      imageCropper.addEventListener("confirmed",confirmed);
    }
    
  4. When the Crop button is clicked, pass the selected document to the cropper and display the cropper.

    async function showCropper(){
       const imageCropper = document.querySelector("image-cropper");
       imageCropper.inactiveSelections = [];
       document.getElementById("cropper").style.display = "block";
       document.getElementById("dwtcontrolContainer").style.display = "none";
       const base64 = await convertToBase64EncodedPNG();
       const dataURL = "data:image/png;base64," + base64;
       const img = document.createElement("img");
       img.onload = async function(){
         imageCropper.img = img;
       }
       img.src = dataURL;
     }
    
     async function convertToBase64EncodedPNG(){
       return new Promise((resolve, reject) => {
         DWObject.ConvertToBase64(
           [DWObject.CurrentImageIndexInBuffer],
           Dynamsoft.DWT.EnumDWT_ImageType.IT_PNG,
           function (result, indices, type) {
             resolve(result.getData(0, result.getLength()));
           },
           function (errorCode, errorString) {
             reject(errorString);
           }
         );  
       })
     }
    
  5. Detect the documents and display them in the cropper after the cropper is shown. Since the documents scanned usually have a white background, which makes it difficult to detect documents that are mostly white, we pass a template optimized for such a scenario for detection. The detect method can get multiple detected documents. We set the first one as the active one which can be adjusted and the rest as inactive selections.

    async function showCropper(){
      const template = "{\"GlobalParameter\": {\"MaxTotalImageDimension\": 0,\"Name\": \"DM_Defaut_GlobalParameter\"},\"ImageParameterArray\": [{\"BaseImageParameterName\": \"\",\"BinarizationModes\": [{\"BlockSizeX\": 7,\"BlockSizeY\": 7,\"EnableFillBinaryVacancy\": 0,\"GrayscaleEnhancementModesIndex\": -1,\"LibraryFileName\": \"\",\"LibraryParameters\": \"\",\"Mode\": \"BM_LOCAL_BLOCK\",\"MorphOperation\": \"Close\",\"MorphOperationKernelSizeX\": 3,\"MorphOperationKernelSizeY\": 3,\"MorphShape\": \"Rectangle\",\"ThresholdCompensation\": 5}],\"ColourChannelUsageType\": \"CCUT_AUTO\",\"ColourConversionModes\": [{\"BlueChannelWeight\": -1,\"GreenChannelWeight\": -1,\"LibraryFileName\": \"\",\"LibraryParameters\": \"\",\"Mode\": \"CICM_GENERAL\",\"RedChannelWeight\": -1}],\"GrayscaleEnhancementModes\": [{\"LibraryFileName\": \"\",\"LibraryParameters\": \"\",\"Mode\": \"GEM_SHARPEN_SMOOTH\",\"SharpenBlockSizeX\": 3,\"SharpenBlockSizeY\": 3,\"SmoothBlockSizeX\": 3,\"SmoothBlockSizeY\": 3}],\"GrayscaleTransformationModes\": [{\"LibraryFileName\": \"\",\"LibraryParameters\": \"\",\"Mode\": \"GTM_ORIGINAL\"}],\"LineExtractionModes\": [{\"LibraryFileName\": \"\",\"LibraryParameters\": \"\",\"Mode\": \"LEM_GENERAL\"}],\"MaxThreadCount\": 4,\"Name\": \"DM_Defaut_ImageParameter\",\"NormalizerParameterName\": \"NormalizerParameter\",\"RegionPredetectionModes\": [{\"LibraryFileName\": \"\",\"LibraryParameters\": \"\",\"Mode\": \"RPM_GENERAL\"}],\"ScaleDownThreshold\": 512,\"TextFilterModes\": [{\"IfEraseTextZone\": 0,\"LibraryFileName\": \"\",\"LibraryParameters\": \"\",\"MinImageDimension\": 65536,\"Mode\": \"TFM_GENERAL_CONTOUR\",\"Sensitivity\": 0}],\"TextureDetectionModes\": [{\"LibraryFileName\": \"\",\"LibraryParameters\": \"\",\"Mode\": \"TDM_GENERAL_WIDTH_CONCENTRATION\",\"Sensitivity\": 5}],\"Timeout\": 10000}],\"NormalizerParameterArray\": [{\"Brightness\": 0,\"ColourMode\": \"ICM_COLOUR\",\"ContentType\": \"CT_DOCUMENT\",\"Contrast\": 0,\"DeskewMode\": {\"ContentDirection\": 0,\"Mode\": \"DM_PERSPECTIVE_CORRECTION\"},\"InteriorAngleRangeArray\": [{\"MaxValue\": 110,\"MinValue\": 70}],\"Name\": \"NormalizerParameter\",\"PageSize\": [-1,-1],\"QuadrilateralDetectionModes\": [{\"Mode\": \"QDM_GENERAL\"}]}]}";
      quads = await imageCropper.detect(img,template);
      if (quads.length>0) {
        imageCropper.quad = quads[0].location;
        if (quads.length>1) {
          let inactiveSelections = [];
          for (let index = 1; index < quads.length; index++) {
            const quad = quads[index];
            inactiveSelections.push(quad.location);
          }
          imageCropper.inactiveSelections = inactiveSelections;
        }
      }else{
        //no documents detected
        imageCropper.quad = {points:[{x:50,y:50},{x:250,y:50},{x:250,y:250},{x:50,y:250}]};
      }
    }
    
  6. Add a listener for the selectionClicked event. If the user clicks an inactive selection, then make it the active one which can be adjusted.

    const selectionClicked = async (e) => {
      const index = e.detail;
      let selections = await imageCropper.getAllSelections();
      let selectedSelection = selections[index];
      if ("width" in selectedSelection) {
        imageCropper.rect = selectedSelection;
      }else{
        imageCropper.quad = selectedSelection;
      }
      selections.splice(index,1); //delete the active selection
      imageCropper.inactiveSelections = selections;
    }
    imageCropper.addEventListener("selectionClicked",selectionClicked)
    
  7. Delete the original one and get the cropped images if the user confirms the results.

    const confirmed = async () => {
      document.getElementById("cropper").style.display = "none";
      document.getElementById("dwtcontrolContainer").style.display = "";
      const selections = await imageCropper.getAllSelections();
      if (selections.length>0) {
        DWObject.RemoveAllSelectedImages();
      }
      for (let index = 0; index < selections.length; index++) {
        const selection = selections[index];
        let croppedImage = await imageCropper.getCroppedImage({selection:selection,perspectiveTransform:true,colorMode:"color"});
        await loadImageFromDataURL(croppedImage);
      } 
    }
       
    function loadImageFromDataURL(dataURL){
      return new Promise((resolve, reject) => {
        DWObject.LoadImageFromBase64Binary(
          removeDataURLHead(dataURL),
          Dynamsoft.DWT.EnumDWT_ImageType.IT_PNG,
          function(){
            resolve();
          },
          function(errorCode,errorString){
            reject(errorString);
          }
        )
      })
    }
    
    function removeDataURLHead(dataURL){
      return dataURL.substring(dataURL.indexOf(",")+1,dataURL.length);
    }
    

All right, we’ve now completed the demo.

Source Code

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

https://github.com/tony-xlh/Scan-and-Crop-Documents/