scanloop 2026.11.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scanloop/__init__.py +0 -0
- scanloop/main.py +205 -0
- scanloop/version.py +1 -0
- scanloop-2026.11.11.0.dist-info/METADATA +48 -0
- scanloop-2026.11.11.0.dist-info/RECORD +8 -0
- scanloop-2026.11.11.0.dist-info/WHEEL +5 -0
- scanloop-2026.11.11.0.dist-info/entry_points.txt +2 -0
- scanloop-2026.11.11.0.dist-info/top_level.txt +1 -0
scanloop/__init__.py
ADDED
|
File without changes
|
scanloop/main.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
|
|
3
|
+
"""A scanner front-end to efficiently scan several multi-page documents."""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from .version import __version__
|
|
17
|
+
except Exception as ex:
|
|
18
|
+
logging.error("unable to determine version: %s", ex)
|
|
19
|
+
__version__ = "unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def command_exists(command):
|
|
23
|
+
"""Returns true if the given command is available in the system."""
|
|
24
|
+
return shutil.which(command) is not None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ScanLoop(object):
|
|
28
|
+
"""Scans documents in a loop."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, quality_percent, scan_cmd,
|
|
31
|
+
scan_format, output_format, pdf_viewer):
|
|
32
|
+
self.quality_percent = quality_percent
|
|
33
|
+
scan_cmd = scan_cmd or ScanLoop.guess_scan_command()
|
|
34
|
+
# None if no scanner was found; check_requirements reports it.
|
|
35
|
+
self.scan_command = scan_cmd.split() if scan_cmd else None
|
|
36
|
+
self.pdf_viewer = pdf_viewer # allow setting no pdf viewer
|
|
37
|
+
self.scan_format = scan_format
|
|
38
|
+
self.output_format = output_format
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def guess_scan_command():
|
|
43
|
+
"""Attempts to guess the scan command."""
|
|
44
|
+
env_cmd = os.getenv("SCAN_CMD")
|
|
45
|
+
if env_cmd:
|
|
46
|
+
return env_cmd
|
|
47
|
+
logging.info("checking scanner availability via scanimage...")
|
|
48
|
+
if command_exists("imagescan"):
|
|
49
|
+
return "{} {}".format("imagescan", " --no-interface")
|
|
50
|
+
if command_exists("scanimage") and subprocess.call(
|
|
51
|
+
["sudo", "scanimage", "-L"]) == 0:
|
|
52
|
+
return "sudo scanimage"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def guess_pdf_viewer():
|
|
58
|
+
"""Attempts to guess the PDF viewer."""
|
|
59
|
+
for pdf_viewer in ("zathura", "evince", "gv", "open"):
|
|
60
|
+
if command_exists(pdf_viewer):
|
|
61
|
+
return pdf_viewer
|
|
62
|
+
logging.warning("no pdf viewer could be guessed")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_requirements(self):
|
|
67
|
+
"""Check all requirements are satisfied before scanning."""
|
|
68
|
+
if not command_exists("convert"):
|
|
69
|
+
raise Exception(
|
|
70
|
+
"missing imagemagick convert. hint: {apt-get,brew} install imagemagick")
|
|
71
|
+
if not self.scan_command:
|
|
72
|
+
raise Exception("no scan command detected")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def list_ordered_pages(self, extension=None):
|
|
76
|
+
"""Lists pages in the cwd as a two-tuple: (page_num, page_fname)."""
|
|
77
|
+
if not extension:
|
|
78
|
+
extension = self.scan_format
|
|
79
|
+
return sorted(
|
|
80
|
+
(int(fname.split(".")[0]), fname)
|
|
81
|
+
for fname in os.listdir(os.getcwd())
|
|
82
|
+
if re.match("^[0-9]+[.]{}$".format(extension), fname))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def scan_page(self, fname_output):
|
|
86
|
+
"""Scan a single page to the given output filename."""
|
|
87
|
+
logging.info("scanning page to %s. Ctrl+C to stop...", fname_output)
|
|
88
|
+
attempts = 0
|
|
89
|
+
try:
|
|
90
|
+
while True:
|
|
91
|
+
attempts += 1
|
|
92
|
+
with open(fname_output, "wb") as fh:
|
|
93
|
+
p = subprocess.Popen(self.scan_command,
|
|
94
|
+
stdout=fh,
|
|
95
|
+
stderr=subprocess.PIPE)
|
|
96
|
+
# communicate() reads stderr while waiting; wait() alone
|
|
97
|
+
# could block forever once the stderr pipe fills up.
|
|
98
|
+
_, stderr = p.communicate()
|
|
99
|
+
if p.returncode == 0:
|
|
100
|
+
logging.info("successfully scanned %s", fname_output)
|
|
101
|
+
return True
|
|
102
|
+
sys.stdout.write("\rscan attempt {}. Ctrl+C to stop...".format(
|
|
103
|
+
attempts))
|
|
104
|
+
sys.stdout.write(" " + stderr.decode().replace("\n", " "))
|
|
105
|
+
sys.stdout.flush()
|
|
106
|
+
|
|
107
|
+
time.sleep(1)
|
|
108
|
+
except KeyboardInterrupt:
|
|
109
|
+
try:
|
|
110
|
+
os.remove(fname_output)
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
pass
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def merge_pages(self, output):
|
|
117
|
+
"""Merge multiple page scans into a single output file."""
|
|
118
|
+
for (_, pnm) in self.list_ordered_pages(extension=self.scan_format):
|
|
119
|
+
# need intermediate jpg conversion to enforce "-quality" space savings
|
|
120
|
+
jpg = "{}.jpg".format(pnm.split(".")[0])
|
|
121
|
+
cmd = ["convert", pnm, "-quality", str(self.quality_percent), jpg]
|
|
122
|
+
ret = subprocess.call(cmd)
|
|
123
|
+
if ret != 0:
|
|
124
|
+
raise Exception("non-zero exit status: {} of {}".format(
|
|
125
|
+
ret, " ".join(cmd)))
|
|
126
|
+
|
|
127
|
+
cmd = (["convert"] +
|
|
128
|
+
[fname for (_, fname) in self.list_ordered_pages(extension="jpg")] +
|
|
129
|
+
[output])
|
|
130
|
+
logging.info("convert command: %s", " ".join(cmd))
|
|
131
|
+
ret = subprocess.call(cmd)
|
|
132
|
+
if ret != 0:
|
|
133
|
+
raise Exception("non-zero exit status: {} of {}".format(
|
|
134
|
+
ret, " ".join(cmd)))
|
|
135
|
+
if self.pdf_viewer:
|
|
136
|
+
subprocess.call([self.pdf_viewer, output])
|
|
137
|
+
|
|
138
|
+
def scan_doc(self, doc_name):
|
|
139
|
+
"""Create a scan of a doc containing one or more pages."""
|
|
140
|
+
pages = self.list_ordered_pages()
|
|
141
|
+
page_no = pages[-1][0] + 1 if pages else 1
|
|
142
|
+
while True:
|
|
143
|
+
page = "{}.{}".format(page_no, self.scan_format)
|
|
144
|
+
expect_more_pages = self.scan_page(page)
|
|
145
|
+
if expect_more_pages:
|
|
146
|
+
page_no += 1
|
|
147
|
+
else:
|
|
148
|
+
break
|
|
149
|
+
if not self.list_ordered_pages():
|
|
150
|
+
logging.error("no pages scanned.")
|
|
151
|
+
return
|
|
152
|
+
output = "{}.{}".format(doc_name, self.output_format)
|
|
153
|
+
self.merge_pages(output)
|
|
154
|
+
logging.info("successfully merged pages to %s", output)
|
|
155
|
+
|
|
156
|
+
def start(self):
|
|
157
|
+
"""Scan documents indefinitely."""
|
|
158
|
+
orig_dir = os.getcwd()
|
|
159
|
+
while True:
|
|
160
|
+
os.chdir(orig_dir)
|
|
161
|
+
try:
|
|
162
|
+
doc_name = input("enter new doc name, q to quit: ")
|
|
163
|
+
except EOFError:
|
|
164
|
+
# Ctrl+D quits too
|
|
165
|
+
break
|
|
166
|
+
if doc_name == "q":
|
|
167
|
+
break
|
|
168
|
+
if not doc_name:
|
|
169
|
+
continue
|
|
170
|
+
assert "/" not in doc_name, "invalid filename"
|
|
171
|
+
try:
|
|
172
|
+
os.mkdir(doc_name)
|
|
173
|
+
except FileExistsError:
|
|
174
|
+
pass
|
|
175
|
+
os.chdir(doc_name)
|
|
176
|
+
self.scan_doc(doc_name)
|
|
177
|
+
|
|
178
|
+
def main():
|
|
179
|
+
"""Main."""
|
|
180
|
+
logging.root.setLevel(logging.INFO)
|
|
181
|
+
parser = argparse.ArgumentParser()
|
|
182
|
+
parser.add_argument("--quality-percent",
|
|
183
|
+
help="The imagemagick -quality of merged document.",
|
|
184
|
+
default=30)
|
|
185
|
+
parser.add_argument("--scan-cmd",
|
|
186
|
+
help="command used to scan a single page to stdout.")
|
|
187
|
+
parser.add_argument("--scan-format", default="pnm",
|
|
188
|
+
help="The file-format extension of the scan command output.")
|
|
189
|
+
parser.add_argument("--output-format", default="pdf",
|
|
190
|
+
help="The file-format extension of the merged document.")
|
|
191
|
+
parser.add_argument("--pdf-viewer", help="Command to open the output files.",
|
|
192
|
+
default=ScanLoop.guess_pdf_viewer())
|
|
193
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
args = vars(parser.parse_args())
|
|
197
|
+
|
|
198
|
+
logging.info("version: %s", __version__)
|
|
199
|
+
|
|
200
|
+
scan_loop = ScanLoop(**args)
|
|
201
|
+
scan_loop.check_requirements()
|
|
202
|
+
scan_loop.start()
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
main()
|
scanloop/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2026.11.11.0"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scanloop
|
|
3
|
+
Version: 2026.11.11.0
|
|
4
|
+
Summary: A scanner front-end to efficiently scan several multi-page documents.
|
|
5
|
+
Author-email: Ernesto Alfonso <erjoalgo@gmail.com>
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/erjoalgo/scanloop
|
|
8
|
+
Project-URL: Repository, https://github.com/erjoalgo/scanloop
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/erjoalgo/scanloop/issues
|
|
10
|
+
Keywords: scanner,imagemagick
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics :: Capture :: Scanners
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
A scanner front-end to efficiently scan several multi-page documents.
|
|
23
|
+
|
|
24
|
+
For each document, enter a name, then feed pages to the scanner one at a time.
|
|
25
|
+
scanloop keeps scanning a page whenever the scanner is ready; press Ctrl+C when
|
|
26
|
+
the document is done. The pages are merged into `NAME/NAME.pdf`, which opens
|
|
27
|
+
in a PDF viewer, and scanloop asks for the next document's name (`q` to quit).
|
|
28
|
+
|
|
29
|
+
Requirements:
|
|
30
|
+
|
|
31
|
+
- A scan command that writes one page to stdout: `scanimage` (from SANE,
|
|
32
|
+
`$ sudo apt install sane-utils`) or Epson's `imagescan` are detected
|
|
33
|
+
automatically. scanimage is run with sudo. To use another, pass `--scan-cmd`
|
|
34
|
+
or set `SCAN_CMD`.
|
|
35
|
+
- ImageMagick's `convert` (`$ sudo apt install imagemagick`). On Debian and
|
|
36
|
+
Ubuntu, ImageMagick's default security policy blocks writing PDFs; to allow
|
|
37
|
+
it, remove or comment out the `pattern="PDF"` line in
|
|
38
|
+
`/etc/ImageMagick-6/policy.xml`.
|
|
39
|
+
|
|
40
|
+
To install:
|
|
41
|
+
|
|
42
|
+
`$ pip install scanloop`
|
|
43
|
+
|
|
44
|
+
To run:
|
|
45
|
+
|
|
46
|
+
`$ scanloop`
|
|
47
|
+
|
|
48
|
+
See `scanloop --help` for options, such as the output quality and format.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
scanloop/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
scanloop/main.py,sha256=EYsIUlGWWOEA7UIXS_b6cZ1s20-LzrLhrlhLqOhkQnk,7382
|
|
3
|
+
scanloop/version.py,sha256=oxvJO6m4HZ26FmsIBz71bZe0nyAyGoFagBVWCymoqXM,29
|
|
4
|
+
scanloop-2026.11.11.0.dist-info/METADATA,sha256=7-wdjXKjQbcSx07bULbvOJH2Ub8haVJcvLVktGqeMOU,1919
|
|
5
|
+
scanloop-2026.11.11.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
scanloop-2026.11.11.0.dist-info/entry_points.txt,sha256=pMTjlsQGn0wOLuvefygaa2OJvgmKhRbmfq0conVJOdA,48
|
|
7
|
+
scanloop-2026.11.11.0.dist-info/top_level.txt,sha256=Ez79TO6DEDa7GxyFiWAQjA1fcN2U9-EvblXZ2ljjsho,9
|
|
8
|
+
scanloop-2026.11.11.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scanloop
|