scanner-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """scanner-mcp: a generic MCP server exposing scan tools for USB and network scanners."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,45 @@
1
+ """Backend registry.
2
+
3
+ Backends are probed lazily; any that raise on construction or report themselves as
4
+ unavailable (missing tools/OS support) are simply skipped, so the same server runs on
5
+ Windows, macOS and Linux and exposes whatever scanners it can reach.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from .base import Backend
13
+
14
+ log = logging.getLogger("scanner_mcp.backends")
15
+
16
+
17
+ def all_backends() -> list[Backend]:
18
+ from .escl import ESCLBackend
19
+ from .sane import SaneBackend
20
+ from .twain import TWAINBackend
21
+ from .wia import WIABackend
22
+
23
+ backends: list[Backend] = []
24
+ for cls in (ESCLBackend, SaneBackend, WIABackend, TWAINBackend):
25
+ try:
26
+ b = cls()
27
+ except Exception as exc: # pragma: no cover - defensive
28
+ log.debug("backend %s failed to construct: %s", cls.__name__, exc)
29
+ continue
30
+ try:
31
+ if b.available():
32
+ backends.append(b)
33
+ except Exception as exc: # pragma: no cover - defensive
34
+ log.debug("backend %s availability check failed: %s", cls.__name__, exc)
35
+ return backends
36
+
37
+
38
+ def backend_for(scanner_id: str) -> Backend | None:
39
+ for b in all_backends():
40
+ if b.handles(scanner_id):
41
+ return b
42
+ return None
43
+
44
+
45
+ __all__ = ["Backend", "all_backends", "backend_for"]
@@ -0,0 +1,29 @@
1
+ """Backend abstraction. Each backend discovers scanners and performs scans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from ..models import ScannerInfo, ScanOptions, ScanResult
8
+
9
+
10
+ class Backend(ABC):
11
+ #: Human-readable backend name.
12
+ name: str = "base"
13
+ #: Prefix used on scanner ids owned by this backend (e.g. "escl").
14
+ prefix: str = "base"
15
+
16
+ def available(self) -> bool:
17
+ """Whether this backend can run in the current environment."""
18
+ return True
19
+
20
+ def handles(self, scanner_id: str) -> bool:
21
+ return scanner_id.startswith(self.prefix + ":")
22
+
23
+ @abstractmethod
24
+ def list_scanners(self) -> list[ScannerInfo]:
25
+ ...
26
+
27
+ @abstractmethod
28
+ def scan(self, scanner_id: str, options: ScanOptions) -> ScanResult:
29
+ ...
@@ -0,0 +1,223 @@
1
+ """eSCL backend: driverless network scanning (a.k.a. Apple AirScan / Mopria eScan).
2
+
3
+ eSCL is an HTTP/XML protocol supported by the vast majority of modern network
4
+ multifunction printers and scanners. Devices are discovered over mDNS
5
+ (``_uscan._tcp`` / ``_uscans._tcp``) and scanned with a small set of HTTP calls:
6
+
7
+ GET {base}/ScannerCapabilities -> supported resolutions / max page size
8
+ POST {base}/ScanJobs -> 201 Created, Location: {job}
9
+ GET {job}/NextDocument -> page bytes (repeat until 404)
10
+
11
+ No vendor driver is required, which is what makes this backend "generic".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ import time
19
+ import xml.etree.ElementTree as ET
20
+
21
+ import httpx
22
+
23
+ from ..models import ScannerInfo, ScanOptions, ScanResult
24
+ from .base import Backend
25
+
26
+ log = logging.getLogger("scanner_mcp.escl")
27
+
28
+ _COLOR = {"color": "RGB24", "gray": "Grayscale8", "lineart": "BlackAndWhite1"}
29
+ _FORMAT_MIME = {"png": "image/png", "jpeg": "image/jpeg", "pdf": "application/pdf"}
30
+ # eSCL scan regions are always expressed in units of 1/300 inch.
31
+ _A4_300 = (2480, 3508)
32
+
33
+
34
+ def _discover(timeout: float = 2.5) -> list[dict]:
35
+ """Discover eSCL devices via mDNS. Returns dicts with base url + metadata."""
36
+ try:
37
+ from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
38
+ except Exception:
39
+ log.debug("zeroconf unavailable; skipping eSCL discovery")
40
+ return []
41
+
42
+ found: dict[str, dict] = {}
43
+
44
+ class _Listener(ServiceListener):
45
+ def __init__(self, secure: bool):
46
+ self.secure = secure
47
+
48
+ def _resolve(self, zc, type_, name):
49
+ info = zc.get_service_info(type_, name, timeout=int(timeout * 1000))
50
+ if not info:
51
+ return
52
+ addrs = info.parsed_addresses() or []
53
+ if not addrs:
54
+ return
55
+ host = addrs[0]
56
+ props = {
57
+ (k.decode() if isinstance(k, bytes) else k): (
58
+ v.decode(errors="replace") if isinstance(v, bytes) else v
59
+ )
60
+ for k, v in (info.properties or {}).items()
61
+ }
62
+ rs = props.get("rs", "eSCL").strip("/")
63
+ scheme = "https" if self.secure else "http"
64
+ base = f"{scheme}://{host}:{info.port}/{rs}"
65
+ found[base] = {
66
+ "base": base,
67
+ "host": host,
68
+ "port": info.port,
69
+ "name": props.get("ty") or name.split(".")[0],
70
+ "note": props.get("note", ""),
71
+ "uuid": props.get("UUID") or props.get("uuid", ""),
72
+ }
73
+
74
+ def add_service(self, zc, type_, name):
75
+ self._resolve(zc, type_, name)
76
+
77
+ def update_service(self, zc, type_, name):
78
+ self._resolve(zc, type_, name)
79
+
80
+ def remove_service(self, zc, type_, name):
81
+ pass
82
+
83
+ zc = Zeroconf()
84
+ try:
85
+ ServiceBrowser(zc, "_uscan._tcp.local.", _Listener(secure=False))
86
+ ServiceBrowser(zc, "_uscans._tcp.local.", _Listener(secure=True))
87
+ time.sleep(timeout)
88
+ finally:
89
+ zc.close()
90
+ return list(found.values())
91
+
92
+
93
+ def _strip_ns(tag: str) -> str:
94
+ return tag.rsplit("}", 1)[-1]
95
+
96
+
97
+ class ESCLBackend(Backend):
98
+ name = "eSCL (network / AirScan)"
99
+ prefix = "escl"
100
+
101
+ def __init__(self, discovery_timeout: float = 2.5):
102
+ self._discovery_timeout = discovery_timeout
103
+
104
+ def available(self) -> bool:
105
+ try:
106
+ import zeroconf # noqa: F401
107
+ except Exception:
108
+ return False
109
+ return True
110
+
111
+ def list_scanners(self) -> list[ScannerInfo]:
112
+ scanners = []
113
+ for dev in _discover(self._discovery_timeout):
114
+ scanners.append(
115
+ ScannerInfo(
116
+ id=f"escl:{dev['base']}",
117
+ name=dev["name"],
118
+ backend=self.name,
119
+ connection="network",
120
+ sources=["platen", "adf"],
121
+ detail={k: v for k, v in dev.items() if k != "base"},
122
+ )
123
+ )
124
+ return scanners
125
+
126
+ # -- scanning -------------------------------------------------------------
127
+
128
+ def _base_url(self, scanner_id: str) -> str:
129
+ return scanner_id[len("escl:") :]
130
+
131
+ def _max_region(self, client: httpx.Client, base: str) -> tuple[int, int]:
132
+ try:
133
+ r = client.get(f"{base}/ScannerCapabilities", timeout=10)
134
+ r.raise_for_status()
135
+ root = ET.fromstring(r.content)
136
+ w = h = None
137
+ for el in root.iter():
138
+ tag = _strip_ns(el.tag)
139
+ if tag == "MaxWidth" and el.text:
140
+ w = int(el.text)
141
+ elif tag == "MaxHeight" and el.text:
142
+ h = int(el.text)
143
+ if w and h:
144
+ return w, h
145
+ except Exception as exc:
146
+ log.debug("capabilities fetch failed (%s); using A4 default", exc)
147
+ return _A4_300
148
+
149
+ def _build_settings(self, opts: ScanOptions, region: tuple[int, int]) -> str:
150
+ w, h = region
151
+ source = "Feeder" if opts.source in ("adf", "adf-duplex") else "Platen"
152
+ duplex = "true" if opts.source == "adf-duplex" else "false"
153
+ color = _COLOR.get(opts.color_mode, "RGB24")
154
+ doc_format = _FORMAT_MIME.get(opts.output_format, "image/png")
155
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
156
+ <scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03"
157
+ xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
158
+ <pwg:Version>2.6</pwg:Version>
159
+ <pwg:ScanRegions pwg:MustHonor="false">
160
+ <pwg:ScanRegion>
161
+ <pwg:XOffset>0</pwg:XOffset>
162
+ <pwg:YOffset>0</pwg:YOffset>
163
+ <pwg:Width>{w}</pwg:Width>
164
+ <pwg:Height>{h}</pwg:Height>
165
+ <pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
166
+ </pwg:ScanRegion>
167
+ </pwg:ScanRegions>
168
+ <pwg:InputSource>{source}</pwg:InputSource>
169
+ <scan:Duplex>{duplex}</scan:Duplex>
170
+ <scan:ColorMode>{color}</scan:ColorMode>
171
+ <scan:XResolution>{opts.resolution}</scan:XResolution>
172
+ <scan:YResolution>{opts.resolution}</scan:YResolution>
173
+ <pwg:DocumentFormat>{doc_format}</pwg:DocumentFormat>
174
+ <scan:DocumentFormatExt>{doc_format}</scan:DocumentFormatExt>
175
+ </scan:ScanSettings>"""
176
+
177
+ def scan(self, scanner_id: str, options: ScanOptions) -> ScanResult:
178
+ base = self._base_url(scanner_id)
179
+ verify = not base.startswith("https") # printers ship self-signed certs
180
+ client = httpx.Client(verify=False if base.startswith("https") else True)
181
+ try:
182
+ region = self._max_region(client, base)
183
+ body = self._build_settings(options, region)
184
+ resp = client.post(
185
+ f"{base}/ScanJobs",
186
+ content=body.encode("utf-8"),
187
+ headers={"Content-Type": "text/xml"},
188
+ timeout=30,
189
+ )
190
+ if resp.status_code not in (200, 201):
191
+ raise RuntimeError(
192
+ f"scanner rejected job ({resp.status_code}): {resp.text[:200]}"
193
+ )
194
+ job = resp.headers.get("Location")
195
+ if not job:
196
+ raise RuntimeError("scanner did not return a job Location header")
197
+ if job.startswith("/"):
198
+ # Some devices return a relative path.
199
+ root = re.match(r"^(https?://[^/]+)", base)
200
+ job = (root.group(1) if root else base) + job
201
+
202
+ pages = self._retrieve_pages(client, job)
203
+ mime = _FORMAT_MIME.get(options.output_format, "image/png")
204
+ if not pages:
205
+ raise RuntimeError("scan produced no pages (empty feeder?)")
206
+ return ScanResult(pages=pages, mime=mime, backend=self.name, scanner_id=scanner_id)
207
+ finally:
208
+ client.close()
209
+
210
+ def _retrieve_pages(self, client: httpx.Client, job: str) -> list[bytes]:
211
+ pages: list[bytes] = []
212
+ for _ in range(200): # hard cap to avoid runaway feeders
213
+ r = client.get(f"{job}/NextDocument", timeout=120)
214
+ if r.status_code == 404:
215
+ break # no more pages
216
+ if r.status_code == 503:
217
+ time.sleep(1.0) # device still warming up / scanning
218
+ continue
219
+ r.raise_for_status()
220
+ if not r.content:
221
+ break
222
+ pages.append(r.content)
223
+ return pages
@@ -0,0 +1,126 @@
1
+ """SANE backend: wraps the ``scanimage`` CLI (Linux, and macOS via Homebrew).
2
+
3
+ Covers most USB scanners plus many network scanners through SANE's own backends.
4
+ Requires the ``sane-utils`` package (provides ``scanimage``); if it is not on PATH the
5
+ backend reports itself unavailable and is skipped.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ from ..models import ScannerInfo, ScanOptions, ScanResult
18
+ from .base import Backend
19
+
20
+ log = logging.getLogger("scanner_mcp.sane")
21
+
22
+ _MODE = {"color": "Color", "gray": "Gray", "lineart": "Lineart"}
23
+ # scanimage understands png/jpeg/tiff/pdf via --format on recent versions.
24
+ _FORMAT = {"png": ("png", "image/png"), "jpeg": ("jpeg", "image/jpeg"), "pdf": ("pdf", "application/pdf")}
25
+ _DEVICE_RE = re.compile(r"device `([^']+)' is a (.+)")
26
+
27
+
28
+ class SaneBackend(Backend):
29
+ name = "SANE (scanimage)"
30
+ prefix = "sane"
31
+
32
+ def available(self) -> bool:
33
+ return shutil.which("scanimage") is not None
34
+
35
+ def list_scanners(self) -> list[ScannerInfo]:
36
+ try:
37
+ out = subprocess.run(
38
+ ["scanimage", "-L"],
39
+ capture_output=True,
40
+ text=True,
41
+ timeout=20,
42
+ ).stdout
43
+ except Exception as exc:
44
+ log.debug("scanimage -L failed: %s", exc)
45
+ return []
46
+ scanners = []
47
+ for line in out.splitlines():
48
+ m = _DEVICE_RE.search(line)
49
+ if not m:
50
+ continue
51
+ dev, desc = m.group(1), m.group(2)
52
+ conn = "network" if any(t in dev for t in ("net:", "airscan", "escl")) else "usb"
53
+ scanners.append(
54
+ ScannerInfo(
55
+ id=f"sane:{dev}",
56
+ name=desc.strip(),
57
+ backend=self.name,
58
+ connection=conn,
59
+ sources=["platen", "adf"],
60
+ detail={"device": dev},
61
+ )
62
+ )
63
+ return scanners
64
+
65
+ def scan(self, scanner_id: str, options: ScanOptions) -> ScanResult:
66
+ device = scanner_id[len("sane:") :]
67
+ fmt_name, mime = _FORMAT.get(options.output_format, ("png", "image/png"))
68
+ mode = _MODE.get(options.color_mode, "Color")
69
+
70
+ with tempfile.TemporaryDirectory(prefix="scanmcp_") as td:
71
+ tmp = Path(td)
72
+ cmd = [
73
+ "scanimage",
74
+ "-d", device,
75
+ "--format", fmt_name,
76
+ "--resolution", str(options.resolution),
77
+ "--mode", mode,
78
+ ]
79
+ source = self._source_arg(options)
80
+ multipage = options.source in ("adf", "adf-duplex")
81
+ if source:
82
+ cmd += ["--source", source]
83
+
84
+ if multipage:
85
+ # --batch writes out-1.ext, out-2.ext, ... for every fed sheet.
86
+ pattern = str(tmp / f"out-%d.{fmt_name}")
87
+ cmd += ["--batch=" + pattern]
88
+ self._run(cmd, allow_no_docs=True)
89
+ files = sorted(tmp.glob(f"out-*.{fmt_name}"), key=_page_index)
90
+ else:
91
+ out_file = tmp / f"out.{fmt_name}"
92
+ cmd += ["-o", str(out_file)]
93
+ self._run(cmd)
94
+ files = [out_file] if out_file.exists() else []
95
+
96
+ pages = [f.read_bytes() for f in files if f.exists() and f.stat().st_size > 0]
97
+
98
+ if not pages:
99
+ raise RuntimeError("scanimage produced no output (check device/source and that paper is loaded)")
100
+ return ScanResult(pages=pages, mime=mime, backend=self.name, scanner_id=scanner_id)
101
+
102
+ @staticmethod
103
+ def _source_arg(options: ScanOptions) -> str | None:
104
+ if options.source == "platen":
105
+ return "Flatbed"
106
+ if options.source == "adf":
107
+ return "ADF"
108
+ if options.source == "adf-duplex":
109
+ return "ADF Duplex"
110
+ return None # "auto": let the driver decide
111
+
112
+ @staticmethod
113
+ def _run(cmd: list[str], allow_no_docs: bool = False) -> None:
114
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
115
+ if proc.returncode != 0:
116
+ err = (proc.stderr or "").strip()
117
+ # scanimage --batch exits non-zero with "no more documents" once the
118
+ # feeder empties, which is a normal end-of-batch condition.
119
+ if allow_no_docs and re.search(r"no more documents|Document feeder out of documents", err, re.I):
120
+ return
121
+ raise RuntimeError(f"scanimage failed: {err or proc.stdout.strip()}")
122
+
123
+
124
+ def _page_index(p: Path) -> int:
125
+ m = re.search(r"out-(\d+)\.", p.name)
126
+ return int(m.group(1)) if m else 0
@@ -0,0 +1,210 @@
1
+ """TWAIN backend: Windows scanners exposed through the legacy TWAIN API.
2
+
3
+ Complements WIA. Some scanners — often older units, or professional document
4
+ scanners — ship only a TWAIN data source and no usable WIA driver. This backend
5
+ reaches those through the optional ``pytwain`` package (imported as ``twain``).
6
+
7
+ Requirements (all Windows-only):
8
+ * ``pip install "scanner-mcp[twain]"`` (installs pytwain), and
9
+ * a TWAIN **DSM** (Data Source Manager) present on the system:
10
+ - 64-bit Python needs ``TWAINDSM.dll`` (shipped by TWAIN 2.x drivers or the
11
+ TWAIN DSM redistributable), or
12
+ - use 32-bit Python, which can load the classic ``twain_32.dll``.
13
+
14
+ If pytwain is missing or no DSM/driver is installed, the backend simply reports
15
+ itself unavailable and is skipped — the rest of the server keeps working.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import platform
22
+
23
+ from ..models import ScannerInfo, ScanOptions, ScanResult
24
+ from .base import Backend
25
+
26
+ log = logging.getLogger("scanner_mcp.twain")
27
+
28
+ # pytwain logs a noisy INFO/ERROR pair when no TWAIN DSM is installed (the common
29
+ # case on machines with only WIA scanners). Keep our own graceful handling; hush its.
30
+ logging.getLogger("twain").setLevel(logging.CRITICAL)
31
+
32
+ # TWAIN pixel types: TWPT_BW=0, TWPT_GRAY=1, TWPT_RGB=2.
33
+ _PIXELTYPE = {"color": 2, "gray": 1, "lineart": 0}
34
+
35
+
36
+ def _desktop_hwnd():
37
+ """A window handle for the TWAIN source manager; TWAIN requires one."""
38
+ import ctypes
39
+
40
+ try:
41
+ return ctypes.windll.user32.GetDesktopWindow()
42
+ except Exception:
43
+ return 0
44
+
45
+
46
+ class TWAINBackend(Backend):
47
+ name = "TWAIN (Windows)"
48
+ prefix = "twain"
49
+
50
+ def __init__(self):
51
+ self._avail: bool | None = None # cached availability probe
52
+
53
+ # -- availability ---------------------------------------------------------
54
+
55
+ def available(self) -> bool:
56
+ if self._avail is not None:
57
+ return self._avail
58
+ self._avail = self._probe()
59
+ return self._avail
60
+
61
+ def _probe(self) -> bool:
62
+ if platform.system() != "Windows":
63
+ return False
64
+ try:
65
+ import twain # noqa: F401
66
+ except Exception:
67
+ log.debug("pytwain not installed; TWAIN backend disabled")
68
+ return False
69
+ # The DSM only loads if a TWAIN data-source manager is actually installed.
70
+ sm = None
71
+ try:
72
+ sm = self._open_sm()
73
+ return True
74
+ except Exception as exc:
75
+ log.debug("TWAIN DSM not available (%s); backend disabled", exc)
76
+ return False
77
+ finally:
78
+ self._close(sm)
79
+
80
+ # -- helpers --------------------------------------------------------------
81
+
82
+ @staticmethod
83
+ def _open_sm():
84
+ import twain
85
+
86
+ return twain.SourceManager(_desktop_hwnd())
87
+
88
+ @staticmethod
89
+ def _close(obj) -> None:
90
+ if obj is None:
91
+ return
92
+ try:
93
+ obj.close()
94
+ except Exception:
95
+ try:
96
+ obj.destroy()
97
+ except Exception:
98
+ pass
99
+
100
+ @staticmethod
101
+ def _set_cap(src, cap: int, type_id: int, value) -> None:
102
+ """Set a capability, ignoring sources that don't support it."""
103
+ try:
104
+ src.set_capability(cap, type_id, value)
105
+ except Exception as exc:
106
+ log.debug("capability %s not set: %s", cap, exc)
107
+
108
+ # -- API ------------------------------------------------------------------
109
+
110
+ def list_scanners(self) -> list[ScannerInfo]:
111
+ if not self.available():
112
+ return []
113
+ sm = None
114
+ try:
115
+ sm = self._open_sm()
116
+ names = list(sm.source_list)
117
+ except Exception as exc:
118
+ log.debug("TWAIN source enumeration failed: %s", exc)
119
+ return []
120
+ finally:
121
+ self._close(sm)
122
+
123
+ scanners = []
124
+ for name in names:
125
+ scanners.append(
126
+ ScannerInfo(
127
+ id=f"twain:{name}",
128
+ name=name,
129
+ backend=self.name,
130
+ connection="unknown",
131
+ sources=["platen", "adf"],
132
+ detail={"source": name},
133
+ )
134
+ )
135
+ return scanners
136
+
137
+ def scan(self, scanner_id: str, options: ScanOptions) -> ScanResult:
138
+ import twain
139
+
140
+ source_name = scanner_id[len("twain:") :]
141
+ sm = None
142
+ src = None
143
+ try:
144
+ sm = self._open_sm()
145
+ src = sm.open_source(source_name)
146
+ if src is None:
147
+ raise RuntimeError(f"could not open TWAIN source {source_name!r}")
148
+
149
+ self._configure(src, options, twain)
150
+
151
+ # Non-UI acquisition: don't pop the driver's dialog.
152
+ src.request_acquire(show_ui=False, modal_ui=False)
153
+
154
+ raw_pages = self._transfer(src, twain, multipage=options.source in ("adf", "adf-duplex"))
155
+ finally:
156
+ self._close(src)
157
+ self._close(sm)
158
+
159
+ if not raw_pages:
160
+ raise RuntimeError("TWAIN scan produced no images (empty feeder or cancelled?)")
161
+
162
+ # TWAIN native transfer yields BMP (DIB) bytes; normalize to the requested format.
163
+ from ..imaging import convert_image, have_pil, images_to_pdf
164
+
165
+ want_pdf = options.output_format == "pdf"
166
+ img_fmt = "jpeg" if options.output_format == "jpeg" else "png"
167
+ if have_pil():
168
+ pages = [convert_image(p, img_fmt) for p in raw_pages]
169
+ else:
170
+ pages = raw_pages # best effort; bytes are BMP
171
+
172
+ mime = f"image/{img_fmt}"
173
+ if want_pdf:
174
+ pages = [images_to_pdf(pages)]
175
+ mime = "application/pdf"
176
+ return ScanResult(pages=pages, mime=mime, backend=self.name, scanner_id=scanner_id)
177
+
178
+ def _configure(self, src, options: ScanOptions, twain) -> None:
179
+ self._set_cap(src, twain.ICAP_PIXELTYPE, twain.TWTY_UINT16,
180
+ _PIXELTYPE.get(options.color_mode, 2))
181
+ for cap in (twain.ICAP_XRESOLUTION, twain.ICAP_YRESOLUTION):
182
+ self._set_cap(src, cap, twain.TWTY_FIX32, float(options.resolution))
183
+
184
+ use_adf = options.source in ("adf", "adf-duplex")
185
+ self._set_cap(src, twain.CAP_FEEDERENABLED, twain.TWTY_BOOL, 1 if use_adf else 0)
186
+ if options.source == "adf-duplex":
187
+ self._set_cap(src, twain.CAP_DUPLEXENABLED, twain.TWTY_BOOL, 1)
188
+
189
+ @staticmethod
190
+ def _transfer(src, twain, multipage: bool) -> list[bytes]:
191
+ pages: list[bytes] = []
192
+ for _ in range(200): # hard cap against runaway feeders
193
+ try:
194
+ handle, pending = src.xfer_image_natively()
195
+ except Exception as exc:
196
+ # Raised when the source has no (more) images to transfer.
197
+ log.debug("native transfer ended: %s", exc)
198
+ break
199
+ try:
200
+ bmp = twain.dib_to_bm_file(handle) # path=None -> returns BMP bytes
201
+ if bmp:
202
+ pages.append(bmp)
203
+ finally:
204
+ try:
205
+ twain.global_handle_free(handle)
206
+ except Exception:
207
+ pass
208
+ if not multipage or pending == 0:
209
+ break
210
+ return pages