scanlib 1.3.0__tar.gz → 1.3.1__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 (30) hide show
  1. {scanlib-1.3.0/src/scanlib.egg-info → scanlib-1.3.1}/PKG-INFO +1 -1
  2. {scanlib-1.3.0 → scanlib-1.3.1}/pyproject.toml +1 -1
  3. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/__init__.py +2 -0
  4. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/_types.py +18 -0
  5. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/backends/_escl.py +54 -5
  6. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/backends/_macos.py +190 -89
  7. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/backends/_sane.py +39 -12
  8. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/backends/_wia.py +108 -45
  9. {scanlib-1.3.0 → scanlib-1.3.1/src/scanlib.egg-info}/PKG-INFO +1 -1
  10. {scanlib-1.3.0 → scanlib-1.3.1}/LICENSE +0 -0
  11. {scanlib-1.3.0 → scanlib-1.3.1}/README.md +0 -0
  12. {scanlib-1.3.0 → scanlib-1.3.1}/setup.cfg +0 -0
  13. {scanlib-1.3.0 → scanlib-1.3.1}/setup.py +0 -0
  14. {scanlib-1.3.0 → scanlib-1.3.1}/src/accel/_scanlib_accel.c +0 -0
  15. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/__main__.py +0 -0
  16. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/_jpeg.py +0 -0
  17. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/_mdns.py +0 -0
  18. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib/backends/__init__.py +0 -0
  19. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib.egg-info/SOURCES.txt +0 -0
  20. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib.egg-info/dependency_links.txt +0 -0
  21. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib.egg-info/entry_points.txt +0 -0
  22. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib.egg-info/requires.txt +0 -0
  23. {scanlib-1.3.0 → scanlib-1.3.1}/src/scanlib.egg-info/top_level.txt +0 -0
  24. {scanlib-1.3.0 → scanlib-1.3.1}/tests/test_composite.py +0 -0
  25. {scanlib-1.3.0 → scanlib-1.3.1}/tests/test_hardware.py +0 -0
  26. {scanlib-1.3.0 → scanlib-1.3.1}/tests/test_jpeg.py +0 -0
  27. {scanlib-1.3.0 → scanlib-1.3.1}/tests/test_mdns.py +0 -0
  28. {scanlib-1.3.0 → scanlib-1.3.1}/tests/test_pdf.py +0 -0
  29. {scanlib-1.3.0 → scanlib-1.3.1}/tests/test_resolve.py +0 -0
  30. {scanlib-1.3.0 → scanlib-1.3.1}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scanlib
3
- Version: 1.3.0
3
+ Version: 1.3.1
4
4
  Summary: A multiplatform document scanning library for Python
5
5
  Author-email: Angelo Mottola <a.mottola@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scanlib"
7
- version = "1.3.0"
7
+ version = "1.3.1"
8
8
  description = "A multiplatform document scanning library for Python"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -26,6 +26,7 @@ from ._types import (
26
26
  ScannerBusyError,
27
27
  ScannerDefaults,
28
28
  ScannerNotOpenError,
29
+ ScannerUnavailableError,
29
30
  ScanOptions,
30
31
  ScanSource,
31
32
  ScannedDocument,
@@ -54,6 +55,7 @@ __all__ = [
54
55
  "ScanError",
55
56
  "ScanAborted",
56
57
  "ScannerBusyError",
58
+ "ScannerUnavailableError",
57
59
  "FeederEmptyError",
58
60
  "NoScannerFoundError",
59
61
  "BackendNotAvailableError",
@@ -47,6 +47,24 @@ class ScannerBusyError(ScanError):
47
47
  super().__init__(message)
48
48
 
49
49
 
50
+ class ScannerUnavailableError(ScanError):
51
+ """The scanner could not be reached (offline, asleep, or disconnected).
52
+
53
+ Unlike :class:`ScannerBusyError`, the device is not held by another
54
+ session — it simply did not respond. This is often transient (a network
55
+ scanner that has gone to sleep) and worth retrying once the device is
56
+ reachable again.
57
+ """
58
+
59
+ _DEFAULT = (
60
+ "Scanner is unavailable — it may be offline, asleep, or disconnected. "
61
+ "Check that it is powered on and reachable, then try again."
62
+ )
63
+
64
+ def __init__(self, message: str = _DEFAULT) -> None:
65
+ super().__init__(message)
66
+
67
+
50
68
  class ScannerNotOpenError(ScanLibError):
51
69
  """Operation requires an open scanner session."""
52
70
 
@@ -27,14 +27,17 @@ from collections.abc import Iterator
27
27
  from urllib.parse import urlparse
28
28
 
29
29
  from .._jpeg import decode_jpeg
30
- from .._mdns import EsclServiceInfo, discover_escl_services, extract_ip_from_uri
30
+ from .._mdns import discover_escl_services
31
31
  from .._types import (
32
32
  DISCOVERY_TIMEOUT,
33
33
  ColorMode,
34
+ FeederEmptyError,
34
35
  ScanAborted,
35
36
  ScanArea,
36
37
  ScanError,
38
+ ScanLibError,
37
39
  ScannerBusyError,
40
+ ScannerUnavailableError,
38
41
  ScannedPage,
39
42
  Scanner,
40
43
  ScannerDefaults,
@@ -156,6 +159,26 @@ class _EsclConnection:
156
159
  state_el = root.find(_ns(_SCAN_NS, "State"))
157
160
  return state_el.text if state_el is not None and state_el.text else "Unknown"
158
161
 
162
+ def get_adf_state(self) -> str | None:
163
+ """Return the feeder (ADF) state from ScannerStatus, or ``None``.
164
+
165
+ eSCL reports ``<scan:AdfState>`` as e.g. ``ScannerAdfProcessing``,
166
+ ``ScannerAdfLoaded``, or ``ScannerAdfEmpty``. Returns ``None`` when
167
+ the scanner doesn't report it (best-effort — not all models do).
168
+ """
169
+ conn = self._connect()
170
+ conn.request("GET", f"{self.base_path}/ScannerStatus")
171
+ resp = conn.getresponse()
172
+ body = resp.read()
173
+ if resp.status != 200:
174
+ return None
175
+ try:
176
+ root = ET.fromstring(body)
177
+ except ET.ParseError:
178
+ return None
179
+ el = next(root.iter(_ns(_SCAN_NS, "AdfState")), None)
180
+ return el.text.strip() if el is not None and el.text else None
181
+
159
182
  def cancel_active_jobs(self) -> int:
160
183
  """Cancel any active jobs on the scanner. Returns count cancelled."""
161
184
  conn = self._connect()
@@ -186,7 +209,6 @@ class _EsclConnection:
186
209
  """
187
210
  import time
188
211
 
189
- last_status = 0
190
212
  for attempt in range(retries):
191
213
  conn = self._connect()
192
214
  conn.request(
@@ -197,7 +219,6 @@ class _EsclConnection:
197
219
  )
198
220
  resp = conn.getresponse()
199
221
  resp.read() # drain body
200
- last_status = resp.status
201
222
  if resp.status in (409, 503):
202
223
  if attempt < retries - 1:
203
224
  # Try cancelling any stale jobs before retrying
@@ -570,11 +591,10 @@ def _decode_png(data: bytes) -> tuple[bytes, int, int, int]:
570
591
  raise ValueError(f"Unsupported PNG color type: {color_type}")
571
592
 
572
593
  row_bytes = width * components
573
- # Remove filter byte from each row
594
+ # Skip the per-row filter byte (decoder assumes filter type 0 / None).
574
595
  pixels = bytearray()
575
596
  for y in range(height):
576
597
  offset = y * (row_bytes + 1)
577
- _filter_byte = raw_filtered[offset]
578
598
  pixels.extend(raw_filtered[offset + 1 : offset + 1 + row_bytes])
579
599
 
580
600
  return bytes(pixels), width, height, components
@@ -732,6 +752,17 @@ class EsclBackend:
732
752
 
733
753
  try:
734
754
  caps_xml = conn.get_capabilities()
755
+ except ScanLibError:
756
+ # Already a typed scanlib error (busy/unavailable/…) — preserve it.
757
+ raise
758
+ except OSError as exc:
759
+ # Socket-level failure: host unreachable, connection refused, or
760
+ # timed out — the scanner is offline, asleep, or disconnected.
761
+ raise ScannerUnavailableError(
762
+ f"Scanner at {conn.ip}:{conn.port} is unavailable — it may be "
763
+ "offline, asleep, or disconnected. Check that it is powered on "
764
+ "and reachable, then try again."
765
+ ) from exc
735
766
  except Exception as exc:
736
767
  raise ScanError(f"Failed to get scanner capabilities: {exc}") from exc
737
768
 
@@ -769,6 +800,16 @@ class EsclBackend:
769
800
 
770
801
  check_progress(options.progress, 0)
771
802
 
803
+ # For the feeder, check the ADF state before creating a job. A
804
+ # scanner asked to scan from an empty feeder would otherwise
805
+ # physically engage the ADF and, on some models, keep producing
806
+ # (blank) pages instead of cleanly reporting "no documents". This
807
+ # is best-effort: only an explicit "empty" report short-circuits;
808
+ # scanners that don't report AdfState fall through to the job (and
809
+ # the page_num == 0 check below still catches an empty feeder).
810
+ if is_feeder and conn.get_adf_state() == "ScannerAdfEmpty":
811
+ raise FeederEmptyError("No documents in feeder")
812
+
772
813
  try:
773
814
  job_path = conn.create_job(settings_xml)
774
815
  except ScanError:
@@ -812,6 +853,14 @@ class EsclBackend:
812
853
  if not is_feeder:
813
854
  break # flatbed: one page per job
814
855
 
856
+ if page_num == 0:
857
+ # A feeder job that produced nothing means the feeder was
858
+ # empty; a flatbed job with no data is a scan failure. Match
859
+ # the SANE/macOS/WIA backends rather than yielding nothing.
860
+ if is_feeder:
861
+ raise FeederEmptyError("No documents in feeder")
862
+ raise ScanError("Scan completed but no image data was received")
863
+
815
864
  except (ScanAborted, ScanError):
816
865
  conn.delete_job(job_path)
817
866
  raise
@@ -24,6 +24,7 @@ from .._types import (
24
24
  ScanAborted,
25
25
  ScanError,
26
26
  ScannerBusyError,
27
+ ScannerUnavailableError,
27
28
  ScannedPage,
28
29
  Scanner,
29
30
  ScannerDefaults,
@@ -51,12 +52,38 @@ _COLOR_MODE_TO_PIXEL_DATA_TYPE = {
51
52
  # ICScannerTransferMode
52
53
  _TRANSFER_MODE_MEMORY_BASED = 1
53
54
 
54
- # NSError codes (in the com.apple.imagecapture domain) that mean the device is
55
- # already in use by another session/application and cannot be opened. -47 is
56
- # the classic macOS ``fBsyErr`` (resource busy), which is what real network
57
- # scanners (e.g. Brother) return when another app holds the session; the
58
- # -9924/-9925 ICReturn codes are the documented in-use values.
59
- _BUSY_ERROR_CODES = frozenset({-47, -9924, -9925})
55
+ # ImageCaptureCore status codes (signed 32-bit), from ICReturnCodes.h. All
56
+ # error classification is done by code, never by parsing the (localised,
57
+ # vendor-specific) NSError message string.
58
+ _FBSY_ERR = -47 # classic macOS fBsyErr (resource busy)
59
+ _IC_SCAN_OPERATION_CANCELED = -9924 # ICReturnScanOperationCanceled
60
+ _IC_SCANNER_IN_USE_BY_LOCAL_USER = -9925 # ICReturnScannerInUseByLocalUser
61
+ _IC_SCANNER_IN_USE_BY_REMOTE_USER = -9926 # ICReturnScannerInUseByRemoteUser
62
+ # Legacy ICLegacyReturnCode* communication-layer failures.
63
+ _IC_LEGACY_COMMUNICATION_ERR = -9900 # ICLegacyReturnCodeCommunicationErr
64
+ _IC_LEGACY_DEVICE_NOT_FOUND_ERR = -9901 # ICLegacyReturnCodeDeviceNotFoundErr
65
+ _IC_LEGACY_DEVICE_NOT_OPEN_ERR = -9902 # ICLegacyReturnCodeDeviceNotOpenErr
66
+
67
+ # Device is already in use by another session/application and cannot be opened.
68
+ # -47 is what real network scanners (e.g. Brother) return when another app holds
69
+ # the session; -9925/-9926 are ImageCaptureCore's documented in-use values.
70
+ _BUSY_ERROR_CODES = frozenset(
71
+ {_FBSY_ERR, _IC_SCANNER_IN_USE_BY_LOCAL_USER, _IC_SCANNER_IN_USE_BY_REMOTE_USER}
72
+ )
73
+
74
+ # The device object exists (it was discovered) but the hardware can't be
75
+ # reached — offline, asleep, or disconnected. Distinct from busy: the device
76
+ # isn't held by anyone, it just didn't answer.
77
+ _UNAVAILABLE_ERROR_CODES = frozenset(
78
+ {
79
+ _IC_LEGACY_COMMUNICATION_ERR,
80
+ _IC_LEGACY_DEVICE_NOT_FOUND_ERR,
81
+ _IC_LEGACY_DEVICE_NOT_OPEN_ERR,
82
+ }
83
+ )
84
+
85
+ # Scan was cancelled (by the user at the device, or programmatically).
86
+ _CANCEL_ERROR_CODES = frozenset({_IC_SCAN_OPERATION_CANCELED})
60
87
 
61
88
 
62
89
  def _normalize_error_code(code: int | None) -> int | None:
@@ -74,6 +101,14 @@ def _normalize_error_code(code: int | None) -> int | None:
74
101
  return code
75
102
 
76
103
 
104
+ def _error_code(error) -> int | None:
105
+ """Return the normalized signed status code of an NSError, or None."""
106
+ try:
107
+ return _normalize_error_code(int(error.code()))
108
+ except Exception:
109
+ return None
110
+
111
+
77
112
  def pump_run_loop(duration: float) -> None:
78
113
  """Run the current thread's run loop for up to *duration* seconds.
79
114
 
@@ -197,10 +232,7 @@ class _ScanDelegate(NSObject):
197
232
  def device_didOpenSessionWithError_(self, device, error):
198
233
  if error:
199
234
  self.error = str(error)
200
- try:
201
- self.error_code = _normalize_error_code(int(error.code()))
202
- except Exception:
203
- self.error_code = None
235
+ self.error_code = _error_code(error)
204
236
  else:
205
237
  self.session_open = True
206
238
  self._open_event.set()
@@ -238,6 +270,7 @@ class _ScanDelegate(NSObject):
238
270
  def scannerDevice_didCompleteScanWithError_(self, device, error):
239
271
  if error:
240
272
  self.error = str(error)
273
+ self.error_code = _error_code(error)
241
274
  self._scan_done.set()
242
275
 
243
276
  def device_didCloseSessionWithError_(self, device, error):
@@ -439,6 +472,34 @@ def _safe_str(dev, attr: str) -> str | None:
439
472
  return None
440
473
 
441
474
 
475
+ def _name_and_location(dev) -> tuple[str | None, str | None]:
476
+ """Compute the (display name, location) pair for an ICDevice.
477
+
478
+ ImageCaptureCore's ``locationDescription`` is *not* a physical location
479
+ for network scanners: over TCP/IP it returns the device's Bonjour name —
480
+ the label macOS itself shows (e.g. ``"PLC"``, ``"Piano 1°"``) — while
481
+ ``name`` is only the generic model series (e.g. ``"EPSON … Series"``).
482
+ So for network devices the location string is really the display name;
483
+ surface it as such and leave ``location`` unset rather than echoing the
484
+ same value in both fields.
485
+
486
+ For USB/FireWire/etc. devices ``locationDescription`` is the standard bus
487
+ descriptor (``"USB"``, …) — a genuine location — so keep it as the
488
+ location and use the device name as the display name.
489
+ """
490
+ name = _safe_str(dev, "name")
491
+ loc = _safe_str(dev, "locationDescription")
492
+ try:
493
+ transport = dev.transportType()
494
+ except Exception:
495
+ transport = None
496
+ if transport is not None and transport == ImageCaptureCore.ICTransportTypeTCPIP:
497
+ # Bonjour name: the OS-displayed name, not a location.
498
+ return (loc or name), None
499
+ # Bus-attached device: locationDescription is the bus type.
500
+ return name, (loc if loc and loc != name else None)
501
+
502
+
442
503
  class MacOSBackend:
443
504
  backend_name = "imagecapture"
444
505
  """macOS scanning backend using ImageCaptureCore.
@@ -627,27 +688,24 @@ class MacOSBackend:
627
688
  if uid not in self._devices:
628
689
  self._devices[uid] = dev
629
690
 
630
- def _location(dev):
631
- # ImageCaptureCore echoes the device name as the location
632
- # description when no real location is set — treat that as
633
- # "no location" rather than surfacing a redundant string.
634
- loc = _safe_str(dev, "locationDescription")
635
- return loc if loc and loc != dev.name() else None
636
-
637
- return [
638
- Scanner(
639
- name=dev.name(),
640
- vendor=_safe_str(dev, "manufacturer"),
641
- model=None,
642
- backend=self.backend_name,
643
- scanner_id=_safe_str(dev, "UUIDString") or dev.name(),
644
- uuid=_safe_str(dev, "UUIDString") or None,
645
- location=_location(dev),
646
- # Image Capture shows the device name verbatim.
647
- display_name=dev.name(),
691
+ scanners = []
692
+ for dev in delegate.scanners:
693
+ display_name, location = _name_and_location(dev)
694
+ scanners.append(
695
+ Scanner(
696
+ name=dev.name(),
697
+ vendor=_safe_str(dev, "manufacturer"),
698
+ model=None,
699
+ backend=self.backend_name,
700
+ scanner_id=_safe_str(dev, "UUIDString") or dev.name(),
701
+ uuid=_safe_str(dev, "UUIDString") or None,
702
+ location=location,
703
+ # The label Image Capture shows (Bonjour name for
704
+ # network scanners, device name otherwise).
705
+ display_name=display_name,
706
+ )
648
707
  )
649
- for dev in delegate.scanners
650
- ]
708
+ return scanners
651
709
 
652
710
  return self._on_main(_build_list)
653
711
 
@@ -691,6 +749,12 @@ class MacOSBackend:
691
749
  "or host (e.g. Image Capture, Preview, or a vendor tool). "
692
750
  "Close it and try again."
693
751
  )
752
+ if last_error_code in _UNAVAILABLE_ERROR_CODES:
753
+ raise ScannerUnavailableError(
754
+ f"Scanner {scanner.name!r} is unavailable — it may be "
755
+ "offline, asleep, or disconnected. Check that it is powered "
756
+ "on and reachable, then try again."
757
+ )
694
758
  raise ScanError(f"Failed to open device session: {last_error}")
695
759
 
696
760
  if scan_delegate is None or not scan_delegate.session_open:
@@ -885,65 +949,9 @@ class MacOSBackend:
885
949
  time.sleep(0.5)
886
950
 
887
951
  is_feeder = options.source == ScanSource.FEEDER
888
- all_pages: list[ScannedPage] = []
889
-
890
- while True:
891
- scan_delegate._scan_done.clear()
892
- scan_delegate.error = None
893
- scan_delegate.completed_pages = []
894
- scan_delegate._current_bands = []
895
- scan_delegate._rows_received = 0
896
- scan_delegate._last_pct = 0
897
- scan_delegate._aborted = False
898
- scan_delegate._expected_height = expected_height
899
- scan_delegate._progress = options.progress
900
-
901
- check_progress(options.progress, -1)
902
-
903
- self._on_main(device.requestScan)
904
-
905
- # Poll with short timeouts so scanner.abort() is responsive.
906
- while not scan_delegate._scan_done.wait(timeout=0.25):
907
- if scanner._abort_event.is_set():
908
- self._on_main(device.cancelScan)
909
- raise ScanAborted("Scan aborted")
910
-
911
- if scan_delegate._aborted:
912
- self._on_main(device.cancelScan)
913
- raise ScanAborted("Scan aborted by user")
914
-
915
- if scan_delegate.error:
916
- err_lower = scan_delegate.error.lower()
917
- if "cancel" in err_lower or "abort" in err_lower:
918
- raise ScanAborted(
919
- f"Scan cancelled by device: {scan_delegate.error}"
920
- )
921
- raise ScanError(f"Scan failed: {scan_delegate.error}")
922
-
923
- # Flush the last (or only) page
924
- if scan_delegate._current_bands:
925
- scan_delegate._finish_page()
926
-
927
- if not scan_delegate.completed_pages:
928
- if is_feeder:
929
- raise FeederEmptyError("No documents in feeder")
930
- raise ScanError("Scan completed but no image data was received")
931
-
932
- for bands, w, h, bpc, nc, pdt in scan_delegate.completed_pages:
933
- raw, width, height, mode = _assemble_image(
934
- bands, w, h, bpc, nc, pdt
935
- )
936
- all_pages.append(
937
- ScannedPage(
938
- data=raw,
939
- width=width,
940
- height=height,
941
- color_mode=mode,
942
- )
943
- )
944
-
945
- if not is_feeder:
946
- break
952
+ all_pages = self._collect_scan_rounds(
953
+ scanner, device, scan_delegate, options, is_feeder, expected_height
954
+ )
947
955
 
948
956
  check_progress(options.progress, 100)
949
957
  return all_pages
@@ -951,3 +959,96 @@ class MacOSBackend:
951
959
  raise
952
960
  except Exception as exc:
953
961
  raise ScanError(f"Scan failed: {exc}") from exc
962
+
963
+ def _collect_scan_rounds(
964
+ self,
965
+ scanner: Scanner,
966
+ device,
967
+ scan_delegate,
968
+ options: ScanOptions,
969
+ is_feeder: bool,
970
+ expected_height: int,
971
+ ) -> list[ScannedPage]:
972
+ """Drive ``requestScan`` rounds and gather the resulting pages.
973
+
974
+ A flatbed scan runs exactly one round. A feeder keeps scanning until
975
+ a round yields no page: ImageCaptureCore signals the end of the stack
976
+ with an empty pass — or, on some devices, a "no documents in feeder"
977
+ completion error. Either way that is the normal terminator once at
978
+ least one page has been collected, so the run stops and returns the
979
+ pages instead of surfacing the condition as an error. Only an empty
980
+ *first* round means the feeder was genuinely empty.
981
+ """
982
+ all_pages: list[ScannedPage] = []
983
+
984
+ while True:
985
+ scan_delegate._scan_done.clear()
986
+ scan_delegate.error = None
987
+ scan_delegate.error_code = None
988
+ scan_delegate.completed_pages = []
989
+ scan_delegate._current_bands = []
990
+ scan_delegate._rows_received = 0
991
+ scan_delegate._last_pct = 0
992
+ scan_delegate._aborted = False
993
+ scan_delegate._expected_height = expected_height
994
+ scan_delegate._progress = options.progress
995
+
996
+ check_progress(options.progress, -1)
997
+
998
+ self._on_main(device.requestScan)
999
+
1000
+ # Poll with short timeouts so scanner.abort() is responsive.
1001
+ while not scan_delegate._scan_done.wait(timeout=0.25):
1002
+ if scanner._abort_event.is_set():
1003
+ self._on_main(device.cancelScan)
1004
+ raise ScanAborted("Scan aborted")
1005
+
1006
+ if scan_delegate._aborted:
1007
+ self._on_main(device.cancelScan)
1008
+ raise ScanAborted("Scan aborted by user")
1009
+
1010
+ # Flush the last (or only) page and collect this round's pages
1011
+ # *before* interpreting any completion error: a feeder's final
1012
+ # pass can deliver a sheet and then report "no documents".
1013
+ if scan_delegate._current_bands:
1014
+ scan_delegate._finish_page()
1015
+
1016
+ new_pages = list(scan_delegate.completed_pages)
1017
+ for bands, w, h, bpc, nc, pdt in new_pages:
1018
+ raw, width, height, mode = _assemble_image(bands, w, h, bpc, nc, pdt)
1019
+ all_pages.append(
1020
+ ScannedPage(
1021
+ data=raw,
1022
+ width=width,
1023
+ height=height,
1024
+ color_mode=mode,
1025
+ )
1026
+ )
1027
+
1028
+ if scan_delegate.error:
1029
+ if scan_delegate.error_code in _CANCEL_ERROR_CODES:
1030
+ raise ScanAborted(
1031
+ f"Scan cancelled by device: {scan_delegate.error}"
1032
+ )
1033
+ # ImageCaptureCore has no "feeder empty" code, so the pass
1034
+ # after the last sheet surfaces as a generic, vendor-specific
1035
+ # scanner error. Once we've collected at least one page that
1036
+ # is the normal end-of-feeder signal, not a failure — stop and
1037
+ # return what we scanned instead of discarding it.
1038
+ if is_feeder and all_pages:
1039
+ break
1040
+ raise ScanError(f"Scan failed: {scan_delegate.error}")
1041
+
1042
+ if not new_pages:
1043
+ # An empty pass ends a feeder run once pages exist; otherwise
1044
+ # the feeder was empty to begin with.
1045
+ if is_feeder:
1046
+ if all_pages:
1047
+ break
1048
+ raise FeederEmptyError("No documents in feeder")
1049
+ raise ScanError("Scan completed but no image data was received")
1050
+
1051
+ if not is_feeder:
1052
+ break
1053
+
1054
+ return all_pages
@@ -20,7 +20,9 @@ from .._types import (
20
20
  ScanArea,
21
21
  ScanAborted,
22
22
  ScanError,
23
+ ScanLibError,
23
24
  ScannerBusyError,
25
+ ScannerUnavailableError,
24
26
  ScannedPage,
25
27
  Scanner,
26
28
  ScannerDefaults,
@@ -219,15 +221,38 @@ if _lib is not None:
219
221
  # ---------------------------------------------------------------------------
220
222
 
221
223
 
224
+ def _status_message(status: int, context: str = "") -> str:
225
+ """Format a human-readable message for a ``SANE_Status`` code."""
226
+ name = _STATUS_NAMES.get(status, f"unknown ({status})")
227
+ msg = f"SANE error: {name}"
228
+ if context:
229
+ msg = f"{context}: {msg}"
230
+ return msg
231
+
232
+
233
+ def _status_error(status: int, context: str = "") -> ScanError:
234
+ """Build a :class:`ScanError` for *status*, tagged with ``.sane_status``.
235
+
236
+ Callers classify the failure by the numeric ``SANE_Status`` code on the
237
+ exception rather than by parsing the message text.
238
+ """
239
+ err = ScanError(_status_message(status, context))
240
+ err.sane_status = status
241
+ return err
242
+
243
+
222
244
  def _check_status(status: int, context: str = "") -> None:
223
245
  if status != _STATUS_GOOD:
224
- name = _STATUS_NAMES.get(status, f"unknown ({status})")
225
- msg = f"SANE error: {name}"
226
- if context:
227
- msg = f"{context}: {msg}"
228
246
  if status == _STATUS_DEVICE_BUSY:
229
247
  raise ScannerBusyError()
230
- raise ScanError(msg)
248
+ if status == _STATUS_IO_ERROR:
249
+ # I/O error means communication with the device failed — it is
250
+ # offline, asleep, or disconnected, not held by another session.
251
+ raise ScannerUnavailableError(
252
+ f"{_status_message(status, context)} — the scanner may be "
253
+ "offline, asleep, or disconnected."
254
+ )
255
+ raise _status_error(status, context)
231
256
 
232
257
 
233
258
  def _ensure_lib() -> None:
@@ -723,8 +748,7 @@ def _scan_one_page(dev: _SaneDevice, progress=None) -> ScannedPage:
723
748
  if status != _STATUS_GOOD:
724
749
  if status == _STATUS_DEVICE_BUSY:
725
750
  raise ScannerBusyError()
726
- name = _STATUS_NAMES.get(status, f"unknown ({status})")
727
- raise ScanError(f"sane_start: {name}")
751
+ raise _status_error(status, "sane_start")
728
752
 
729
753
  params = dev.get_parameters()
730
754
  width = params.pixels_per_line
@@ -752,8 +776,7 @@ def _scan_one_page(dev: _SaneDevice, progress=None) -> ScannedPage:
752
776
  # instead of EOF once all data has been delivered.
753
777
  break
754
778
  if st != _STATUS_GOOD:
755
- name = _STATUS_NAMES.get(st, f"unknown ({st})")
756
- raise ScanError(f"sane_read: {name}")
779
+ raise _status_error(st, "sane_read")
757
780
 
758
781
  raw = b"".join(chunks)
759
782
 
@@ -872,6 +895,9 @@ class SaneBackend:
872
895
  def open_scanner(self, scanner: Scanner) -> None:
873
896
  try:
874
897
  dev = _open_device(scanner.name)
898
+ except ScanLibError:
899
+ # Already a typed scanlib error (busy/unavailable/…) — preserve it.
900
+ raise
875
901
  except Exception as exc:
876
902
  raise ScanError(f"Failed to open scanner {scanner.name!r}: {exc}") from exc
877
903
  self._handles[scanner.name] = dev
@@ -983,10 +1009,11 @@ class SaneBackend:
983
1009
  scan_started = True
984
1010
  page = _scan_one_page(dev, progress=options.progress)
985
1011
  except ScanError as exc:
986
- msg = str(exc).lower()
987
- if is_feeder and ("no docs" in msg or "eof" in msg):
1012
+ status = getattr(exc, "sane_status", None)
1013
+ # End of an automatic feeder run: no more documents / EOF.
1014
+ if is_feeder and status in (_STATUS_NO_DOCS, _STATUS_EOF):
988
1015
  break
989
- if "cancel" in msg or "jammed" in msg:
1016
+ if status in (_STATUS_CANCELLED, _STATUS_JAMMED):
990
1017
  raise ScanAborted(f"Scan cancelled by device: {exc}") from exc
991
1018
  raise
992
1019
 
@@ -66,6 +66,7 @@ from .._types import (
66
66
  ScanAborted,
67
67
  ScanError,
68
68
  ScannerBusyError,
69
+ ScannerUnavailableError,
69
70
  ScannedPage,
70
71
  Scanner,
71
72
  ScannerDefaults,
@@ -115,11 +116,28 @@ _FEEDER = 2
115
116
 
116
117
  _WIA_FORMAT_BMP = GUID("{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}")
117
118
 
119
+ # WIA item categories (wiadef.h). A WIA 2.0 scanner exposes one child
120
+ # item per source; scanning the *wrong* item (e.g. the flatbed item with
121
+ # the feeder selected) makes some drivers scan blank pages from an empty
122
+ # feeder forever instead of reporting WIA_ERROR_PAPER_EMPTY.
123
+ _WIA_CATEGORY_FLATBED = GUID("{FB607B1F-43F3-488B-855B-FB703EC342A6}")
124
+ _WIA_CATEGORY_FEEDER = GUID("{FE131934-F84C-42AD-8DA4-6129CDDD7288}")
125
+
118
126
  _WIA_ERROR_PAPER_EMPTY = -2145320957 # 0x80210003
119
127
  # HRESULTs that mean the device is in use / unavailable to this session.
120
128
  _WIA_ERROR_BUSY = -2145320954 # 0x80210006
121
129
  _WIA_ERROR_DEVICE_LOCKED = -2145320947 # 0x8021000D
122
130
  _WIA_BUSY_ERRORS = frozenset({_WIA_ERROR_BUSY, _WIA_ERROR_DEVICE_LOCKED})
131
+ # HRESULTs that mean the device couldn't be reached — offline, asleep, or
132
+ # disconnected — rather than held by another session.
133
+ _WIA_ERROR_OFFLINE = -2145320955 # 0x80210005
134
+ _WIA_ERROR_DEVICE_COMM = -2145320950 # 0x8021000A (DEVICE_COMMUNICATION)
135
+ _WIA_UNAVAILABLE_ERRORS = frozenset({_WIA_ERROR_OFFLINE, _WIA_ERROR_DEVICE_COMM})
136
+ # Generic Windows cancellation HRESULTs (a device-initiated cancel; a
137
+ # caller-initiated abort is detected separately via the callback).
138
+ _E_ABORT = -2147467260 # 0x80004004
139
+ _WIA_ERROR_CANCELLED = -2147023673 # 0x800704C7 HRESULT_FROM_WIN32(ERROR_CANCELLED)
140
+ _WIA_CANCEL_ERRORS = frozenset({_E_ABORT, _WIA_ERROR_CANCELLED})
123
141
 
124
142
  # Property attribute flags (from WiaDef.h)
125
143
  _WIA_PROP_RANGE = 0x10
@@ -780,6 +798,42 @@ def _read_wia_color_modes(storage) -> list[ColorMode]:
780
798
  return []
781
799
 
782
800
 
801
+ def _enum_scan_items(root_item):
802
+ """Map scan sources to their dedicated WIA child items.
803
+
804
+ Returns ``(child_map, default_child)`` where *child_map* maps
805
+ :class:`ScanSource` to the child item whose WIA category matches that
806
+ source, and *default_child* is the first child item (a fallback for
807
+ scanners that expose a single generic item). Scanning a source through
808
+ its own child item is what makes the driver report
809
+ ``WIA_ERROR_PAPER_EMPTY`` for an empty feeder instead of scanning blanks.
810
+ """
811
+ child_map: dict[ScanSource, object] = {}
812
+ default_child = None
813
+ try:
814
+ enum_items = root_item.EnumChildItems(None)
815
+ except Exception:
816
+ return child_map, default_child
817
+ while True:
818
+ try:
819
+ child, fetched = enum_items.Next(1)
820
+ except Exception:
821
+ break
822
+ if not fetched:
823
+ break
824
+ if default_child is None:
825
+ default_child = child
826
+ try:
827
+ category = child.GetItemCategory()
828
+ except Exception:
829
+ continue
830
+ if category == _WIA_CATEGORY_FEEDER:
831
+ child_map[ScanSource.FEEDER] = child
832
+ elif category == _WIA_CATEGORY_FLATBED:
833
+ child_map[ScanSource.FLATBED] = child
834
+ return child_map, default_child
835
+
836
+
783
837
  def _read_wia_sources(storage) -> list[ScanSource]:
784
838
  """Determine available scan sources from device capabilities."""
785
839
  caps = _read_prop(storage, _WIA_DPS_DOCUMENT_HANDLING_CAPABILITIES, 0)
@@ -1149,8 +1203,15 @@ class WiaBackend:
1149
1203
  dm = self._create_device_manager()
1150
1204
  root_item = dm.CreateDevice(0, scanner.id)
1151
1205
  except Exception as exc:
1152
- if getattr(exc, "hresult", None) in _WIA_BUSY_ERRORS:
1206
+ hr = getattr(exc, "hresult", None)
1207
+ if hr in _WIA_BUSY_ERRORS:
1153
1208
  raise ScannerBusyError() from exc
1209
+ if hr in _WIA_UNAVAILABLE_ERRORS:
1210
+ raise ScannerUnavailableError(
1211
+ f"Scanner {scanner.id!r} is unavailable — it may be "
1212
+ "offline, asleep, or disconnected. Check that it is powered "
1213
+ "on and reachable, then try again."
1214
+ ) from exc
1154
1215
  raise ScanError(f"Failed to open scanner {scanner.id!r}: {exc}") from exc
1155
1216
 
1156
1217
  # Read device-level properties from root item
@@ -1165,46 +1226,31 @@ class WiaBackend:
1165
1226
  if not source_types:
1166
1227
  source_types = [ScanSource.FLATBED]
1167
1228
 
1168
- # Get first child item for item-level properties
1169
- child_item = None
1170
- try:
1171
- enum_items = root_item.EnumChildItems(None)
1172
- child, fetched = enum_items.Next(1)
1173
- if fetched:
1174
- child_item = child
1175
- except Exception:
1176
- pass
1229
+ # Map each source to its dedicated child item (Flatbed/Feeder),
1230
+ # falling back to the first item for single-item scanners.
1231
+ child_map, default_child = _enum_scan_items(root_item)
1177
1232
 
1178
1233
  source_infos: list[SourceInfo] = []
1179
- if child_item is not None:
1234
+ if default_child is not None:
1180
1235
  try:
1181
- item_storage = child_item.QueryInterface(IWiaPropertyStorage)
1182
- device_area = _read_wia_max_scan_area(root_storage, item_storage)
1183
-
1184
- # Read per-source resolutions and color modes.
1236
+ # Read each source's resolutions/color modes from its own
1237
+ # child item (the feeder may differ from the flatbed).
1185
1238
  for source in source_types:
1186
- if root_storage is not None:
1187
- try:
1188
- select_val = (
1189
- _FEEDER if source == ScanSource.FEEDER else _FLATBED
1190
- )
1191
- _write_prop(
1192
- root_storage,
1193
- _WIA_DPS_DOCUMENT_HANDLING_SELECT,
1194
- select_val,
1195
- )
1196
- except Exception:
1197
- pass
1239
+ src_item = child_map.get(source, default_child)
1240
+ item_storage = src_item.QueryInterface(IWiaPropertyStorage)
1198
1241
  source_infos.append(
1199
1242
  SourceInfo(
1200
1243
  type=source,
1201
1244
  resolutions=_read_wia_resolutions(item_storage),
1202
1245
  color_modes=_read_wia_color_modes(item_storage),
1203
- max_scan_area=device_area,
1246
+ max_scan_area=_read_wia_max_scan_area(
1247
+ root_storage, item_storage
1248
+ ),
1204
1249
  )
1205
1250
  )
1206
1251
 
1207
- scanner._defaults = _read_wia_defaults(item_storage, source_types)
1252
+ default_storage = default_child.QueryInterface(IWiaPropertyStorage)
1253
+ scanner._defaults = _read_wia_defaults(default_storage, source_types)
1208
1254
  except Exception:
1209
1255
  scanner._defaults = None
1210
1256
  else:
@@ -1222,7 +1268,7 @@ class WiaBackend:
1222
1268
  scanner._defaults = None
1223
1269
 
1224
1270
  scanner._sources = source_infos
1225
- self._handles[scanner.id] = (root_item, child_item)
1271
+ self._handles[scanner.id] = (root_item, child_map, default_child)
1226
1272
 
1227
1273
  def _close_scanner_impl(self, scanner: Scanner) -> None:
1228
1274
  self._handles.pop(scanner.id, None)
@@ -1234,7 +1280,11 @@ class WiaBackend:
1234
1280
  if items is None:
1235
1281
  raise ScanError("Scanner is not open")
1236
1282
 
1237
- root_item, child_item = items
1283
+ root_item, child_map, default_child = items
1284
+ source = options.source or ScanSource.FLATBED
1285
+ # Scan from the source's own child item — using the flatbed item for
1286
+ # a feeder scan makes some drivers scan blanks from an empty feeder.
1287
+ child_item = child_map.get(source, default_child)
1238
1288
  if child_item is None:
1239
1289
  raise ScanError("Scanner has no scan items")
1240
1290
 
@@ -1296,34 +1346,47 @@ class WiaBackend:
1296
1346
 
1297
1347
  while True:
1298
1348
  callback = _TransferCallback(options.progress, scanner._abort_event)
1349
+ end_of_feeder = False
1299
1350
  try:
1300
1351
  transfer.Download(0, callback)
1301
1352
  except Exception as exc:
1353
+ # Classify by HRESULT, never by message text.
1302
1354
  hr = getattr(exc, "hresult", None)
1303
- msg_text = str(exc).lower()
1304
- if callback._aborted:
1355
+ if callback._aborted or hr in _WIA_CANCEL_ERRORS:
1305
1356
  raise ScanAborted("Scan aborted") from exc
1306
- if (
1307
- hr == _WIA_ERROR_PAPER_EMPTY
1308
- or "paper" in msg_text
1309
- or "empty" in msg_text
1310
- ):
1357
+ if hr in _WIA_BUSY_ERRORS:
1358
+ raise ScannerBusyError() from exc
1359
+ if hr in _WIA_UNAVAILABLE_ERRORS:
1360
+ raise ScannerUnavailableError() from exc
1361
+ if hr == _WIA_ERROR_PAPER_EMPTY:
1362
+ # Feeder ran dry: empty from the start if nothing was
1363
+ # scanned, otherwise the normal end of a feeder run.
1311
1364
  if is_feeder and not all_pages and not callback.pages:
1312
1365
  raise FeederEmptyError("No documents in feeder") from exc
1313
- # Feeder empty after some pages — that's normal
1314
- elif hr in _WIA_BUSY_ERRORS:
1315
- raise ScannerBusyError() from exc
1316
- elif "cancel" in msg_text or "abort" in msg_text:
1317
- raise ScanAborted(f"Scan cancelled by device: {exc}") from exc
1318
- elif not callback.pages and not all_pages:
1366
+ end_of_feeder = True
1367
+ elif not all_pages and not callback.pages:
1368
+ # Unrecognised failure with nothing scanned — a real error.
1319
1369
  raise ScanError(f"Scan failed: {exc}") from exc
1370
+ else:
1371
+ # Unrecognised error after pages on a feeder — stop the
1372
+ # run rather than risk looping on a repeating failure.
1373
+ end_of_feeder = True
1320
1374
 
1321
1375
  all_pages.extend(callback.pages)
1322
1376
 
1323
- if not is_feeder:
1377
+ if not is_feeder or end_of_feeder:
1378
+ break
1379
+ if not callback.pages:
1380
+ # Feeder Download returned successfully but produced no
1381
+ # page and didn't raise WIA_ERROR_PAPER_EMPTY — some
1382
+ # scanners (e.g. Epson WSD) signal an empty feeder this
1383
+ # way. Stop instead of calling Download again, which
1384
+ # would re-run the scanner on an empty feeder forever.
1324
1385
  break
1325
1386
 
1326
1387
  if not all_pages:
1388
+ if is_feeder:
1389
+ raise FeederEmptyError("No documents in feeder")
1327
1390
  raise ScanError("No pages were scanned")
1328
1391
 
1329
1392
  check_progress(options.progress, 100)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scanlib
3
- Version: 1.3.0
3
+ Version: 1.3.1
4
4
  Summary: A multiplatform document scanning library for Python
5
5
  Author-email: Angelo Mottola <a.mottola@gmail.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes