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.
- scanner_mcp/__init__.py +3 -0
- scanner_mcp/backends/__init__.py +45 -0
- scanner_mcp/backends/base.py +29 -0
- scanner_mcp/backends/escl.py +223 -0
- scanner_mcp/backends/sane.py +126 -0
- scanner_mcp/backends/twain.py +210 -0
- scanner_mcp/backends/wia.py +218 -0
- scanner_mcp/imaging.py +50 -0
- scanner_mcp/models.py +52 -0
- scanner_mcp/ocr.py +29 -0
- scanner_mcp/server.py +200 -0
- scanner_mcp-0.1.0.dist-info/METADATA +167 -0
- scanner_mcp-0.1.0.dist-info/RECORD +16 -0
- scanner_mcp-0.1.0.dist-info/WHEEL +4 -0
- scanner_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- scanner_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
scanner_mcp/__init__.py
ADDED
|
@@ -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
|