tinyprint 0.1.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.
tinyprint/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from . import config
2
+ from . import xescpos
3
+ from . import jobconfig
4
+ from . import job
5
+ from . import preprocessor
tinyprint/__main__.py ADDED
@@ -0,0 +1,254 @@
1
+ """GUI for tinyprint"""
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ import logging
6
+ from pathlib import Path
7
+ import tempfile
8
+ from typing import Optional
9
+
10
+ from tkinter import Tk
11
+ from tkinter import ttk
12
+ import tkinter as tk
13
+
14
+ from tkinter.filedialog import (
15
+ askopenfilename,
16
+ askopenfilenames,
17
+ asksaveasfilename,
18
+ )
19
+
20
+ from tinyprint.jobconfig import JobConfig
21
+ from tinyprint.job import Job
22
+
23
+
24
+ def main():
25
+ tempdir = tempfile.TemporaryDirectory(prefix="tinyprint")
26
+ try:
27
+ _main(tempdir.name)
28
+ finally:
29
+ tempdir.cleanup()
30
+
31
+
32
+ def _main(tempdir: str):
33
+ root = Tk()
34
+ root.title("~ tiny print ~")
35
+ frm = ttk.Frame(root, padding=15)
36
+ frm.grid()
37
+
38
+ _no_hidden_files(root)
39
+
40
+ jcm = JobConfigManager(
41
+ JobConfigFields(frm), tempdir, filename_indicator=tk.StringVar(value="")
42
+ )
43
+ jm = JobManager(jcm)
44
+
45
+ ymax = 8
46
+
47
+ ttk.Button(frm, text="print", command=jm.print).grid(column=0, row=0)
48
+ ttk.Button(frm, text="stop", command=jm.stop).grid(column=1, row=0)
49
+
50
+ ttk.Separator(frm).grid(row=1, columnspan=ymax, sticky="ew", pady=10)
51
+
52
+ ttk.Label(frm, text="job config: ").grid(column=0, row=2)
53
+ ttk.Label(frm, textvariable=jcm.fields.filename).grid(
54
+ column=1, row=2, columnspan=3, pady=5, sticky="w"
55
+ )
56
+
57
+ ttk.Button(frm, text="new", command=jcm.new).grid(column=0, row=3, pady=5, padx=2)
58
+ ttk.Button(frm, text="open", command=jcm.open).grid(column=1, row=3, padx=2)
59
+ ttk.Button(frm, text="save", command=jcm.save).grid(column=2, row=3, padx=2)
60
+ ttk.Button(frm, text="save as ...", command=jcm.save_as).grid(
61
+ column=3, row=3, padx=2
62
+ )
63
+ ttk.Button(frm, text="reload", command=jcm.load).grid(column=4, row=3, padx=2)
64
+
65
+ ttk.Separator(frm).grid(row=4, columnspan=ymax, sticky="ew", pady=10)
66
+
67
+ ttk.Label(frm, text="resource: ").grid(
68
+ column=0, row=5, columnspan=2, sticky="w", pady=2
69
+ )
70
+ ttk.Button(frm, text="select", command=jcm.select_resource).grid(
71
+ column=2, row=5, sticky="w"
72
+ )
73
+ ttk.Label(frm, textvariable=jcm.fields.resource_list).grid(
74
+ column=4, row=5, columnspan=2, sticky="w"
75
+ )
76
+
77
+ ttk.Label(frm, text="printer configuration: ").grid(
78
+ column=0, row=6, columnspan=2, sticky="w", pady=2
79
+ )
80
+ ttk.Button(frm, text="select", command=jcm.select_printer_configuration).grid(
81
+ column=2, row=6, sticky="w"
82
+ )
83
+ ttk.Label(frm, textvariable=jcm.fields.printer_config).grid(
84
+ column=4, row=6, sticky="w"
85
+ )
86
+
87
+ ttk.Label(frm, text="cut: ").grid(column=0, row=7, columnspan=2, sticky="w", pady=2)
88
+ jcm.fields.cut.grid(column=2, row=7)
89
+ jcm.fields.cut.bind("<<ComboboxSelected>>", jcm.select_cut)
90
+
91
+ ttk.Label(frm, text="sleep time: ").grid(
92
+ column=0, row=8, columnspan=2, sticky="w", pady=2
93
+ )
94
+ ttk.Label(frm, textvariable=jcm.fields.sleep_time).grid(column=2, row=8, sticky="w")
95
+ ttk.Scale(frm, from_=0, to=2, command=jcm.select_sleep_time, length=500).grid(
96
+ column=3, row=8, columnspan=2
97
+ )
98
+
99
+ root.mainloop()
100
+
101
+
102
+ # See https://stackoverflow.com/a/54068050
103
+ def _no_hidden_files(root):
104
+ # call a dummy dialog with an impossible option to initialize the file
105
+ # dialog without really getting a dialog window; this will throw a
106
+ # TclError, so we need a try...except :
107
+ try:
108
+ root.tk.call("tk_getOpenFile", "-foobarbaz")
109
+ except tk.TclError:
110
+ pass
111
+ # now set the magic variables accordingly
112
+ root.tk.call("set", "::tk::dialog::file::showHiddenBtn", "1")
113
+ root.tk.call("set", "::tk::dialog::file::showHiddenVar", "0")
114
+
115
+
116
+ class JobConfigFields:
117
+ def __init__(self, frm):
118
+ self.filename = tk.StringVar()
119
+ self.resource_list = tk.StringVar()
120
+ self.printer_config = tk.StringVar()
121
+ self.cut = ttk.Combobox(frm, values=["yes", "no"])
122
+ self.sleep_time = tk.DoubleVar()
123
+
124
+ def update(self, job_config: JobConfig):
125
+ resource_list = "; ".join(
126
+ [r[0].split(os.sep)[-1] for r in job_config.resource_list]
127
+ )
128
+ if len(resource_list) > 25:
129
+ resource_list = resource_list[:30] + " ..."
130
+ self.resource_list.set(resource_list)
131
+ self.printer_config.set(job_config.printer_config)
132
+ self.cut.set(("no", "yes")[job_config.cut])
133
+ self.sleep_time.set(job_config.sleep_time or 0)
134
+
135
+
136
+ @dataclass
137
+ class JobConfigManager:
138
+ fields: JobConfigFields
139
+
140
+ tempdir: str
141
+ temp_resource: Optional[str] = None
142
+
143
+ job_config: Optional[JobConfig] = None
144
+ filename: Optional[str] = None
145
+ filename_indicator: Optional[tk.StringVar] = None
146
+
147
+ def __post_init__(self):
148
+ self.temp_resource = f"{self.tempdir}/tmp-resource.md"
149
+ with open(self.temp_resource, "w") as f:
150
+ f.write("tiny printer test")
151
+ self.new(f"{self.tempdir}/tmp-print-job.json")
152
+
153
+ def new(self, *args, **kwargs):
154
+ self.job_config = JobConfig([(self.temp_resource, None)])
155
+ self.save_as(*args, **kwargs)
156
+ self.updatefields()
157
+
158
+ def open(self):
159
+ filetypes = (("print job configuration", "*.json"), ("All files", "*.*"))
160
+ if filename := askopenfilename(
161
+ title="Open a file", initialdir=str(Path.home()), filetypes=filetypes
162
+ ):
163
+ self.set_filename(filename)
164
+ self.load()
165
+
166
+ def save(self):
167
+ self.job_config.to_file(self.filename)
168
+
169
+ def save_as(self, filename: Optional[str] = None):
170
+ if not filename:
171
+ filetypes = (("print job configuration", "*.json"), ("All files", "*.*"))
172
+ filename = asksaveasfilename(
173
+ title="File path", filetypes=filetypes, initialdir=str(Path.home())
174
+ )
175
+ if not filename:
176
+ return
177
+ if not filename.endswith(".json"):
178
+ filename = f"{filename}.json"
179
+ self.set_filename(filename)
180
+ self.save()
181
+
182
+ def load(self):
183
+ if not self.filename:
184
+ return logging.warn("can't load as no file loaded yet")
185
+ logging.info(f"load {self.filename}")
186
+ self.job_config = JobConfig.from_file(self.filename)
187
+ self.updatefields()
188
+
189
+ def updatefields(self):
190
+ self.fields.update(self.job_config)
191
+
192
+ def set_filename(self, filename: str):
193
+ self.filename = filename
194
+ self.fields.filename.set(self.filename.split(os.sep)[-1])
195
+
196
+ def select_resource(self):
197
+ filetypes = (
198
+ ("all files", "*.*"),
199
+ ("pdf file", "*.pdf"),
200
+ ("image file", "*.jpg"),
201
+ )
202
+ file_tuple = askopenfilenames(
203
+ title="Open a file", initialdir=str(Path.home()), filetypes=filetypes
204
+ )
205
+ if not file_tuple:
206
+ return
207
+
208
+ resource_list = [(p, None) for p in file_tuple]
209
+
210
+ self.job_config.resource_list = resource_list
211
+ self.updatefields()
212
+
213
+ def select_printer_configuration(self):
214
+ filetypes = (
215
+ ("printer configuration", "*.yml"),
216
+ ("all files", "*.*"),
217
+ )
218
+ filename = askopenfilename(
219
+ title="Open a file", initialdir=str(Path.home()), filetypes=filetypes
220
+ )
221
+ if filename:
222
+ self.job_config.printer_config = filename
223
+ self.updatefields()
224
+
225
+ def select_cut(self, *_):
226
+ self.job_config.cut = {"yes": True, "no": False}[self.fields.cut.get()]
227
+
228
+ def select_sleep_time(self, value: str):
229
+ if self.job_config:
230
+ v = round(float(value), 2)
231
+ self.job_config.sleep_time = v
232
+ self.fields.sleep_time.set(v)
233
+
234
+
235
+ @dataclass
236
+ class JobManager:
237
+ job_config_manager: JobConfigManager
238
+ job: Optional[Job] = None
239
+
240
+ def print(self):
241
+ if self.job and self.job.is_printing:
242
+ return logging.warn("job already printing!")
243
+
244
+ config = self.job_config_manager.job_config
245
+ if config:
246
+ self.job = Job(config)
247
+ self.job.print()
248
+ else:
249
+ logging.warn("no job config set")
250
+
251
+ def stop(self):
252
+ if self.job:
253
+ self.job.stop()
254
+ self.job = None
tinyprint/config.py ADDED
@@ -0,0 +1,5 @@
1
+ import logging
2
+
3
+ LOGGING_LEVEL = logging.INFO
4
+
5
+ del logging
tinyprint/job.py ADDED
@@ -0,0 +1,306 @@
1
+ from dataclasses import dataclass
2
+ import functools
3
+ import logging
4
+ import mimetypes
5
+ import threading
6
+ import traceback
7
+ import time
8
+ from typing import Optional
9
+
10
+ from escpos.config import Config as escpos_Config # type: ignore
11
+ from escpos.constants import ESC, PAPER_PART_CUT, GS # type: ignore
12
+ from escpos.printer import Dummy # type: ignore
13
+ from escpos.printer.usb import Usb as UsbPrinter # type: ignore
14
+ import six
15
+
16
+ import usb # type: ignore
17
+
18
+ import tinyprint
19
+ from tinyprint.jobconfig import JobConfig, Resource
20
+ from tinyprint import xescpos
21
+
22
+
23
+ @dataclass
24
+ class Job:
25
+ """Pre-process input files & prints them with ESC/POS printer."""
26
+
27
+ config: JobConfig
28
+ is_printing: bool = False
29
+ _print_job: Optional[threading.Thread] = None
30
+
31
+ def print(self):
32
+ self.is_printing = True
33
+ self._print_job = threading.Thread(target=self._print)
34
+ self._print_job.start()
35
+
36
+ def wait(self, timeout: Optional[float] = None):
37
+ """Wait/block until print job is finished"""
38
+ if self._print_job:
39
+ self._print_job.join(timeout)
40
+ self._print_job = None
41
+ self.is_printing = False
42
+
43
+ def stop(self):
44
+ """Stop currently running print job"""
45
+ if self._print_job and self._print_job.is_alive():
46
+ self._logger.info(" stop print! >")
47
+ self.is_printing = False
48
+ self.wait()
49
+
50
+ def _print(self):
51
+ self._logger.info("< preprocess input ...")
52
+ page_tuple = self._generate_page_tuple()
53
+ self._logger.info(" finished preprocessing >")
54
+ self._logger.info("< create ESC/POS codes ...")
55
+ cmd_list = self._generate_cmd_list(page_tuple)
56
+ self._logger.info(" finished creating ESC/POS codes >")
57
+ self._logger.info("< start printing")
58
+ self._send_to_printer(cmd_list)
59
+ self._logger.info(" finished printing >")
60
+ self.is_printing = False
61
+
62
+ def close(self):
63
+ self._logger.info("close printer")
64
+ self.stop()
65
+ self.printer.close()
66
+
67
+ def __enter__(self):
68
+ return self
69
+
70
+ def __exit__(self, *args, **kwargs):
71
+ self.wait()
72
+ self.close()
73
+
74
+ def __del__(self):
75
+ self.close()
76
+
77
+ def _generate_cmd_list(self, page_tuple):
78
+ dummy = Dummy()
79
+ for copy_index in range(self.config.copy_count):
80
+ for i, page in enumerate(page_tuple):
81
+ try:
82
+ page(dummy)
83
+ except Exception:
84
+ tb = traceback.format_exc()
85
+ self._logger.warn(f"error when printing page {i+1}: {tb}")
86
+ output_list = dummy._output_list
87
+ if self.config.cut:
88
+ return self._add_auto_cut(output_list)
89
+ else:
90
+ return [getattr(cmd, "cmd", cmd) for cmd in output_list]
91
+
92
+ def _add_auto_cut(self, output_list: list[bytes | xescpos.Cmd]) -> list[bytes]:
93
+ # NOTE In case we want to cut the paper after each image,
94
+ # we need to take special care to avoid whitespace between
95
+ # each image. This whitespace happens if we just use the
96
+ # default 'cut' method, because then the printer needs to
97
+ # feed the paper until it reaches the cutting point (when
98
+ # finished printing an image, some parts of the paper are
99
+ # still inside the printer). So what we need to do is:
100
+ #
101
+ # - print image 1
102
+ # - print image 2 partially
103
+ # - cut paper
104
+ # - print image 2 partially
105
+ # - print image 3 partially
106
+ # - cut paper
107
+ # - ...
108
+ #
109
+ # XXX This cutting currently only works for 'bitImageColumn' images
110
+ # XXX This cutting currently only works for RessourceType PDF/ImageList
111
+ processed_list = []
112
+ img_cut_index = self.printer.profile.img_cut_index
113
+ for cmd in output_list:
114
+ match cmd:
115
+ case xescpos.Cmd():
116
+ processed_list.append(cmd.cmd)
117
+ if cmd.index == img_cut_index:
118
+ processed_list.extend(
119
+ [
120
+ ESC + b"2",
121
+ PAPER_PART_CUT,
122
+ ESC + b"3" + six.int2byte(16),
123
+ ]
124
+ )
125
+ case _:
126
+ processed_list.append(cmd)
127
+ return processed_list
128
+
129
+ def _generate_page_tuple(self):
130
+ page_list = []
131
+ for resource in self.config.resource_list:
132
+ preprocessor = self.resource_to_preprocessor(resource)
133
+ page_list.append(preprocessor(resource))
134
+ return tuple(page_list)
135
+
136
+ def _send_to_printer(self, cmd_list: list[bytes]):
137
+ printer = self.printer
138
+ sleep_time = self.config.sleep_time or printer.profile.sleep_time
139
+ try:
140
+ for cmd in cmd_list:
141
+ if not self.is_printing:
142
+ break
143
+ try:
144
+ printer._raw(cmd)
145
+ # NOTE ( USB device printer fix )
146
+ #
147
+ # Do not lose any command:
148
+ # Python sends faster commands than the matrix
149
+ # printer can print. And the USB device doesn't
150
+ # block until one print command is finished, but
151
+ # returns immediately. If we run into a USBTimeout,
152
+ # this becomes a serious problem, because then
153
+ # we restart the printer & only resend the last command.
154
+ # In case the previous command wasn't processed yet,
155
+ # we'd lose this command (usually the paper cut).
156
+ # To avoid this, we wait for a little time, to better
157
+ # synchronize Python and the Matrix printer.
158
+ if sleep_time and len(cmd) > 5:
159
+ time.sleep(sleep_time)
160
+ # NOTE ( USB device printer fix )
161
+ # Don't give up when a time out happens - it seems USB
162
+ # connection is sometimes unstable & breaks.
163
+ except usb.core.USBTimeoutError:
164
+ logging.warn("timed out ... reset printer")
165
+ # We first read all data from printer to reduce the
166
+ # likelihood that the printer outputs glitchy gibberish
167
+ # to the print.
168
+ while printer._read():
169
+ time.sleep(0.01)
170
+ # Then reset printer to make it workable again
171
+ time.sleep(10)
172
+ printer.close()
173
+ time.sleep(10)
174
+ printer.open()
175
+ time.sleep(10)
176
+ # Finally try send command again
177
+ printer._raw(cmd)
178
+ finally:
179
+ # No matter what happens - make final cut.
180
+ # Final cut
181
+ # 66 => move to cut position
182
+ # 00 => then make full cut
183
+ printer._raw(GS + b"V" + six.int2byte(66) + b"\x00")
184
+
185
+ @functools.cached_property
186
+ def printer(self):
187
+ if not self.config.printer_config:
188
+ self._logger.warn("no printer config: use dummy printer")
189
+ return Dummy()
190
+ c = escpos_Config()
191
+ c.load(self.config.printer_config)
192
+ p = c.printer()
193
+ patch_printer_profile(p)
194
+ return p
195
+
196
+ @functools.cached_property
197
+ def _logger(self):
198
+ """The class based logger."""
199
+ cls = type(self)
200
+ logger = logging.getLogger(f"{cls.__module__}.{cls.__name__}")
201
+ logger.setLevel(tinyprint.config.LOGGING_LEVEL)
202
+ return logger
203
+
204
+ def resource_to_preprocessor(self, resource: Resource):
205
+ path = resource[0]
206
+ p = None
207
+ if _is_binary_file(path):
208
+ m = mimetypes.guess_type(path)
209
+ if m[0]:
210
+ if m[0].startswith("image"):
211
+ p = _ImagePreprocessor()
212
+ elif m[0] == "application/pdf":
213
+ p = _PdfPreprocessor()
214
+ else:
215
+ p = _MarkdownPreprocessor()
216
+ if not p:
217
+ raise NotImplementedError(f"can't guess type of {path}")
218
+ return p(self)
219
+
220
+
221
+ def patch_printer_profile(printer):
222
+ """Auto-add printer-dependent options that aren't in escpos profile"""
223
+ profile = printer.profile
224
+
225
+ # Default values in case printer is unknown
226
+ profile.img_kwargs = {}
227
+ profile.img_cut_index = 3
228
+ profile.img_scale_factor = 1
229
+ profile.img_x_scale_factor = 1
230
+ profile.img_y_scale_factor = 1
231
+ profile.sleep_time = 0
232
+
233
+ # Special treatment for specific printer models.
234
+ match profile.profile_data["name"]:
235
+ case "TM-T88III":
236
+ profile.img_kwargs = dict(
237
+ impl="bitImageColumn",
238
+ high_density_vertical=True,
239
+ high_density_horizontal=True,
240
+ center=False,
241
+ )
242
+ profile.img_cut_index = 4
243
+ case "TM-U220B":
244
+ profile.img_kwargs = dict(
245
+ impl="bitImageColumn",
246
+ high_density_vertical=False,
247
+ high_density_horizontal=False,
248
+ center=False,
249
+ fragment_height=32,
250
+ )
251
+ profile.img_cut_index = 9
252
+ # Profile is wrong, it's 200 dots, not 400
253
+ # https://files.support.epson.com/pdf/pos/bulk/tm-u220_trg_en_std_reve.pdf
254
+ # https://github.com/receipt-print-hq/escpos-printer-db/pull/87/commits/242299097
255
+ profile.profile_data["media"]["width"]["pixels"] = 200
256
+
257
+ case _:
258
+ pass
259
+
260
+ # Special treatment for specific connections to printer.
261
+ match printer:
262
+ case UsbPrinter():
263
+ # Usb printer immediately returns, but is usually slower
264
+ # than Python - improve synchronicity & sleep after each
265
+ # print command. Otherwise we get a lot of timeouts &
266
+ # printed gibberish. The time here seems to depend on the
267
+ # printed material. If it's more dense & blackish / colorful,
268
+ # then we need a higher value to not run into any trouble.
269
+ # More spare material seems to be less problematic. Also
270
+ # the problem becomes bigger if we print multiple pages.
271
+ profile.sleep_time = 0.3
272
+ case _:
273
+ pass
274
+
275
+
276
+ def _is_binary_file(path):
277
+ with open(path, "rb") as f:
278
+ header = f.read(1024)
279
+ textchars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7F})
280
+ return bool(header.translate(None, textchars))
281
+
282
+
283
+ # XXX Fix circular import error
284
+
285
+
286
+ @functools.cache
287
+ def _ImagePreprocessor():
288
+ from tinyprint.preprocessor import ImagePreprocessor
289
+
290
+ return ImagePreprocessor
291
+
292
+
293
+ @functools.cache
294
+ def _PdfPreprocessor():
295
+
296
+ from tinyprint.preprocessor import PdfPreprocessor
297
+
298
+ return PdfPreprocessor
299
+
300
+
301
+ @functools.cache
302
+ def _MarkdownPreprocessor():
303
+
304
+ from tinyprint.preprocessor import MarkdownPreprocessor
305
+
306
+ return MarkdownPreprocessor
tinyprint/jobconfig.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import enum
5
+ from typing import Any, Optional, TypeAlias
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
+ ResourcePath: TypeAlias = str
52
+ FragmentHeight: TypeAlias = Optional[int]
53
+ Resource: TypeAlias = tuple[ResourcePath, FragmentHeight]
54
+
55
+
56
+ @dataclass
57
+ class JobConfig(DataClassJsonMixin):
58
+ resource_list: list[Resource]
59
+ printer_config: Optional[str] = None
60
+ copy_count: int = 1
61
+ cut: bool = True
62
+ orientation: Orientation = Orientation.HORIZONTAL
63
+ page_size: Optional[PageSize] = None
64
+ sleep_time: Optional[float] = None
65
+
66
+ def to_file(self, path: str):
67
+ with open(path, "w") as f:
68
+ f.write(self.to_json())
69
+
70
+ @classmethod
71
+ def from_file(cls, path: str):
72
+ with open(path, "r") as f:
73
+ return cls.from_json(f.read())