Build a Web Document Scanner with JavaScript: File, Camera, and TWAIN Scanner Support

Dynamic Web TWAIN Service is the core component of the Dynamic Web TWAIN SDK. It enables you to acquire documents from document scanners over a local network using REST API. This API overcomes the limitations of traditional browser-based scanning solutions by allowing you to capture documents from scanners connected to any machine on the same local network. This article will guide you through the process of setting up and using the Dynamic Web TWAIN Service to acquire documents from document scanners using HTML5 and JavaScript.

What you’ll build: A browser-based document management app that lets users acquire documents from a TWAIN/SANE/WIA scanner over a local network, capture images from a webcam, or load files — all integrated into Dynamsoft Document Viewer using the Dynamic Web TWAIN Service REST API.

Key Takeaways

  • The Dynamic Web TWAIN Service REST API enables browser-based document scanning from TWAIN/SANE/WIA scanners connected anywhere on the local network — no plugins required.
  • JavaScript’s getUserMedia API can be combined with TWAIN scanner acquisition to create a unified multi-source document capture interface.
  • The Dynamic Web TWAIN Service must be bound to a local IP address (via http://127.0.0.1:18625/) to accept requests from other machines on the same network.
  • All scanned pages and captured images are loaded into Dynamsoft Document Viewer as blobs, enabling annotation and PDF export in the browser.

Common Developer Questions

  • How do I scan documents from a TWAIN scanner over a local network using JavaScript?
  • How do I capture images from a webcam and load them into a document viewer in HTML5?
  • How do I build a web document scanner that supports file upload, camera, and physical scanner input?

See the Web Document Scanner in Action

Prerequisites

Enable Local Network Access for the REST API

By default, the Dynamic Web TWAIN Service only accepts requests from localhost. To enable access over your local network, bind the service to your machine’s IP address via the configuration page http://127.0.0.1:18625/ in your web browser.

Dynamic Web TWAIN Service Configuration

Explore the REST API and Scanner Parameters

To integrate scanning capabilities in your web app, refer to the official REST API documentation:

Build a Web Document Scanner: File, Camera, and TWAIN in JavaScript

To save time, download the sample code that demonstrates how to use the Dynamsoft Document Viewer. In this tutorial, you’ll learn how to extend the sample by integrating the following features:

  1. Popup Window for Managing Document Scanners: This window allows users to select a scanner, configure scan settings, and initiate the scanning process via a REST API. Scanned images are automatically added to the Dynamsoft Document Viewer for further processing, such as annotation or saving as PDF.
  2. Popup Window for Capturing Images from the Camera: Using the getUserMedia API, this window enables users to capture multiple images with the device’s built-in or connected camera. Captured images can then be viewed and annotated in the Document Viewer.
  3. Dropdown Menu for Selecting Input Source (File, Camera, Scanner): This menu allows users to choose the input method. Based on the selection, the appropriate popup interface is launched for image acquisition.

Step 1: Add TWAIN Scanner Support via the REST API

capture document from file, camera and scanner

To enable document scanning from a local or network scanner:

  1. In index.html, add the following code snippet. The layout includes a dropdown to select the scanner source, options for resolution, and an auto feeder checkbox. The buttons will trigger the scanning process or cancel it.

     <div id="pop-scanner" class="overlay">
         <div class="popup">
             <div class="form-group">
                 <label for="sources">Scanner Source:</label>
                 <select id="sources">
                 </select>
             </div>
    
             <div class="form-group">
                 <label>
                     <input type="checkbox" id="ADF" checked="checked">Auto Feeder</label>
                 <select id="Resolution">
                     <option value="100">100</option>
                     <option value="150">150</option>
                     <option value="200">200</option>
                     <option value="300">300</option>
                 </select>
             </div>
    
             <button class="button" id="acquireDocument">OK</button>
             <button class="button" id="cancelCapture">Cancel</button>
         </div>
     </div>
    
  2. In main.js, add the following JavaScript code.

     let host = "http://127.0.0.1:18622";
     const acquireDocumentButton = document.getElementById('acquireDocument');
     const cancelCaptureButton = document.getElementById('cancelCapture');
        
     cancelCaptureButton.addEventListener('click', () => {
         document.getElementById("pop-scanner").style.display = "none";
     });
        
     acquireDocumentButton.addEventListener('click', async () => {
         document.getElementById("pop-scanner").style.display = "none";
        
         let select = document.getElementById('sources');
         let scanner = select.value;
        
         if (scanner == null || scanner.length == 0) {
             alert('Please select a scanner.');
             return;
         }
        
         let license = document.getElementById('licensekey').value;
        
         let resolutionSelect = document.getElementById('Resolution');
        
         let adfCheck = document.getElementById('ADF');
        
        
         let parameters = {
             license: license,
             device: JSON.parse(scanner)['device'],
         };
        
         parameters.config = {
             PixelType: 2,
             Resolution: parseInt(resolutionSelect.value),
             IfFeederEnabled: adfCheck.checked,
         };
        
         let url = host + '/api/device/scanners/jobs';
        
         try {
             let response = await fetch(url, {
                 method: 'POST',
                 headers: {
                     'Content-Type': 'application/json'
                 },
                 body: JSON.stringify(parameters)
             });
        
             if (response.ok) {
                 let job = await response.json();
                 let jobId = job.jobuid;
        
                 let blobs = [];
                 let url = host + '/api/device/scanners/jobs/' + jobId + '/next-page';
        
                 while (true) {
                     try {
        
                         let response = await fetch(url);
        
                         if (response.status == 200) {
                             const arrayBuffer = await response.arrayBuffer();
                             const blob = new Blob([arrayBuffer], { type: response.type });
                             await load(blob, '');
                         }
                         else {
                             break;
                         }
        
                     } catch (error) {
                         console.error('No more images.');
                         break;
                     }
                 }
             }
        
         } catch (error) {
             alert(error);
         }
     });
    
     async function popScanner() {
         toggleLoading(true);
        
         try {
        
             let url = host + '/api/device/scanners';
             let response = await fetch(url);
        
             if (response.ok) {
                 let devices = await response.json();
        
                 let select = document.getElementById('sources');
                 select.innerHTML = '';
        
                 for (let i = 0; i < devices.length; i++) {
                     let device = devices[i];
                     let option = document.createElement("option");
                     option.text = device['name'];
                     option.value = JSON.stringify(device);
                     select.add(option);
                 };
             }
        
         } catch (error) {
             alert(error);
             return "";
         }
        
         toggleLoading(false);
        
         document.getElementById("pop-scanner").style.display = "flex";
     }
    

    Explanation:

    • The /api/device/scanners endpoint is used to list all available scanners on the host machine. The response will include the scanner names and device information, which will be populated in the dropdown menu (<select id="sources">).
    • The /api/device/scanners/jobs endpoint is used to create a new scanning job. The request body includes the license key, device information, and scanning parameters (pixel type, resolution, and whether to use the auto feeder).
    • Once the job is created, the job ID is returned in the response. This job ID is then used to fetch the scanned images using the /api/device/scanners/jobs/{jobId}/next-page endpoint.
    • All fetched images are encoded as blobs and loaded into the Dynamsoft Document Viewer using the load function. This allows you to view and annotate the scanned images within the viewer.

Step 2: Add Camera Capture Using getUserMedia

web camera capture

To add camera capture functionality:

  1. In index.html, add the following code snippet for the camera capture popup window.

     <div id="camera-popup" class="overlay">
         <div class="popup">
             <h2>Camera Capture</h2>
    
             <div class="form-group">
                 <label for="cameraSelect">Select Camera:</label>
                 <select id="cameraSelect" style="width: 100%"></select>
             </div>
    
             <video id="camera-preview" autoplay playsinline style="width: 100%; border: 1px solid #ccc;"></video>
    
             <div id="captured-thumbnails" style="margin: 10px 0; display: flex; gap: 10px; overflow-x: auto;"></div>
    
             <div class="popup-buttons" style="margin-top: 10px;">
                 <button id="captureFrameBtn">Capture</button>
                 <button id="insertFramesBtn">Insert</button>
                 <button id="cancelCameraBtn">Cancel</button>
             </div>
         </div>
     </div>
    
  2. In main.js, add the following JavaScript code to handle camera access and image capture:

     let stream = null;
     let capturedBlobs = [];
     let currentDeviceId = null;
        
     const cameraSelect = document.getElementById("cameraSelect");
     const video = document.getElementById("camera-preview");
     const thumbnailContainer = document.getElementById("captured-thumbnails");
     const captureBtn = document.getElementById("captureFrameBtn");
     const insertBtn = document.getElementById("insertFramesBtn");
     const cancelBtn = document.getElementById("cancelCameraBtn");
     const cameraPopup = document.getElementById("camera-popup");
        
     async function listCameras() {
         const devices = await navigator.mediaDevices.enumerateDevices();
         const videoDevices = devices.filter(device => device.kind === "videoinput");
        
         cameraSelect.innerHTML = "";
         videoDevices.forEach((device, index) => {
             const option = document.createElement("option");
             option.value = device.deviceId;
             option.text = device.label || `Camera ${index + 1}`;
             cameraSelect.appendChild(option);
         });
        
         if (videoDevices.length > 0) {
             currentDeviceId = videoDevices[0].deviceId;
             cameraSelect.value = currentDeviceId;
         }
     }
        
     async function startCamera(deviceId) {
         if (stream) {
             stream.getTracks().forEach(track => track.stop());
         }
        
         try {
             stream = await navigator.mediaDevices.getUserMedia({
                 video: { deviceId: { exact: deviceId } }
             });
             video.srcObject = stream;
             currentDeviceId = deviceId;
         } catch (err) {
             console.error("Failed to start camera:", err);
             alert("Unable to access selected camera.");
         }
     }
        
     async function showCameraPopup() {
         await listCameras();
         await startCamera(currentDeviceId);
         capturedBlobs = [];
         thumbnailContainer.innerHTML = "";
         cameraPopup.style.display = "flex";
     }
        
     function hideCameraPopup() {
         if (stream) stream.getTracks().forEach(track => track.stop());
         video.srcObject = null;
         cameraPopup.style.display = "none";
         capturedBlobs = [];
         thumbnailContainer.innerHTML = "";
     }
        
     cameraSelect.addEventListener("change", () => {
         const selectedId = cameraSelect.value;
         if (selectedId !== currentDeviceId) {
             startCamera(selectedId);
         }
     });
        
     captureBtn.addEventListener("click", () => {
         const canvas = document.createElement("canvas");
         canvas.width = video.videoWidth;
         canvas.height = video.videoHeight;
         canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
        
         canvas.toBlob((blob) => {
             if (blob) {
                 capturedBlobs.push(blob);
        
                 const img = document.createElement("img");
                 img.src = URL.createObjectURL(blob);
                 img.style.width = "80px";
                 img.style.height = "60px";
                 img.style.objectFit = "cover";
                 img.style.border = "1px solid #ccc";
                 thumbnailContainer.appendChild(img);
             }
         }, "image/jpeg");
     });
        
     insertBtn.addEventListener("click", async () => {
         if (!capturedBlobs.length) {
             alert("Please capture at least one frame.");
             return;
         }
        
         for (const blob of capturedBlobs) {
             await load(blob);
         }
        
         hideCameraPopup();
     });
        
     cancelBtn.addEventListener("click", hideCameraPopup);
    

    Explanation:

    • The listCameras function enumerates the available video input devices (cameras) and populates the <select> dropdown with the camera options.
    • The startCamera function initiates the camera stream using the selected device ID and sets it as the source for the <video> element.
    • The showCameraPopup function displays the camera popup and initializes the camera stream.
    • The hideCameraPopup function stops the camera stream and hides the popup.
    • The captureBtn captures a frame from the video stream, converts it to a blob, and adds it to the capturedBlobs array. It also displays a thumbnail of the captured image in the popup.
    • The insertBtn iterates over the captured blobs and loads each one into the Dynamsoft Document Viewer using the load function. After inserting the images, it hides the camera popup.

Step 3: Add a Source Selection Dropdown (File, Camera, Scanner)

dropdown menu

You can integrate a dropdown menu directly into the existing UI layout to allow users to choose how they want to acquire documents:

  1. Create a dropdown button and insert it into the pre-built UI layout.

     let dropdownButton = {
         type: Dynamsoft.DDV.Elements.Button,
         className: "ddv-button ddv-load-image",
         tooltip: "Sources: File, Camera, Scanner",
         events: {
             click: "toggleDropdown",
         },
     };
    
     const pcEditViewerUiConfig = {
         type: Dynamsoft.DDV.Elements.Layout,
         flexDirection: "column",
         className: "ddv-edit-viewer-desktop",
         children: [
             {
                 type: Dynamsoft.DDV.Elements.Layout,
                 className: "ddv-edit-viewer-header-desktop",
                 children: [
                     {
                         type: Dynamsoft.DDV.Elements.Layout,
                         children: [
                             ...
                             dropdownButton,
                         ],
                     },
                     ...
                 ],
             },
             Dynamsoft.DDV.Elements.MainView,
         ],
     };
    
  2. Find the JS path of the dropdown button using Developer Tools . Since there is no API available for customizing a dropdown menu in Dynamsoft Document Viewer, a workaround is to locate the div element and dynamically append a custom dropdown menu below it.

    dropdown menu in dev tools

  3. Define the CSS for the dropdown menu in main.css to ensure its styling aligns consistently with the rest of your application.
     .dropdown-menu {
         display: none;
         position: absolute;
         top: 100%;
         left: 0;
         background-color: #222;
         min-width: 160px;
         box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.2);
         z-index: 1;
         border-radius: 4px;
         overflow: hidden;
         margin-top: 5px;
     }
        
     .dropdown-menu.show {
         display: block;
     }
        
     .dropdown-menu button {
         color: white;
         padding: 10px 16px;
         text-align: left;
         background-color: transparent;
         border: none;
         width: 100%;
         cursor: pointer;
         font-size: 14px;
     }
        
     .dropdown-menu button:hover,
     .dropdown-menu .selected {
         background-color: #444;
     }
    
  4. Add the JavaScript code to handle the dropdown menu’s functionality in main.js:

     let dropdown = null;
     async function showViewer() {
         ...
        
         let button;
         if (isMobile()) {
             button = document.querySelector("#edit-viewer > div > div.ddv-layout.ddv-edit-viewer-footer-mobile > div.ddv-button.ddv-button.ddv-load-image")
         }
         else {
             button = document.querySelector("#edit-viewer > div > div.ddv-layout.ddv-edit-viewer-header-desktop > div:nth-child(1) > div.ddv-button.ddv-button.ddv-load-image")
         }
        
         button.addEventListener("click", (event) => {
             event.stopPropagation();
        
             if (!dropdown) {
                 dropdown = createDropdownMenu();
                 dropdown.style.position = "absolute";
             }
        
             dropdown.style.display = dropdown.style.display === "block" ? "none" : "block";
        
             const rect = button.getBoundingClientRect();
             dropdown.style.left = `${rect.left}px`;
             dropdown.style.top = `${rect.bottom + 5}px`;
         });
     }
        
     function createDropdownMenu() {
         const dropdown = document.createElement("div");
         dropdown.classList.add("dropdown-menu");
        
         dropdown.innerHTML = `
           <button onclick="handleDropdownSelect(this)">File</button>
           <button onclick="handleDropdownSelect(this)">Camera</button>
           <button class="selected" onclick="handleDropdownSelect(this)">Scanner</button>
         `;
        
         document.body.appendChild(dropdown);
         return dropdown;
     }
        
     window.handleDropdownSelect = function (btn) {
         document.querySelectorAll(".dropdown-menu button").forEach(el => el.classList.remove("selected"));
         btn.classList.add("selected");
         console.log("Selected:", btn.textContent);
         dropdown.style.display = "none";
        
         if (btn.textContent === "File") {
             loadDocument();
         }
         else if (btn.textContent === "Camera") {
             showCameraPopup();
         }
         else if (btn.textContent === "Scanner") {
             popScanner();
         }
     };
        
     window.addEventListener("click", () => {
         if (dropdown) dropdown.style.display = "none";
     });
    

    Explanation:

    • The toggleDropdown function toggles the visibility of the custom dropdown menu.
    • The createDropdownMenu function creates the dropdown menu with three options: File, Camera, and Scanner. Each button has an onclick event that calls the handleDropdownSelect function when clicked.

Common Issues & Edge Cases

  • CORS errors when calling the REST API from a remote browser: The Dynamic Web TWAIN Service must be explicitly configured to allow cross-origin requests. Bind the service to your machine’s IP (not just localhost) via http://127.0.0.1:18625/ and ensure the correct IP is used as the host value in your JavaScript.
  • Camera not listed or stream fails to start: Some browsers require a secure context (HTTPS or localhost) to access getUserMedia. Serve your app over HTTPS when testing on a remote device, or use localhost for local development.
  • Scanner returns no pages when Auto Feeder is enabled: If IfFeederEnabled is true but no paper is loaded in the ADF, the API may return an empty job. The polling loop handles this via response.status !== 200 — ensure your error handler breaks the loop cleanly rather than retrying indefinitely.

Source Code

https://github.com/yushulx/web-twain-document-scan-management/tree/main/examples/document_annotation