Build a Cross-Platform Python Document Scanner with TWAIN, WIA, and SANE

Dynamic Web TWAIN Service streamlines document scanner programming for developers by providing a unified REST API that works with TWAIN, WIA, eSCL, SANE and ICA drivers. We have previously released a Node.js package and a Flutter plugin to interact with Dynamic Web TWAIN Service’s REST API. In this article, we will extend this convenience to Python development.

Python Flet TWAIN document scanner demo

What you’ll build: A cross-platform Python desktop app (using Flet) that discovers connected document scanners via TWAIN, WIA, or SANE and acquires scanned images through a unified REST API.

Key Takeaways

  • Dynamic Web TWAIN Service exposes a single REST API that abstracts TWAIN (Windows), WIA (Windows), SANE (Linux), ICA (macOS), and eSCL scanners behind one unified interface.
  • The twain-wia-sane-scanner Python package wraps this REST API into six methods — getDevices, createJob, deleteJob, getImageFile, getImageFiles, and getImageStreams — covering the full scan workflow.
  • The same Python code runs on Windows, macOS, and Linux without driver-specific changes — only the locally installed Dynamic Web TWAIN Service handles the driver layer.
  • Flet (built on Flutter) lets you create a desktop GUI for document scanning in pure Python with no frontend experience required.

Common Developer Questions

  • How do I scan documents from a TWAIN or SANE scanner in Python?
  • Is there a cross-platform Python library for document scanning on Windows, macOS, and Linux?
  • How do I build a desktop document scanner GUI app in Python without frontend code?

Install the Python Package

https://pypi.org/project/twain-wia-sane-scanner/

pip install twain-wia-sane-scanner

Prerequisites

  1. Install Dynamic Web TWAIN Service.
  2. Get a 30-day free trial license for Dynamic Web TWAIN.

Explore the REST API Reference

Build a Python Package for Document Image Acquisition

A Python package primarily consists of two files: __init__.py and setup.py.

__init__.py

We create a dynamsoftservice folder and add a __init__.py file to it.

The __init__.py file contains two classes: ScannerType and ScannerController.

  • ScannerType is an enumeration of scanner types.

      class ScannerType:
          TWAINSCANNER = 0x10
          WIASCANNER = 0x20
          TWAINX64SCANNER = 0x40
          ICASCANNER = 0x80
          SANESCANNER = 0x100
          ESCLSCANNER = 0x200
          WIFIDIRECTSCANNER = 0x400
          WIATWAINSCANNER = 0x800
    
  • ScannerController is a class that provides methods for interacting with Dynamic Web TWAIN Service.

      class ScannerController:
    
          def getDevices(self, host: str, scannerType: int = None) -> List[Any]:
              return []
    
          def createJob(self, host: str, parameters: Dict[str, Any]) -> str:
              return ""
    
          def deleteJob(self, host: str, jobId: str) -> None:
              pass
    
          def getImageFile(self, host, job_id, directory):
              return ''
    
          def getImageFiles(self, host: str, jobId: str, directory: str) -> List[str]:
              return []
    
          def getImageStreams(self, host: str, jobId: str) -> List[bytes]:
              return []
    

The getDevices() method returns a list of scanners.

def getDevices(self, host: str, scannerType: int = None) -> List[Any]:
    devices = []
    url = f"{host}/api/device/scanners"
    if scannerType is not None:
        url += f"?type={scannerType}"

    try:
        response = requests.get(url)
        if response.status_code == 200 and response.text:
            devices = json.loads(response.text)
            return devices
    except Exception as error:
        pass
    return []

The createJob() method starts a scan job.

def createJob(self, host: str, parameters: Dict[str, Any]) -> str:
    url = f"{host}/api/device/scanners/jobs"
    try:
        response = requests.post(url, json=parameters, headers={
                                    'Content-Type': 'application/text'})
        jobId = response.text
        if response.status_code == 201:
            return jobId
    except Exception as error:
        pass
    return ""

The deleteJob() method deletes a scan job.

def deleteJob(self, host: str, jobId: str) -> None:
    if not jobId:
        return
    url = f"{host}/api/device/scanners/jobs/{jobId}"
    try:
        response = requests.delete(url)
        if response.status_code == 200:
            pass
    except Exception as error:
        pass

The getImageFile() method fetches a document image from Dynamic Web TWAIN Service to a specified directory.

def getImageFile(self, host, job_id, directory):
    url = f"{host}/api/device/scanners/jobs/{job_id}/next-page"
    try:
        response = requests.get(url, stream=True)
        if response.status_code == 200:
            timestamp = str(int(time.time() * 1000))
            filename = f"image_{timestamp}.jpg"
            image_path = os.path.join(directory, filename)
            with open(image_path, 'wb') as f:
                f.write(response.content)
            return filename
    except Exception as e:
        print("No more images.")
        return ''
    return ''

The getImageFiles() method consecutively fetch all document images until the job is finished.

def getImageFiles(self, host: str, jobId: str, directory: str) -> List[str]:
    images = []
    while True:
        filename = self.getImageFile(host, jobId, directory)
        if filename == '':
            break
        else:
            images.append(filename)
    return images

The getImageStreams() method returns a list of document image streams.

def getImageStreams(self, host: str, jobId: str) -> List[bytes]:
    streams = []
    url = f"{host}/api/device/scanners/jobs/{job_id}/next-page"
    while True:
        try:
            response = requests.get(url)
            if response.status_code == 200:
                streams.append(response.content)
            elif response.status_code == 410:
                break
        except Exception as error:
            break
    return streams

setup.py

The setup.py file is used to build the Python package. It defines the package name, version, description, author, install_requires and so on.

from setuptools.command import build_ext
from setuptools import setup
import os
import io
from setuptools.command.install import install
import shutil

long_description = io.open("README.md", encoding="utf-8").read()

setup(name='twain-wia-sane-scanner',
      version='1.0.0',
      description='A Python package for digitizing documents from TWAIN, WIA, SANE, ICA and eSCL compatible scanners.',
      long_description=long_description,
      long_description_content_type="text/markdown",
      author='yushulx',
      url='https://github.com/yushulx/twain-wia-sane-scanner',
      license='MIT',
      packages=['dynamsoftservice'],
      classifiers=[
           "Development Status :: 5 - Production/Stable",
           "Environment :: Console",
           "Intended Audience :: Developers",
          "Intended Audience :: Education",
          "Intended Audience :: Information Technology",
          "Intended Audience :: Science/Research",
          "License :: OSI Approved :: MIT License",
          "Operating System :: Microsoft :: Windows",
          "Operating System :: MacOS",
          "Operating System :: POSIX :: Linux",
          "Programming Language :: Python",
          "Programming Language :: Python :: 3",
          "Programming Language :: Python :: 3 :: Only",
          "Programming Language :: Python :: 3.6",
          "Programming Language :: Python :: 3.7",
          "Programming Language :: Python :: 3.8",
          "Programming Language :: Python :: 3.9",
          "Programming Language :: Python :: 3.10",
          "Topic :: Scientific/Engineering",
          "Topic :: Software Development",
      ],
      install_requires=['requests'])

To generate the wheel file, run the following command:

python setup.py bdist_wheel

Create a Cross-Platform Desktop Scanner App with Flet

Flet, powered by Flutter, is a framework that enables developers to easily build web, mobile, and desktop apps in Python. No frontend experience is required.

python install flet

We can scaffold a new flet project as follows:

flet create myapp
cd myapp

In the main.py file, add some UI controls:

def main(page: ft.Page):
    page.title = "Document Scanner"

    buttonDevice = ft.ElevatedButton(
        text="Get Devices", on_click=get_device)

    dd = ft.Dropdown(on_change=dropdown_changed,)
    buttonScan = ft.ElevatedButton(text="Scan", on_click=scan_document)
    row = ft.Row(spacing=10, controls=[
                 buttonDevice, dd, buttonScan], alignment=ft.MainAxisAlignment.CENTER,)

    lv = ft.ListView(expand=1, spacing=10, padding=20, auto_scroll=True)

    page.add(
        row, lv
    )
  • The button buttonDevice is used to get all available scanners.

      def get_device(e):
          devices.clear()
          dd.options = []
          scanners = scannerController.getDevices(
              host, ScannerType.TWAINSCANNER | ScannerType.TWAINX64SCANNER)
          for i, scanner in enumerate(scanners):
              devices.append(scanner)
              dd.options.append(ft.dropdown.Option(scanner['name']))
              dd.value = scanner['name']
    
          page.update()
    
  • The dropdown dd is used to select a scanner.

      def dropdown_changed(e):
          page.update()
    
  • The button buttonScan is used to trigger the document scan. You need to replace the license key with your own. The source of an Image control can be either a file path or a base64-encoded string. In this case, we use the latter.

      def scan_document(e):
          if len(devices) == 0:
              return
    
          device = devices[0]["device"]
    
          for scanner in devices:
              if scanner['name'] == dd.value:
                  device = scanner['device']
                  break
    
          parameters = {
              "license": license_key,
              "device": device,
          }
    
          parameters["config"] = {
              "IfShowUI": False,
              "PixelType": 2,
              "Resolution": 200,
              "IfFeederEnabled": False,
              "IfDuplexEnabled": False,
          }
    
          job_id = scannerController.createJob(host, parameters)
    
          if job_id != "":
              images = scannerController.getImageStreams(host, job_id)
              for i, image in enumerate(images):
                  base64_encoded = base64.b64encode(image)
                  display = ft.Image(src_base64=base64_encoded.decode('utf-8'), width=600,
                                     height=600,
                                     fit=ft.ImageFit.CONTAIN,)
                  lv.controls.append(display)
    
              scannerController.deleteJob(host, job_id)
    
          page.update()
    
  • The listview lv is used to append and display document images.

Run the app:

flet run

python-flet-twain-document-scanner

Common Issues and Edge Cases

  • Dynamic Web TWAIN Service not running: If getDevices() returns an empty list, verify the Dynamsoft Service is installed and running. On Linux, check with systemctl status DynamsoftService. On Windows, look for the service in the system tray.
  • Scanner not detected on Linux: SANE-compatible scanners require the correct SANE backend driver to be installed. Run scanimage -L in a terminal to confirm SANE can see the device before calling the REST API.
  • Timeout or empty images on duplex/feeder scans: When IfFeederEnabled is True, the scanner pulls pages from the automatic document feeder. If no pages are loaded, the job may hang or return no images. Always check that pages are in the feeder before starting a duplex scan job.

Source Code

https://github.com/yushulx/twain-wia-sane-scanner