scanlib 1.0.0__tar.gz → 1.2.0__tar.gz

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.
Files changed (31) hide show
  1. {scanlib-1.0.0 → scanlib-1.2.0}/PKG-INFO +65 -2
  2. scanlib-1.0.0/src/scanlib.egg-info/PKG-INFO → scanlib-1.2.0/README.md +41 -22
  3. {scanlib-1.0.0 → scanlib-1.2.0}/pyproject.toml +31 -1
  4. {scanlib-1.0.0 → scanlib-1.2.0}/src/accel/_scanlib_accel.c +89 -5
  5. scanlib-1.2.0/src/scanlib/__init__.py +249 -0
  6. scanlib-1.2.0/src/scanlib/__main__.py +490 -0
  7. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/_jpeg.py +309 -6
  8. scanlib-1.2.0/src/scanlib/_mdns.py +520 -0
  9. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/_types.py +177 -6
  10. scanlib-1.2.0/src/scanlib/backends/_escl.py +839 -0
  11. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/backends/_macos.py +315 -240
  12. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/backends/_sane.py +30 -22
  13. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/backends/_wia.py +101 -36
  14. scanlib-1.2.0/src/scanlib.egg-info/PKG-INFO +137 -0
  15. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib.egg-info/SOURCES.txt +5 -0
  16. scanlib-1.2.0/src/scanlib.egg-info/entry_points.txt +2 -0
  17. {scanlib-1.0.0 → scanlib-1.2.0}/tests/test_jpeg.py +24 -2
  18. scanlib-1.2.0/tests/test_mdns.py +193 -0
  19. {scanlib-1.0.0 → scanlib-1.2.0}/tests/test_pdf.py +16 -4
  20. {scanlib-1.0.0 → scanlib-1.2.0}/tests/test_types.py +254 -1
  21. scanlib-1.0.0/README.md +0 -53
  22. scanlib-1.0.0/src/scanlib/__init__.py +0 -96
  23. {scanlib-1.0.0 → scanlib-1.2.0}/LICENSE +0 -0
  24. {scanlib-1.0.0 → scanlib-1.2.0}/setup.cfg +0 -0
  25. {scanlib-1.0.0 → scanlib-1.2.0}/setup.py +0 -0
  26. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/backends/__init__.py +0 -0
  27. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib.egg-info/dependency_links.txt +0 -0
  28. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib.egg-info/requires.txt +0 -0
  29. {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib.egg-info/top_level.txt +0 -0
  30. {scanlib-1.0.0 → scanlib-1.2.0}/tests/test_hardware.py +0 -0
  31. {scanlib-1.0.0 → scanlib-1.2.0}/tests/test_resolve.py +0 -0
@@ -1,9 +1,32 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scanlib
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: A multiplatform document scanning library for Python
5
5
  Author-email: Angelo Mottola <a.mottola@gmail.com>
6
6
  License: MIT
7
+ Project-URL: Homepage, https://github.com/amottola/scanlib
8
+ Project-URL: Documentation, https://python-scanlib.readthedocs.io/
9
+ Project-URL: Repository, https://github.com/amottola/scanlib
10
+ Project-URL: Changelog, https://github.com/amottola/scanlib/blob/main/CHANGELOG.md
11
+ Project-URL: Issues, https://github.com/amottola/scanlib/issues
12
+ Keywords: scanner,scanning,sane,wia,escl,airscan,twain,pdf,document
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Programming Language :: C
27
+ Classifier: Topic :: Multimedia :: Graphics :: Capture :: Scanners
28
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
29
+ Classifier: Typing :: Typed
7
30
  Requires-Python: >=3.9
8
31
  Description-Content-Type: text/markdown
9
32
  License-File: LICENSE
@@ -30,6 +53,8 @@ A multiplatform document scanning library for Python with platform-native scanni
30
53
  ## Features
31
54
 
32
55
  - **Cross-platform** — unified API across Windows (WIA 2.0), macOS (ImageCaptureCore), and Linux (SANE)
56
+ - **eSCL / AirScan** — direct HTTP scanning of network scanners without OS drivers, enabled automatically on Linux and Windows
57
+ - **Command-line interface** — `scanlib list`, `scanlib info`, and `scanlib scan` for quick scanning from the shell
33
58
  - **Output to PDF** — assemble scanned pages into a PDF and control page encoding (JPEG or PNG)
34
59
  - **Minimal dependencies** — no external image or PDF processing libraries; JPEG uses platform-native encoders, PNG uses stdlib `zlib`, PDF assembly uses only the standard library
35
60
  - **Multi-page scanning** — automatic document feeder support and flatbed multi-page with a simple callback
@@ -37,16 +62,54 @@ A multiplatform document scanning library for Python with platform-native scanni
37
62
  - **Thread-safe** — call from any thread; backend threading is handled internally
38
63
  - **Progress & cancellation** — monitor scan progress and abort mid-scan via callback
39
64
 
65
+ ## Backends
66
+
67
+ | Platform | Backend | Scanner types | eSCL | System packages |
68
+ |---|---|---|---|---|
69
+ | **macOS 10.7+** | ImageCaptureCore | USB + network | Opt-in (`SCANLIB_ESCL=1`) | None |
70
+ | **Windows 10+** | WIA 2.0 | USB | Always enabled | None |
71
+ | **Linux** | SANE | USB | Always enabled | `libsane-dev libjpeg-dev` |
72
+
73
+ The eSCL (AirScan) backend discovers and drives network scanners directly over HTTP — no OS-level scanner drivers needed. On Linux and Windows it runs alongside the platform backend automatically. On macOS, ImageCaptureCore already handles network scanners natively; set `SCANLIB_ESCL=1` to use the eSCL backend instead.
74
+
40
75
  ## Installation
41
76
 
42
77
  ```bash
43
78
  pip install scanlib
44
79
  ```
45
80
 
46
- Python 3.9+. Pre-built wheels available for all major platforms. On Linux, `libsane` and `libjpeg-turbo` are required at runtime (`apt install libsane-dev libturbojpeg0-dev`); on other platforms, no additional dependencies are required.
81
+ Python 3.9+. Pre-built wheels available for all major platforms. On Linux, SANE and libjpeg must be installed at the system level:
82
+
83
+ ```bash
84
+ # Debian / Ubuntu
85
+ apt install libsane-dev libjpeg-dev
86
+
87
+ # Fedora / RHEL
88
+ dnf install sane-backends libjpeg-turbo-devel
89
+ ```
90
+
91
+ On macOS and Windows, no additional system packages are required.
47
92
 
48
93
  ## Quick Start
49
94
 
95
+ ### Command line
96
+
97
+ ```bash
98
+ # List available scanners
99
+ scanlib list
100
+
101
+ # Show scanner capabilities
102
+ scanlib info -s 0
103
+
104
+ # Scan to PDF
105
+ scanlib scan -o document.pdf --dpi 300 --color-mode gray
106
+
107
+ # Multi-page flatbed scan with interactive prompting
108
+ scanlib scan -o multipage.pdf --pages ask
109
+ ```
110
+
111
+ ### Python API
112
+
50
113
  ```python
51
114
  import scanlib
52
115
 
@@ -1,24 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: scanlib
3
- Version: 1.0.0
4
- Summary: A multiplatform document scanning library for Python
5
- Author-email: Angelo Mottola <a.mottola@gmail.com>
6
- License: MIT
7
- Requires-Python: >=3.9
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: comtypes>=1.4; sys_platform == "win32"
11
- Requires-Dist: pyobjc-framework-ImageCaptureCore; sys_platform == "darwin"
12
- Provides-Extra: dev
13
- Requires-Dist: pytest; extra == "dev"
14
- Requires-Dist: pytest-timeout; extra == "dev"
15
- Requires-Dist: pre-commit; extra == "dev"
16
- Provides-Extra: docs
17
- Requires-Dist: sphinx; extra == "docs"
18
- Requires-Dist: furo; extra == "docs"
19
- Requires-Dist: sphinx-autodoc-typehints; extra == "docs"
20
- Dynamic: license-file
21
-
22
1
  # scanlib
23
2
 
24
3
  [![Tests](https://github.com/amottola/scanlib/actions/workflows/test.yml/badge.svg)](https://github.com/amottola/scanlib/actions/workflows/test.yml)
@@ -30,6 +9,8 @@ A multiplatform document scanning library for Python with platform-native scanni
30
9
  ## Features
31
10
 
32
11
  - **Cross-platform** — unified API across Windows (WIA 2.0), macOS (ImageCaptureCore), and Linux (SANE)
12
+ - **eSCL / AirScan** — direct HTTP scanning of network scanners without OS drivers, enabled automatically on Linux and Windows
13
+ - **Command-line interface** — `scanlib list`, `scanlib info`, and `scanlib scan` for quick scanning from the shell
33
14
  - **Output to PDF** — assemble scanned pages into a PDF and control page encoding (JPEG or PNG)
34
15
  - **Minimal dependencies** — no external image or PDF processing libraries; JPEG uses platform-native encoders, PNG uses stdlib `zlib`, PDF assembly uses only the standard library
35
16
  - **Multi-page scanning** — automatic document feeder support and flatbed multi-page with a simple callback
@@ -37,16 +18,54 @@ A multiplatform document scanning library for Python with platform-native scanni
37
18
  - **Thread-safe** — call from any thread; backend threading is handled internally
38
19
  - **Progress & cancellation** — monitor scan progress and abort mid-scan via callback
39
20
 
21
+ ## Backends
22
+
23
+ | Platform | Backend | Scanner types | eSCL | System packages |
24
+ |---|---|---|---|---|
25
+ | **macOS 10.7+** | ImageCaptureCore | USB + network | Opt-in (`SCANLIB_ESCL=1`) | None |
26
+ | **Windows 10+** | WIA 2.0 | USB | Always enabled | None |
27
+ | **Linux** | SANE | USB | Always enabled | `libsane-dev libjpeg-dev` |
28
+
29
+ The eSCL (AirScan) backend discovers and drives network scanners directly over HTTP — no OS-level scanner drivers needed. On Linux and Windows it runs alongside the platform backend automatically. On macOS, ImageCaptureCore already handles network scanners natively; set `SCANLIB_ESCL=1` to use the eSCL backend instead.
30
+
40
31
  ## Installation
41
32
 
42
33
  ```bash
43
34
  pip install scanlib
44
35
  ```
45
36
 
46
- Python 3.9+. Pre-built wheels available for all major platforms. On Linux, `libsane` and `libjpeg-turbo` are required at runtime (`apt install libsane-dev libturbojpeg0-dev`); on other platforms, no additional dependencies are required.
37
+ Python 3.9+. Pre-built wheels available for all major platforms. On Linux, SANE and libjpeg must be installed at the system level:
38
+
39
+ ```bash
40
+ # Debian / Ubuntu
41
+ apt install libsane-dev libjpeg-dev
42
+
43
+ # Fedora / RHEL
44
+ dnf install sane-backends libjpeg-turbo-devel
45
+ ```
46
+
47
+ On macOS and Windows, no additional system packages are required.
47
48
 
48
49
  ## Quick Start
49
50
 
51
+ ### Command line
52
+
53
+ ```bash
54
+ # List available scanners
55
+ scanlib list
56
+
57
+ # Show scanner capabilities
58
+ scanlib info -s 0
59
+
60
+ # Scan to PDF
61
+ scanlib scan -o document.pdf --dpi 300 --color-mode gray
62
+
63
+ # Multi-page flatbed scan with interactive prompting
64
+ scanlib scan -o multipage.pdf --pages ask
65
+ ```
66
+
67
+ ### Python API
68
+
50
69
  ```python
51
70
  import scanlib
52
71
 
@@ -4,17 +4,47 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scanlib"
7
- version = "1.0.0"
7
+ version = "1.2.0"
8
8
  description = "A multiplatform document scanning library for Python"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
11
11
  authors = [{name = "Angelo Mottola", email = "a.mottola@gmail.com"}]
12
+ keywords = ["scanner", "scanning", "sane", "wia", "escl", "airscan", "twain", "pdf", "document"]
13
+ classifiers = [
14
+ "Development Status :: 5 - Production/Stable",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: MacOS",
18
+ "Operating System :: Microsoft :: Windows",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Programming Language :: C",
28
+ "Topic :: Multimedia :: Graphics :: Capture :: Scanners",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ "Typing :: Typed",
31
+ ]
12
32
  requires-python = ">=3.9"
13
33
  dependencies = [
14
34
  "comtypes>=1.4; sys_platform == 'win32'",
15
35
  "pyobjc-framework-ImageCaptureCore; sys_platform == 'darwin'",
16
36
  ]
17
37
 
38
+ [project.urls]
39
+ Homepage = "https://github.com/amottola/scanlib"
40
+ Documentation = "https://python-scanlib.readthedocs.io/"
41
+ Repository = "https://github.com/amottola/scanlib"
42
+ Changelog = "https://github.com/amottola/scanlib/blob/main/CHANGELOG.md"
43
+ Issues = "https://github.com/amottola/scanlib/issues"
44
+
45
+ [project.scripts]
46
+ scanlib = "scanlib.__main__:main"
47
+
18
48
  [project.optional-dependencies]
19
49
  dev = ["pytest", "pytest-timeout", "pre-commit"]
20
50
  docs = ["sphinx", "furo", "sphinx-autodoc-typehints"]
@@ -99,11 +99,17 @@ static PyObject *py_rgb_to_bgr(PyObject *Py_UNUSED(self), PyObject *args) {
99
99
  static PyObject *py_gray_to_bw(PyObject *Py_UNUSED(self), PyObject *args) {
100
100
  const char *data;
101
101
  Py_ssize_t data_len;
102
- int width, height;
102
+ int width, height, threshold = 128;
103
103
 
104
- if (!PyArg_ParseTuple(args, "y#ii", &data, &data_len, &width, &height))
104
+ if (!PyArg_ParseTuple(args, "y#ii|i", &data, &data_len, &width, &height,
105
+ &threshold))
105
106
  return NULL;
106
107
 
108
+ if (threshold < 0 || threshold > 255) {
109
+ PyErr_SetString(PyExc_ValueError, "threshold must be 0-255");
110
+ return NULL;
111
+ }
112
+
107
113
  Py_ssize_t count = (Py_ssize_t)width * height;
108
114
  if (data_len < count) {
109
115
  PyErr_SetString(PyExc_ValueError, "pixel buffer too small");
@@ -130,7 +136,7 @@ static PyObject *py_gray_to_bw(PyObject *Py_UNUSED(self), PyObject *args) {
130
136
  int bits = width - x;
131
137
  if (bits > 8) bits = 8;
132
138
  for (int bit = 0; bit < bits; bit++) {
133
- if (src[src_off + x + bit] >= 64)
139
+ if (src[src_off + x + bit] >= threshold)
134
140
  byte_val |= (unsigned char)(0x80 >> bit);
135
141
  }
136
142
  dst[dst_off + x / 8] = byte_val;
@@ -704,6 +710,79 @@ static PyObject *py_encode_jpeg(PyObject *Py_UNUSED(self), PyObject *args) {
704
710
  return result;
705
711
  }
706
712
 
713
+ /* ------------------------------------------------------------------ */
714
+ /* decode_jpeg (Linux only, requires libjpeg) */
715
+ /* ------------------------------------------------------------------ */
716
+
717
+ static PyObject *py_decode_jpeg(PyObject *Py_UNUSED(self), PyObject *args) {
718
+ const char *data;
719
+ Py_ssize_t data_len;
720
+
721
+ if (!PyArg_ParseTuple(args, "y#", &data, &data_len))
722
+ return NULL;
723
+
724
+ if (data_len < 2) {
725
+ PyErr_SetString(PyExc_ValueError, "data too short for JPEG");
726
+ return NULL;
727
+ }
728
+
729
+ struct jpeg_decompress_struct cinfo;
730
+ struct my_error_mgr jerr;
731
+ volatile PyThreadState *gil_state = NULL;
732
+
733
+ cinfo.err = jpeg_std_error(&jerr.pub);
734
+ jerr.pub.error_exit = my_error_exit;
735
+
736
+ if (setjmp(jerr.setjmp_buffer)) {
737
+ if (gil_state)
738
+ PyEval_RestoreThread((PyThreadState *)gil_state);
739
+ jpeg_destroy_decompress(&cinfo);
740
+ PyErr_SetString(PyExc_RuntimeError, "libjpeg decoding error");
741
+ return NULL;
742
+ }
743
+
744
+ jpeg_create_decompress(&cinfo);
745
+ jpeg_mem_src(&cinfo, (const unsigned char *)data, (unsigned long)data_len);
746
+ jpeg_read_header(&cinfo, TRUE);
747
+
748
+ /* Force RGB or grayscale output */
749
+ if (cinfo.num_components == 1) {
750
+ cinfo.out_color_space = JCS_GRAYSCALE;
751
+ } else {
752
+ cinfo.out_color_space = JCS_RGB;
753
+ }
754
+
755
+ jpeg_start_decompress(&cinfo);
756
+
757
+ int width = (int)cinfo.output_width;
758
+ int height = (int)cinfo.output_height;
759
+ int components = (int)cinfo.output_components;
760
+ Py_ssize_t row_stride = (Py_ssize_t)width * components;
761
+ Py_ssize_t total = row_stride * height;
762
+
763
+ PyObject *result = PyBytes_FromStringAndSize(NULL, total);
764
+ if (!result) {
765
+ jpeg_destroy_decompress(&cinfo);
766
+ return NULL;
767
+ }
768
+
769
+ unsigned char *dst = (unsigned char *)PyBytes_AsString(result);
770
+
771
+ gil_state = PyEval_SaveThread();
772
+ while (cinfo.output_scanline < cinfo.output_height) {
773
+ unsigned char *row = dst + (Py_ssize_t)cinfo.output_scanline * row_stride;
774
+ JSAMPROW row_ptr = (JSAMPROW)row;
775
+ jpeg_read_scanlines(&cinfo, &row_ptr, 1);
776
+ }
777
+ PyEval_RestoreThread((PyThreadState *)gil_state);
778
+ gil_state = NULL;
779
+
780
+ jpeg_finish_decompress(&cinfo);
781
+ jpeg_destroy_decompress(&cinfo);
782
+
783
+ return Py_BuildValue("(Niii)", result, width, height, components);
784
+ }
785
+
707
786
  #endif /* HAVE_JPEGLIB */
708
787
 
709
788
  /* ------------------------------------------------------------------ */
@@ -718,8 +797,9 @@ static PyMethodDef methods[] = {
718
797
  "rgb_to_bgr(data, width, height) -> bytes\n"
719
798
  "Convert 8-bit interleaved RGB to BGR (swap R and B channels)."},
720
799
  {"gray_to_bw", py_gray_to_bw, METH_VARARGS,
721
- "gray_to_bw(data, width, height) -> bytes\n"
722
- "Convert 8-bit grayscale to 1-bit packed (MSB first)."},
800
+ "gray_to_bw(data, width, height, threshold=128) -> bytes\n"
801
+ "Convert 8-bit grayscale to 1-bit packed (MSB first).\n"
802
+ "Pixels >= threshold become white (1), below become black (0)."},
723
803
  {"bw_to_gray", py_bw_to_gray, METH_VARARGS,
724
804
  "bw_to_gray(data, width, height) -> bytes\n"
725
805
  "Convert 1-bit packed (MSB first) to 8-bit grayscale."},
@@ -741,6 +821,10 @@ static PyMethodDef methods[] = {
741
821
  {"encode_jpeg", py_encode_jpeg, METH_VARARGS,
742
822
  "encode_jpeg(data, width, height, num_components, quality) -> bytes\n"
743
823
  "Encode raw pixels to JPEG using libjpeg."},
824
+ {"decode_jpeg", py_decode_jpeg, METH_VARARGS,
825
+ "decode_jpeg(data) -> tuple[bytes, int, int, int]\n"
826
+ "Decode JPEG bytes to raw pixels. Returns "
827
+ "(raw_data, width, height, num_components)."},
744
828
  #endif
745
829
  {NULL, NULL, 0, NULL}
746
830
  };
@@ -0,0 +1,249 @@
1
+ """scanlib — A multiplatform document scanning library for Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import threading
8
+ from collections.abc import Iterator
9
+ from importlib.metadata import version
10
+
11
+ __version__ = version("scanlib")
12
+
13
+ from ._types import (
14
+ DISCOVERY_TIMEOUT,
15
+ BackendNotAvailableError,
16
+ ColorMode,
17
+ FeederEmptyError,
18
+ ImageFormat,
19
+ NoScannerFoundError,
20
+ ScanArea,
21
+ ScanAborted,
22
+ ScanBackend,
23
+ ScanError,
24
+ ScanLibError,
25
+ Scanner,
26
+ ScannerDefaults,
27
+ ScannerNotOpenError,
28
+ ScanOptions,
29
+ ScanSource,
30
+ ScannedDocument,
31
+ ScannedPage,
32
+ SourceInfo,
33
+ build_pdf,
34
+ )
35
+
36
+ __all__ = [
37
+ "list_scanners",
38
+ "open_scanner",
39
+ "build_pdf",
40
+ "DISCOVERY_TIMEOUT",
41
+ "ColorMode",
42
+ "ImageFormat",
43
+ "ScanArea",
44
+ "Scanner",
45
+ "ScannerDefaults",
46
+ "ScannerNotOpenError",
47
+ "ScanOptions",
48
+ "ScanSource",
49
+ "ScannedDocument",
50
+ "ScannedPage",
51
+ "SourceInfo",
52
+ "ScanLibError",
53
+ "ScanError",
54
+ "ScanAborted",
55
+ "FeederEmptyError",
56
+ "NoScannerFoundError",
57
+ "BackendNotAvailableError",
58
+ ]
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Composite backend — merges a platform backend with eSCL
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ class _CompositeBackend:
67
+ """Merges results from a platform backend and the eSCL backend.
68
+
69
+ ``list_scanners`` runs both backends' discovery in parallel and
70
+ deduplicates by IP address. Each scanner's ``_backend_impl`` points
71
+ to whichever backend discovered it.
72
+ """
73
+
74
+ def __init__(self, platform_backend: ScanBackend) -> None:
75
+ from .backends._escl import EsclBackend
76
+
77
+ self._platform = platform_backend
78
+ self._escl = EsclBackend()
79
+
80
+ def list_scanners(
81
+ self,
82
+ timeout: float = DISCOVERY_TIMEOUT,
83
+ cancel: threading.Event | None = None,
84
+ ) -> list[Scanner]:
85
+ # Run both discoveries in parallel.
86
+ platform_box: list[list[Scanner]] = [[]]
87
+ escl_box: list[list[Scanner]] = [[]]
88
+
89
+ def _run_platform() -> None:
90
+ try:
91
+ platform_box[0] = self._platform.list_scanners(
92
+ timeout=timeout, cancel=cancel
93
+ )
94
+ except Exception:
95
+ platform_box[0] = []
96
+
97
+ def _run_escl() -> None:
98
+ try:
99
+ escl_box[0] = self._escl.list_scanners(timeout=timeout, cancel=cancel)
100
+ except Exception:
101
+ escl_box[0] = []
102
+
103
+ t_platform = threading.Thread(target=_run_platform, daemon=True)
104
+ t_escl = threading.Thread(target=_run_escl, daemon=True)
105
+ t_platform.start()
106
+ t_escl.start()
107
+
108
+ # Wait for eSCL first (fast, ~4s max), then give the platform
109
+ # backend a short grace period to finish. Don't block on a
110
+ # slow platform backend when eSCL already has results.
111
+ t_escl.join(timeout=timeout + 2)
112
+ if t_platform.is_alive():
113
+ t_platform.join(timeout=2.0)
114
+
115
+ if cancel is not None and cancel.is_set():
116
+ return []
117
+
118
+ platform_scanners = platform_box[0]
119
+ escl_scanners = escl_box[0]
120
+
121
+ if not escl_scanners:
122
+ return platform_scanners
123
+
124
+ # Collect IPs from platform scanners for deduplication
125
+ from ._mdns import extract_ip_from_uri
126
+
127
+ platform_ips: set[str] = set()
128
+ for s in platform_scanners:
129
+ ip = extract_ip_from_uri(s.name)
130
+ if ip:
131
+ platform_ips.add(ip)
132
+
133
+ # Add eSCL scanners not already found by the platform backend
134
+ escl_ips = self._escl.get_scanner_ips()
135
+ for s in escl_scanners:
136
+ ip = escl_ips.get(s.id)
137
+ if ip and ip in platform_ips:
138
+ continue # already discovered by platform backend
139
+ platform_scanners.append(s)
140
+
141
+ return platform_scanners
142
+
143
+ # Delegate remaining methods to the scanner's own _backend_impl
144
+ def open_scanner(self, scanner: Scanner) -> None:
145
+ scanner._backend_impl.open_scanner(scanner)
146
+
147
+ def close_scanner(self, scanner: Scanner) -> None:
148
+ scanner._backend_impl.close_scanner(scanner)
149
+
150
+ def scan_pages(
151
+ self, scanner: Scanner, options: ScanOptions
152
+ ) -> Iterator[ScannedPage]:
153
+ return scanner._backend_impl.scan_pages(scanner, options)
154
+
155
+ def abort_scan(self, scanner: Scanner) -> None:
156
+ scanner._backend_impl.abort_scan(scanner)
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Backend selection
161
+ # ---------------------------------------------------------------------------
162
+
163
+ _backend: ScanBackend | None = None
164
+
165
+
166
+ def _get_backend() -> ScanBackend:
167
+ """Return the appropriate scanning backend for the current platform."""
168
+ global _backend
169
+ if _backend is not None:
170
+ return _backend
171
+
172
+ if sys.platform == "linux":
173
+ from .backends._sane import SaneBackend
174
+
175
+ _backend = _CompositeBackend(SaneBackend())
176
+
177
+ elif sys.platform == "darwin":
178
+ from .backends._macos import MacOSBackend
179
+
180
+ if os.environ.get("SCANLIB_ESCL", "").strip() == "1":
181
+ _backend = _CompositeBackend(MacOSBackend())
182
+ else:
183
+ _backend = MacOSBackend()
184
+
185
+ elif sys.platform == "win32":
186
+ from .backends._wia import WiaBackend
187
+
188
+ _backend = _CompositeBackend(WiaBackend())
189
+
190
+ else:
191
+ raise BackendNotAvailableError(f"Unsupported platform: {sys.platform}")
192
+
193
+ return _backend
194
+
195
+
196
+ def list_scanners(
197
+ *,
198
+ timeout: float = DISCOVERY_TIMEOUT,
199
+ cancel: threading.Event | None = None,
200
+ ) -> list[Scanner]:
201
+ """Return all available scanners on the current platform.
202
+
203
+ *timeout* controls how long (in seconds) to wait for scanner
204
+ discovery. The default is :data:`DISCOVERY_TIMEOUT` (15 s).
205
+
206
+ *cancel*, if given, is a :class:`threading.Event` that the caller can
207
+ set from another thread to interrupt discovery early. When the event
208
+ is set the function returns immediately with an empty list.
209
+
210
+ The returned :class:`Scanner` objects are lightweight — no device sessions
211
+ are opened. Use :meth:`Scanner.open` (or the context-manager protocol)
212
+ to start a session before scanning.
213
+ """
214
+ return _get_backend().list_scanners(timeout=timeout, cancel=cancel)
215
+
216
+
217
+ def open_scanner(scanner_id: str) -> Scanner:
218
+ """Open a scanner directly by its ID, without discovery.
219
+
220
+ *scanner_id* is the :attr:`Scanner.id` string obtained from a
221
+ previous :func:`list_scanners` call (e.g. ``"escl:192.168.1.5:443"``
222
+ for eSCL, a SANE device URI, or a WIA device ID).
223
+
224
+ Returns an **opened** :class:`Scanner` ready for scanning. The
225
+ caller must call :meth:`Scanner.close` (or use the context-manager
226
+ protocol) when done.
227
+
228
+ This avoids the latency of scanner discovery when the ID is already
229
+ known.
230
+ """
231
+ if scanner_id.startswith("escl:"):
232
+ from .backends._escl import EsclBackend
233
+
234
+ impl = EsclBackend()
235
+ else:
236
+ # Use the platform backend
237
+ top = _get_backend()
238
+ impl = top._platform if isinstance(top, _CompositeBackend) else top
239
+
240
+ scanner = Scanner(
241
+ name=scanner_id,
242
+ vendor=None,
243
+ model=None,
244
+ backend=impl.backend_name,
245
+ scanner_id=scanner_id,
246
+ _backend_impl=impl,
247
+ )
248
+ scanner.open()
249
+ return scanner