User Guide

Basic Usage

List available scanners and scan a document:

import scanlib

# Discover scanners
scanners = scanlib.list_scanners()
for s in scanners:
    print(s)  # e.g. "2nd Floor" or "HP Officejet Pro 8500"

# Scan a document
with scanners[0] as scanner:
    doc = scanner.scan()

# doc.data contains PDF bytes
with open("output.pdf", "wb") as f:
    f.write(doc.data)

Scan Options

Customize the scan with keyword arguments:

from scanlib import ColorMode, ScanArea, ScanSource

with scanners[0] as scanner:
    doc = scanner.scan(
        dpi=600,
        color_mode=ColorMode.GRAY,
        scan_area=ScanArea(0, 0, 2100, 2970),  # full A4 in 1/10 mm
        source=ScanSource.FLATBED,
    )

Black & White Threshold

When scanning in BW mode, grayscale pixels are converted to 1-bit black or white using a threshold. Pixels with a value ≥ threshold become white; below become black. The default is 128:

with scanners[0] as scanner:
    # Lower threshold = more white (lighter output)
    doc = scanner.scan(color_mode=ColorMode.BW, bw_threshold=100)

    # Higher threshold = more black (darker output)
    doc = scanner.scan(color_mode=ColorMode.BW, bw_threshold=180)

The threshold applies both to scan()/scan_pages() and to build_pdf() when converting grayscale pages to BW.

Opening a Scanner by ID

If you already know a scanner’s ID from a previous discovery, you can open it directly without running list_scanners() again:

import scanlib

scanner = scanlib.open_scanner("escl:192.168.1.5:443")
with scanner:
    doc = scanner.scan()

This skips the mDNS/platform discovery step and connects immediately. On macOS with native ImageCaptureCore, a quick targeted discovery is run behind the scenes to resolve the UUID to a device object.

Scanner Capabilities

After opening a scanner, you can query its capabilities:

with scanners[0] as scanner:
    for si in scanner.sources:
        print(si.type)           # ScanSource.FLATBED
        print(si.resolutions)    # [150, 300, 600, 1200]
        print(si.color_modes)    # [ColorMode.COLOR, ColorMode.GRAY, ColorMode.BW]
        print(si.max_scan_area)  # ScanArea(x=0, y=0, width=2159, height=2972)
    print(scanner.defaults)      # ScannerDefaults(dpi=300, ...)

The first entry in sources is the scanner’s primary source (typically flatbed). When scan() is called without an explicit source, the first entry is used for parameter validation.

Feeder Scanning

When scanning from a document feeder, all pages are scanned automatically:

with scanners[0] as scanner:
    doc = scanner.scan(source=ScanSource.FEEDER)
    print(doc.page_count)  # Number of pages in the feeder

Multi-Page Flatbed Scanning

Use the next_page callback to scan multiple pages one at a time on a flatbed scanner. The callback receives the number of pages scanned so far and returns True to continue or False to stop:

def prompt_next(pages_so_far: int) -> bool:
    return input(f"{pages_so_far} page(s) scanned. Add another? [y/n] ") == "y"

with scanners[0] as scanner:
    doc = scanner.scan(next_page=prompt_next)
    # doc is a single multi-page PDF

Page-Level Scanning

Use scan_pages() to receive individual pages as they arrive. Each ScannedPage carries raw pixel data and can be encoded as JPEG or PNG for previewing. After reviewing and reordering, assemble a PDF with build_pdf():

import scanlib

with scanners[0] as scanner:
    pages = list(scanner.scan_pages())

# Preview each page
for i, page in enumerate(pages):
    with open(f"page_{i}.jpg", "wb") as f:
        f.write(page.to_jpeg())

# Rotate a page 90° clockwise
pages[0] = pages[0].rotate(90)

# Reorder, filter, then build the final PDF
pages.reverse()
doc = scanlib.build_pdf(pages, dpi=300)
with open("output.pdf", "wb") as f:
    f.write(doc.data)

Progress Callback

Monitor scan progress with a callback. Return False to abort:

def on_progress(percent: int) -> bool:
    print(f"Scanning... {percent}%")
    return True  # return False to abort

with scanners[0] as scanner:
    doc = scanner.scan(progress=on_progress)

Aborting a Scan

Call abort() from any thread to cancel an in-progress scan. The running scan() or scan_pages() call will raise ScanAborted shortly after:

import threading

with scanners[0] as scanner:
    # Abort after 5 seconds from another thread
    threading.Timer(5, scanner.abort).start()
    try:
        doc = scanner.scan()
    except scanlib.ScanAborted:
        print("Scan was cancelled")

abort() is safe to call even when no scan is running.

Cancelling Discovery

Pass a threading.Event to list_scanners() to cancel a long-running discovery from another thread:

import threading
import scanlib

cancel = threading.Event()

# Cancel after 5 seconds from another thread
threading.Timer(5, cancel.set).start()

scanners = scanlib.list_scanners(timeout=120, cancel=cancel)

When the event is set, list_scanners() returns immediately with whatever scanners have been found (or an empty list).

eSCL / AirScan Network Scanners

scanlib includes a built-in eSCL (AirScan) backend that discovers network scanners via mDNS and communicates with them directly over HTTP — no OS-level scanner drivers are needed.

Platform

eSCL status

Notes

Linux

Always enabled

Runs alongside SANE; SANE handles USB, eSCL handles network

Windows

Always enabled

Runs alongside WIA; WIA handles USB, eSCL handles network

macOS

Opt-in (SCANLIB_ESCL=1)

ImageCaptureCore already handles eSCL natively

To enable the eSCL backend on macOS:

export SCANLIB_ESCL=1

When enabled, eSCL discovery runs in parallel with the native backend and results are deduplicated by IP address. Each scanner’s backend property indicates which backend discovered it:

scanner.backend

Description

Scanner ID format

"sane"

Linux SANE (USB)

SANE device URI

"imagecapture"

macOS ImageCaptureCore

ICC UUID

"wia"

Windows WIA 2.0 (USB)

WIA device ID

"escl"

eSCL / AirScan (network)

escl:IP:PORT

Thread Safety

All scanlib operations can be called from any thread. The library internally dispatches operations to the correct thread for backends that require it (macOS ImageCaptureCore, Windows WIA).

Note that progress callbacks may execute on an internal thread. If your callback updates a GUI, dispatch to your UI thread accordingly. The next_page callback always runs on the caller’s thread.