tinyprint 0.2.4__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.
- tinyprint/__init__.py +8 -0
- tinyprint/__main__.py +21 -0
- tinyprint/config.py +5 -0
- tinyprint/gui.py +251 -0
- tinyprint/gui_misc.py +160 -0
- tinyprint/job.py +151 -0
- tinyprint/jobconfig.py +76 -0
- tinyprint/patches/__init__.py +5 -0
- tinyprint/patches/escpos.py +172 -0
- tinyprint/patches/mako.py +26 -0
- tinyprint/patches.py +28 -0
- tinyprint/preprocessor.py +291 -0
- tinyprint/printer/__init__.py +2 -0
- tinyprint/printer/manager.py +137 -0
- tinyprint/printer/profile.py +91 -0
- tinyprint/xescpos.py +166 -0
- tinyprint-0.2.4.dist-info/METADATA +53 -0
- tinyprint-0.2.4.dist-info/RECORD +22 -0
- tinyprint-0.2.4.dist-info/WHEEL +5 -0
- tinyprint-0.2.4.dist-info/entry_points.txt +2 -0
- tinyprint-0.2.4.dist-info/licenses/LICENSE +674 -0
- tinyprint-0.2.4.dist-info/top_level.txt +1 -0
tinyprint/__init__.py
ADDED
tinyprint/__main__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Main entry point for the TinyPrint application."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from tinyprint.gui import GUI
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
"""Entry point for the application."""
|
|
9
|
+
# Configure logging
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.INFO,
|
|
12
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Start the application
|
|
16
|
+
with GUI() as gui:
|
|
17
|
+
gui.run()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
tinyprint/config.py
ADDED
tinyprint/gui.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""TinyPrint GUI"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import logging
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from tkinter import Tk, filedialog
|
|
12
|
+
from tkinter.ttk import Frame
|
|
13
|
+
|
|
14
|
+
from tinyprint.jobconfig import JobConfig, Resource
|
|
15
|
+
from tinyprint.job import Job
|
|
16
|
+
from tinyprint.gui_misc import Block, Blocks, no_hidden_files
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GUI:
|
|
20
|
+
"""Main GUI class for the TinyPrint application."""
|
|
21
|
+
|
|
22
|
+
COLUMN_COUNT = 5
|
|
23
|
+
|
|
24
|
+
def __enter__(self):
|
|
25
|
+
self._tempdir = None
|
|
26
|
+
self._tempdir_context = tempfile.TemporaryDirectory(prefix="tinyprint")
|
|
27
|
+
self.tempdir = self._tempdir_context.name
|
|
28
|
+
self.setup()
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
32
|
+
self.cleanup()
|
|
33
|
+
|
|
34
|
+
def setup(self):
|
|
35
|
+
"""Set up the main window and all components."""
|
|
36
|
+
self.root = Tk()
|
|
37
|
+
no_hidden_files(self.root)
|
|
38
|
+
self.root.title("~ tiny print ~")
|
|
39
|
+
self.main_frame = Frame(self.root, padding=15)
|
|
40
|
+
self.main_frame.grid(sticky="nsew")
|
|
41
|
+
blk = self.blk = Blocks(self)
|
|
42
|
+
blk.ADD.job_controller(JobController)
|
|
43
|
+
blk.ADD.job_config_manager(JobConfigManager)
|
|
44
|
+
blk.ADD.job_config_editor(JobConfigEditor)
|
|
45
|
+
for i in range(GUI.COLUMN_COUNT):
|
|
46
|
+
self.main_frame.columnconfigure(i, weight=1)
|
|
47
|
+
|
|
48
|
+
def run(self):
|
|
49
|
+
"""Start the main event loop."""
|
|
50
|
+
self.root.mainloop()
|
|
51
|
+
|
|
52
|
+
def cleanup(self):
|
|
53
|
+
"""Clean up resources, including temporary directories."""
|
|
54
|
+
g = self
|
|
55
|
+
g.blk and g.blk.close()
|
|
56
|
+
g._tempdir_context and g._tempdir_context.cleanup()
|
|
57
|
+
g.blk, g.root, g.main_frame, g._tempdir_context, g._tempdir = [None] * 5
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class JobController(Block):
|
|
61
|
+
"""Controls to start/stop print job."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, *args, **kwargs):
|
|
64
|
+
self.job = None
|
|
65
|
+
super().__init__(*args, **kwargs)
|
|
66
|
+
|
|
67
|
+
def create_widgets(self):
|
|
68
|
+
btn = self.btn
|
|
69
|
+
for i, cmd in enumerate("print stop".split()):
|
|
70
|
+
b = btn.ADD(cmd, text=cmd, command=getattr(self, cmd))
|
|
71
|
+
b.grid(column=i, row=0)
|
|
72
|
+
|
|
73
|
+
def print(self):
|
|
74
|
+
if self.job and self.job.is_running:
|
|
75
|
+
return logging.warning("job already printing!")
|
|
76
|
+
self.stop()
|
|
77
|
+
config = self.gui.blk.job_config_manager.job_config
|
|
78
|
+
if config:
|
|
79
|
+
self.job = Job(config)
|
|
80
|
+
self.job.start()
|
|
81
|
+
else:
|
|
82
|
+
logging.warning("no job config set")
|
|
83
|
+
|
|
84
|
+
def stop(self):
|
|
85
|
+
if not self.job:
|
|
86
|
+
return
|
|
87
|
+
self.job.stop()
|
|
88
|
+
self.job.close()
|
|
89
|
+
self.job = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class JobConfigManager(Block):
|
|
93
|
+
"""Load/save a job configuration"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, gui, *args, **kwargs):
|
|
96
|
+
self.job_config = None
|
|
97
|
+
self.filename = None
|
|
98
|
+
super().__init__(gui, *args, **kwargs)
|
|
99
|
+
self.temp_resource = os.path.join(gui.tempdir, "tmp-resource.md")
|
|
100
|
+
with open(self.temp_resource, "w") as f:
|
|
101
|
+
f.write("tiny printer test")
|
|
102
|
+
self.new(os.path.join(self.gui.tempdir, "tmp-print-job.json"))
|
|
103
|
+
|
|
104
|
+
def create_widgets(self):
|
|
105
|
+
var, btn, lbl = self.var, self.btn, self.lbl
|
|
106
|
+
var.str.ADD.filename_indicator()
|
|
107
|
+
lbl.ADD.job_config(text="job config: ")
|
|
108
|
+
lbl.job_config.grid(column=0, row=0)
|
|
109
|
+
lbl.ADD.filename(textvariable=var.filename_indicator)
|
|
110
|
+
lbl.filename.grid(column=1, row=0, columnspan=3, pady=5, sticky="w")
|
|
111
|
+
for i, cmd in enumerate("new open save save_as reload".split()):
|
|
112
|
+
b = btn.ADD(cmd, text=cmd, command=getattr(self, cmd))
|
|
113
|
+
b.grid(column=i, row=1, pady=5, padx=2)
|
|
114
|
+
|
|
115
|
+
def new(self, *args, **kwargs):
|
|
116
|
+
self.job_config = JobConfig([Resource(self.temp_resource)])
|
|
117
|
+
self.save_as(*args, **kwargs)
|
|
118
|
+
|
|
119
|
+
def open(self):
|
|
120
|
+
if filename := self.select_file(
|
|
121
|
+
(("print job configuration", "*.json"), ("All files", "*.*"))
|
|
122
|
+
):
|
|
123
|
+
self.set_filename(filename)
|
|
124
|
+
self.reload()
|
|
125
|
+
|
|
126
|
+
def save(self):
|
|
127
|
+
self.job_config.to_file(self.filename)
|
|
128
|
+
|
|
129
|
+
def save_as(self, filename: Optional[str] = None):
|
|
130
|
+
if not filename:
|
|
131
|
+
filetypes = (("print job configuration", "*.json"), ("All files", "*.*"))
|
|
132
|
+
filename = filedialog.asksaveasfilename(
|
|
133
|
+
title="File path", filetypes=filetypes, initialdir=str(Path.home())
|
|
134
|
+
)
|
|
135
|
+
if not filename:
|
|
136
|
+
return
|
|
137
|
+
if not filename.endswith(".json"):
|
|
138
|
+
filename = f"{filename}.json"
|
|
139
|
+
self.set_filename(filename)
|
|
140
|
+
self.save()
|
|
141
|
+
|
|
142
|
+
def reload(self):
|
|
143
|
+
if not self.filename:
|
|
144
|
+
return logging.warning("can't load as no file loaded yet")
|
|
145
|
+
logging.info(f"load {self.filename}")
|
|
146
|
+
self.job_config = JobConfig.from_file(self.filename)
|
|
147
|
+
self.gui.blk.job_config_editor.sync_from_model()
|
|
148
|
+
|
|
149
|
+
def set_filename(self, filename: str):
|
|
150
|
+
self.filename = filename
|
|
151
|
+
self.var.filename_indicator.set(Path(filename).name)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class JobConfigEditor(Block):
|
|
155
|
+
"""Edit parameters of currently loaded job configuration"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, *args, **kwargs):
|
|
158
|
+
self.temp_resource = None
|
|
159
|
+
super().__init__(*args, **kwargs)
|
|
160
|
+
|
|
161
|
+
def create_widgets(self):
|
|
162
|
+
var, btn, lbl, cbx, scl = self.var, self.btn, self.lbl, self.cbx, self.scl
|
|
163
|
+
|
|
164
|
+
var.str.ADD.resource_list()
|
|
165
|
+
var.str.ADD.printer_config()
|
|
166
|
+
var.double.ADD.sleep_time()
|
|
167
|
+
|
|
168
|
+
for i, t in enumerate( # render names of parameters
|
|
169
|
+
("resource", "printer configuration", "cut", "sleep time")
|
|
170
|
+
):
|
|
171
|
+
label = lbl.ADD(f"arg{i}", text=f"{t}: ")
|
|
172
|
+
label.grid(column=0, row=i, columnspan=2, sticky="w", pady=2)
|
|
173
|
+
|
|
174
|
+
btn.ADD.select_resource(text="select", command=self.select_resource)
|
|
175
|
+
lbl.ADD.resource_list(textvariable=var.resource_list)
|
|
176
|
+
btn.select_resource.grid(column=2, row=0, sticky="w")
|
|
177
|
+
lbl.resource_list.grid(column=4, row=0, columnspan=2, sticky="w")
|
|
178
|
+
|
|
179
|
+
btn.ADD.select_printer(text="select", command=self.select_printer)
|
|
180
|
+
lbl.ADD.printer_value(textvariable=var.printer_config)
|
|
181
|
+
btn.select_printer.grid(column=2, row=1, sticky="w")
|
|
182
|
+
lbl.printer_value.grid(column=4, row=1, sticky="w")
|
|
183
|
+
|
|
184
|
+
cbx.ADD.cut(values=["yes", "no"])
|
|
185
|
+
cbx.cut.set("no")
|
|
186
|
+
cbx.cut.bind("<<ComboboxSelected>>", self.select_cut)
|
|
187
|
+
cbx.cut.grid(column=2, row=2)
|
|
188
|
+
|
|
189
|
+
lbl.ADD.sleep_value(textvariable=var.sleep_time)
|
|
190
|
+
scl.ADD.sleep_time(
|
|
191
|
+
from_=0,
|
|
192
|
+
to=2,
|
|
193
|
+
command=self.select_sleep_time,
|
|
194
|
+
variable=var.sleep_time,
|
|
195
|
+
length=500,
|
|
196
|
+
)
|
|
197
|
+
lbl.sleep_value.grid(column=2, row=3, sticky="w")
|
|
198
|
+
scl.sleep_time.grid(column=3, row=3, columnspan=2)
|
|
199
|
+
|
|
200
|
+
def sync_from_model(self):
|
|
201
|
+
job_config, var, cbx = self.job_config, self.var, self.cbx
|
|
202
|
+
if not job_config:
|
|
203
|
+
return
|
|
204
|
+
resource_list = "; ".join(
|
|
205
|
+
[r.path.split(os.sep)[-1] for r in job_config.resource_list]
|
|
206
|
+
)
|
|
207
|
+
if len(resource_list) > 25:
|
|
208
|
+
resource_list = resource_list[:30] + " ..."
|
|
209
|
+
var.resource_list.set(resource_list)
|
|
210
|
+
var.printer_config.set(job_config.printer_config)
|
|
211
|
+
var.sleep_time.set(job_config.sleep_time)
|
|
212
|
+
cbx.cut.set(("no", "yes")[job_config.cut])
|
|
213
|
+
|
|
214
|
+
def select_resource(self):
|
|
215
|
+
self.select_file(
|
|
216
|
+
"resource_list",
|
|
217
|
+
(("all files", "*.*"), ("pdf", "*.pdf"), ("images", "*.jpg")),
|
|
218
|
+
multiple=True,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def select_printer(self):
|
|
222
|
+
self.select_file(
|
|
223
|
+
"printer_config", (("printer config", "*.yml"), ("all files", "*.*"))
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def select_file(self, attr, filetypes, multiple=False):
|
|
227
|
+
file_list = super().select_file(filetypes, multiple)
|
|
228
|
+
if multiple is False:
|
|
229
|
+
file_list = [file_list] if file_list else []
|
|
230
|
+
if file_list:
|
|
231
|
+
setattr(
|
|
232
|
+
self.job_config,
|
|
233
|
+
attr,
|
|
234
|
+
[Resource(f) for f in file_list] if multiple else file_list[0],
|
|
235
|
+
)
|
|
236
|
+
self.sync_from_model()
|
|
237
|
+
|
|
238
|
+
def select_cut(self, *_):
|
|
239
|
+
self.job_config.cut = {"yes": True, "no": False}[self.cbx.cut.get()]
|
|
240
|
+
|
|
241
|
+
def select_sleep_time(self, value: str):
|
|
242
|
+
v = round(float(value), 2)
|
|
243
|
+
self.job_config.sleep_time = v
|
|
244
|
+
# NOTE Since var.sleep_time is also 'variable' of scale, this
|
|
245
|
+
# already gets set automatically - yet we still re-set it here
|
|
246
|
+
# to get the rounded value.
|
|
247
|
+
self.var.sleep_time.set(v)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def job_config(self):
|
|
251
|
+
return self.gui.blk.job_config_manager.job_config
|
tinyprint/gui_misc.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Provides helper for the tiny print GUI"""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from tkinter import TclError, filedialog
|
|
6
|
+
from tkinter.ttk import Separator, Frame, Scale, Button, Label, Combobox
|
|
7
|
+
from tkinter import StringVar, DoubleVar, BooleanVar
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Block(Frame):
|
|
11
|
+
"""A block represents one visual and logical unit in the GUI"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, gui, *args, **kwargs):
|
|
14
|
+
super().__init__(gui.main_frame, *args, **kwargs)
|
|
15
|
+
self.gui = gui
|
|
16
|
+
self.var = _VarFactory()
|
|
17
|
+
self.btn, self.lbl, self.cbx, self.scl = (
|
|
18
|
+
_WidgetFactory(self, w) for w in (Button, Label, Combobox, Scale)
|
|
19
|
+
)
|
|
20
|
+
self.create_widgets()
|
|
21
|
+
|
|
22
|
+
def create_widgets(self):
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def close(self):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def select_file(self, filetypes, multiple=False):
|
|
29
|
+
k = dict(title="Open a file", filetypes=filetypes, initialdir=Path.home())
|
|
30
|
+
file_list = (
|
|
31
|
+
filedialog.askopenfilenames(**k)
|
|
32
|
+
if multiple
|
|
33
|
+
else [filedialog.askopenfilename(**k)]
|
|
34
|
+
)
|
|
35
|
+
if file_list:
|
|
36
|
+
if multiple:
|
|
37
|
+
return file_list
|
|
38
|
+
return file_list[0]
|
|
39
|
+
|
|
40
|
+
def __del__(self):
|
|
41
|
+
self.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _WidgetFactory:
|
|
45
|
+
def __init__(self, frame, widget_type):
|
|
46
|
+
self._frame = frame
|
|
47
|
+
self._widget_type = widget_type
|
|
48
|
+
self._widget_key_list = []
|
|
49
|
+
self.ADD = _WidgetFactory.Add(self)
|
|
50
|
+
|
|
51
|
+
def __iter__(self):
|
|
52
|
+
return (getattr(self, k) for k in self._widget_key_list)
|
|
53
|
+
|
|
54
|
+
class Add:
|
|
55
|
+
def __init__(self, factory):
|
|
56
|
+
self.factory = factory
|
|
57
|
+
|
|
58
|
+
def __call__(self, key, *args, **kwargs):
|
|
59
|
+
return getattr(self, key)(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
def __getattr__(self, key):
|
|
62
|
+
def _(*args, **kwargs):
|
|
63
|
+
f = self.factory
|
|
64
|
+
widget = f._widget_type(self.factory._frame, *args, **kwargs)
|
|
65
|
+
f._widget_key_list.append(key)
|
|
66
|
+
setattr(f, key, widget)
|
|
67
|
+
return widget
|
|
68
|
+
|
|
69
|
+
return _
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _VarFactory:
|
|
73
|
+
"""Factory for creating Tkinter variables with consistent ADD pattern."""
|
|
74
|
+
|
|
75
|
+
class VarTypeFactory:
|
|
76
|
+
def __init__(self, var_type, parent):
|
|
77
|
+
self.var_type = var_type
|
|
78
|
+
self.parent = parent
|
|
79
|
+
self.ADD = self.Add(self)
|
|
80
|
+
|
|
81
|
+
class Add:
|
|
82
|
+
def __init__(self, factory):
|
|
83
|
+
self.factory = factory
|
|
84
|
+
|
|
85
|
+
def __getattr__(self, name):
|
|
86
|
+
def _(value=None):
|
|
87
|
+
var = self.factory.var_type(value=value)
|
|
88
|
+
setattr(self.factory.parent, name, var)
|
|
89
|
+
return var
|
|
90
|
+
|
|
91
|
+
return _
|
|
92
|
+
|
|
93
|
+
def __init__(self):
|
|
94
|
+
self.str = self.VarTypeFactory(StringVar, self)
|
|
95
|
+
self.double = self.VarTypeFactory(DoubleVar, self)
|
|
96
|
+
self.bool = self.VarTypeFactory(BooleanVar, self)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Blocks:
|
|
100
|
+
"""Namespace for the collection of all GUI blocks."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, gui):
|
|
103
|
+
self.gui = gui
|
|
104
|
+
self.ADD = self.Add(self)
|
|
105
|
+
self._block_list = []
|
|
106
|
+
|
|
107
|
+
class Add:
|
|
108
|
+
def __init__(self, blocks):
|
|
109
|
+
self._blocks = blocks
|
|
110
|
+
self.block_row = 0
|
|
111
|
+
|
|
112
|
+
def __getattr__(self, name):
|
|
113
|
+
def _(block_class, **kwargs):
|
|
114
|
+
gui = self._blocks.gui
|
|
115
|
+
assert gui.COLUMN_COUNT == 5
|
|
116
|
+
block = block_class(gui, **kwargs)
|
|
117
|
+
block.grid(
|
|
118
|
+
row=self.block_row,
|
|
119
|
+
column=0,
|
|
120
|
+
columnspan=gui.COLUMN_COUNT,
|
|
121
|
+
sticky="w",
|
|
122
|
+
)
|
|
123
|
+
Separator(gui.main_frame, orient="horizontal").grid(
|
|
124
|
+
row=self.block_row + 1,
|
|
125
|
+
column=0,
|
|
126
|
+
columnspan=gui.COLUMN_COUNT,
|
|
127
|
+
sticky="ew",
|
|
128
|
+
pady=10,
|
|
129
|
+
)
|
|
130
|
+
self.block_row += 2
|
|
131
|
+
setattr(self._blocks, name, block)
|
|
132
|
+
self._blocks._block_list.append(block)
|
|
133
|
+
return block
|
|
134
|
+
|
|
135
|
+
return _
|
|
136
|
+
|
|
137
|
+
def close(self):
|
|
138
|
+
for blk in self._block_list:
|
|
139
|
+
blk.close()
|
|
140
|
+
|
|
141
|
+
def __del__(self):
|
|
142
|
+
self.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def no_hidden_files(root):
|
|
146
|
+
try:
|
|
147
|
+
_no_hidden_files(root)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass # support macos
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _no_hidden_files(root):
|
|
153
|
+
"""Configure file dialogs to show hidden files (macOS support)."""
|
|
154
|
+
call = root.tk.call
|
|
155
|
+
try:
|
|
156
|
+
call("tk_getOpenFile", "-foobarbaz")
|
|
157
|
+
except TclError:
|
|
158
|
+
pass
|
|
159
|
+
call("set", "::tk::dialog::file::showHiddenBtn", "1")
|
|
160
|
+
call("set", "::tk::dialog::file::showHiddenVar", "0")
|
tinyprint/job.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import traceback
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from escpos.constants import ESC, PAPER_PART_CUT # type: ignore
|
|
8
|
+
from escpos.printer import Dummy # type: ignore
|
|
9
|
+
import six
|
|
10
|
+
|
|
11
|
+
import tinyprint
|
|
12
|
+
from tinyprint.jobconfig import JobConfig
|
|
13
|
+
from tinyprint.patches.escpos import PrinterCommand
|
|
14
|
+
from tinyprint.printer.manager import Manager
|
|
15
|
+
from tinyprint.preprocessor import create_pages_from_resources
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Job:
|
|
19
|
+
"""Pre-process input files & prints them with ESC/POS printer."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: JobConfig):
|
|
22
|
+
self.config = config
|
|
23
|
+
self._job: Optional[threading.Thread] = None
|
|
24
|
+
self.manager = Manager(config)
|
|
25
|
+
self.command_generator = CommandGenerator(config, self.manager.printer.profile)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_running(self):
|
|
29
|
+
return self._job and self._job.is_alive()
|
|
30
|
+
|
|
31
|
+
def start(self):
|
|
32
|
+
"""Start printing in background thread"""
|
|
33
|
+
self._job = threading.Thread(target=self._run)
|
|
34
|
+
self._job.start()
|
|
35
|
+
|
|
36
|
+
def wait(self, timeout: Optional[float] = None):
|
|
37
|
+
"""Wait/block until print job is finished"""
|
|
38
|
+
if self._job:
|
|
39
|
+
self._job.join(timeout)
|
|
40
|
+
self._job = None
|
|
41
|
+
|
|
42
|
+
def stop(self):
|
|
43
|
+
"""Stop currently running print job"""
|
|
44
|
+
if self.is_running:
|
|
45
|
+
self._logger.info(" stop print! >")
|
|
46
|
+
self.manager.stop()
|
|
47
|
+
self.wait()
|
|
48
|
+
|
|
49
|
+
def close(self):
|
|
50
|
+
try:
|
|
51
|
+
self.stop()
|
|
52
|
+
finally:
|
|
53
|
+
self.manager.close()
|
|
54
|
+
|
|
55
|
+
def _run(self):
|
|
56
|
+
"""Run printing job"""
|
|
57
|
+
i = self._logger.info
|
|
58
|
+
|
|
59
|
+
i("< preprocess input ...")
|
|
60
|
+
page_tuple = create_pages_from_resources(
|
|
61
|
+
self.config, self.manager.printer.profile
|
|
62
|
+
)
|
|
63
|
+
i(" finished preprocessing >")
|
|
64
|
+
|
|
65
|
+
i("< create ESC/POS codes ...")
|
|
66
|
+
cmd_list = self.command_generator(page_tuple)
|
|
67
|
+
i(" finished creating ESC/POS codes >")
|
|
68
|
+
|
|
69
|
+
i("< start printing")
|
|
70
|
+
self.manager.print(cmd_list)
|
|
71
|
+
self.manager.wait()
|
|
72
|
+
i(" finished printing >")
|
|
73
|
+
|
|
74
|
+
def __enter__(self):
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, *args, **kwargs):
|
|
78
|
+
self.wait()
|
|
79
|
+
self.close()
|
|
80
|
+
|
|
81
|
+
def __del__(self):
|
|
82
|
+
self.close()
|
|
83
|
+
|
|
84
|
+
@functools.cached_property
|
|
85
|
+
def _logger(self):
|
|
86
|
+
"""The class based logger."""
|
|
87
|
+
cls = type(self)
|
|
88
|
+
logger = logging.getLogger(f"{cls.__module__}.{cls.__name__}")
|
|
89
|
+
logger.setLevel(tinyprint.config.LOGGING_LEVEL)
|
|
90
|
+
return logger
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CommandGenerator:
|
|
94
|
+
"""Generates ESC/POS commands from printable pages."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, config, printer_profile):
|
|
97
|
+
self.config = config
|
|
98
|
+
self.printer_profile = printer_profile
|
|
99
|
+
self._logger = logging.getLogger(f"{__name__}.{type(self).__name__}")
|
|
100
|
+
|
|
101
|
+
def __call__(self, page_tuple):
|
|
102
|
+
dummy = Dummy()
|
|
103
|
+
for i, page in enumerate(page_tuple):
|
|
104
|
+
try:
|
|
105
|
+
page(dummy)
|
|
106
|
+
except Exception:
|
|
107
|
+
tb = traceback.format_exc()
|
|
108
|
+
self._logger.warning(f"error when printing page {i+1}: {tb}")
|
|
109
|
+
output_list = dummy._output_list
|
|
110
|
+
if self.config.cut:
|
|
111
|
+
cmd_list = self._add_auto_cut(output_list)
|
|
112
|
+
else:
|
|
113
|
+
cmd_list = [getattr(cmd, "cmd", cmd) for cmd in output_list]
|
|
114
|
+
return cmd_list * self.config.copy_count
|
|
115
|
+
|
|
116
|
+
def _add_auto_cut(self, output_list: list[bytes | PrinterCommand]) -> list[bytes]:
|
|
117
|
+
# NOTE In case we want to cut the paper after each image,
|
|
118
|
+
# we need to take special care to avoid whitespace between
|
|
119
|
+
# each image. This whitespace happens if we just use the
|
|
120
|
+
# default 'cut' method, because then the printer needs to
|
|
121
|
+
# feed the paper until it reaches the cutting point (when
|
|
122
|
+
# finished printing an image, some parts of the paper are
|
|
123
|
+
# still inside the printer). So what we need to do is:
|
|
124
|
+
#
|
|
125
|
+
# - print image 1
|
|
126
|
+
# - print image 2 partially
|
|
127
|
+
# - cut paper
|
|
128
|
+
# - print image 2 partially
|
|
129
|
+
# - print image 3 partially
|
|
130
|
+
# - cut paper
|
|
131
|
+
# - ...
|
|
132
|
+
#
|
|
133
|
+
# XXX This cutting currently only works for 'bitImageColumn' images
|
|
134
|
+
# XXX This cutting currently only works for RessourceType PDF/ImageList
|
|
135
|
+
processed_list = []
|
|
136
|
+
img_cut_index = self.printer_profile.img_cut_index
|
|
137
|
+
for cmd in output_list:
|
|
138
|
+
match cmd:
|
|
139
|
+
case PrinterCommand():
|
|
140
|
+
processed_list.append(cmd.cmd)
|
|
141
|
+
if cmd.index == img_cut_index:
|
|
142
|
+
processed_list.extend(
|
|
143
|
+
[
|
|
144
|
+
ESC + b"2",
|
|
145
|
+
PAPER_PART_CUT,
|
|
146
|
+
ESC + b"3" + six.int2byte(16),
|
|
147
|
+
]
|
|
148
|
+
)
|
|
149
|
+
case _:
|
|
150
|
+
processed_list.append(cmd)
|
|
151
|
+
return processed_list
|
tinyprint/jobconfig.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import enum
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from dataclasses_json import DataClassJsonMixin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Orientation(enum.Enum):
|
|
11
|
+
"""Set if book has vertical or horizontal layout
|
|
12
|
+
|
|
13
|
+
Horizontal: | | |
|
|
14
|
+
|
|
15
|
+
__
|
|
16
|
+
Vertical: __
|
|
17
|
+
__
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
HORIZONTAL = 0
|
|
21
|
+
VERTICAL = 1
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def parse(cls, v: str):
|
|
25
|
+
return _parse_str(cls, v)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_str(cls, v):
|
|
29
|
+
try:
|
|
30
|
+
return getattr(cls, v.upper())
|
|
31
|
+
except Exception:
|
|
32
|
+
raise AttributeError("{cls}: enum {v} not found")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class PageSize(DataClassJsonMixin):
|
|
37
|
+
width: float
|
|
38
|
+
heigth: float
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_any(cls, obj: Any) -> PageSize:
|
|
42
|
+
match obj:
|
|
43
|
+
case PageSize():
|
|
44
|
+
return obj
|
|
45
|
+
case tuple() | list():
|
|
46
|
+
return PageSize(*obj)
|
|
47
|
+
case _:
|
|
48
|
+
raise NotImplementedError(f"can't parse {obj} to PageSize")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Resource:
|
|
53
|
+
path: str
|
|
54
|
+
fragment_height: Optional[int] = None
|
|
55
|
+
# Either 0 (= black) or 1 (= red)
|
|
56
|
+
color: int = 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class JobConfig(DataClassJsonMixin):
|
|
61
|
+
resource_list: list[Resource]
|
|
62
|
+
printer_config: Optional[str] = None
|
|
63
|
+
copy_count: int = 1
|
|
64
|
+
cut: bool = True
|
|
65
|
+
orientation: Orientation = Orientation.HORIZONTAL
|
|
66
|
+
page_size: Optional[PageSize] = None
|
|
67
|
+
sleep_time: Optional[float] = None
|
|
68
|
+
|
|
69
|
+
def to_file(self, path: str):
|
|
70
|
+
with open(path, "w") as f:
|
|
71
|
+
f.write(self.to_json())
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_file(cls, path: str):
|
|
75
|
+
with open(path, "r") as f:
|
|
76
|
+
return cls.from_json(f.read())
|