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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scanloop = scanloop.main:main
@@ -0,0 +1 @@
1
+ scanloop