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 ( |
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:
|
Description |
Scanner ID format |
|---|---|---|
|
Linux SANE (USB) |
SANE device URI |
|
macOS ImageCaptureCore |
ICC UUID |
|
Windows WIA 2.0 (USB) |
WIA device ID |
|
eSCL / AirScan (network) |
|
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.