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 ADDED
@@ -0,0 +1,8 @@
1
+ from . import patches
2
+ del patches
3
+
4
+ from . import config
5
+ from . import gui
6
+ from . import jobconfig
7
+ from . import job
8
+ from . import preprocessor
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
@@ -0,0 +1,5 @@
1
+ import logging
2
+
3
+ LOGGING_LEVEL = logging.INFO
4
+
5
+ del logging
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())
@@ -0,0 +1,5 @@
1
+ """Monkey patches"""
2
+
3
+ from . import mako
4
+ from . import escpos
5
+