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.
- {scanlib-1.0.0 → scanlib-1.2.0}/PKG-INFO +65 -2
- scanlib-1.0.0/src/scanlib.egg-info/PKG-INFO → scanlib-1.2.0/README.md +41 -22
- {scanlib-1.0.0 → scanlib-1.2.0}/pyproject.toml +31 -1
- {scanlib-1.0.0 → scanlib-1.2.0}/src/accel/_scanlib_accel.c +89 -5
- scanlib-1.2.0/src/scanlib/__init__.py +249 -0
- scanlib-1.2.0/src/scanlib/__main__.py +490 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/_jpeg.py +309 -6
- scanlib-1.2.0/src/scanlib/_mdns.py +520 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/_types.py +177 -6
- scanlib-1.2.0/src/scanlib/backends/_escl.py +839 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/backends/_macos.py +315 -240
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/backends/_sane.py +30 -22
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/backends/_wia.py +101 -36
- scanlib-1.2.0/src/scanlib.egg-info/PKG-INFO +137 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib.egg-info/SOURCES.txt +5 -0
- scanlib-1.2.0/src/scanlib.egg-info/entry_points.txt +2 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/tests/test_jpeg.py +24 -2
- scanlib-1.2.0/tests/test_mdns.py +193 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/tests/test_pdf.py +16 -4
- {scanlib-1.0.0 → scanlib-1.2.0}/tests/test_types.py +254 -1
- scanlib-1.0.0/README.md +0 -53
- scanlib-1.0.0/src/scanlib/__init__.py +0 -96
- {scanlib-1.0.0 → scanlib-1.2.0}/LICENSE +0 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/setup.cfg +0 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/setup.py +0 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib/backends/__init__.py +0 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib.egg-info/dependency_links.txt +0 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib.egg-info/requires.txt +0 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/src/scanlib.egg-info/top_level.txt +0 -0
- {scanlib-1.0.0 → scanlib-1.2.0}/tests/test_hardware.py +0 -0
- {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.
|
|
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,
|
|
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
|
[](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,
|
|
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.
|
|
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] >=
|
|
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
|