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 +5 -0
- tinyprint/__main__.py +254 -0
- tinyprint/config.py +5 -0
- tinyprint/job.py +306 -0
- tinyprint/jobconfig.py +73 -0
- tinyprint/preprocessor.py +132 -0
- tinyprint/xescpos.py +166 -0
- tinyprint-0.1.0.dist-info/LICENSE +674 -0
- tinyprint-0.1.0.dist-info/METADATA +21 -0
- tinyprint-0.1.0.dist-info/RECORD +13 -0
- tinyprint-0.1.0.dist-info/WHEEL +5 -0
- tinyprint-0.1.0.dist-info/entry_points.txt +2 -0
- tinyprint-0.1.0.dist-info/top_level.txt +1 -0
tinyprint/__init__.py
ADDED
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
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())
|