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.
@@ -0,0 +1,172 @@
1
+ """Patching escpos library to cut between images without whitespace between
2
+
3
+ See comment at Job.print for more details.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ import logging
8
+ import time
9
+ from typing import Optional
10
+
11
+ import six # type: ignore
12
+
13
+ from escpos.image import EscposImage # type: ignore
14
+ from escpos.escpos import Escpos # type: ignore
15
+ from escpos.constants import ESC, GS # type: ignore
16
+ from escpos.exceptions import ImageWidthError # type: ignore
17
+ from escpos.printer.usb import Usb as UsbPrinter # type: ignore
18
+
19
+
20
+ @dataclass
21
+ class PrinterCommand:
22
+ """Wrapper for ESC/POS commands with an index for tracking command sequence.
23
+
24
+ This is used to track the position of commands for special handling,
25
+ particularly for paper cutting between images.
26
+ """
27
+ index: int # Position in command sequence
28
+ cmd: bytes # Raw ESC/POS command bytes
29
+
30
+
31
+
32
+ if 1:
33
+
34
+ def _image(
35
+ self,
36
+ img_source,
37
+ high_density_vertical: bool = True,
38
+ high_density_horizontal: bool = True,
39
+ impl: str = "bitImageRaster",
40
+ fragment_height: int = 960,
41
+ center: bool = False,
42
+ cmd_index_offset: int = 0,
43
+ ) -> Optional[int]:
44
+ """Print an image.
45
+
46
+ You can select whether the printer should print in high density or not. The default value is high density.
47
+ When printing in low density, the image will be stretched.
48
+
49
+ Esc/Pos supplies several commands for printing. This function supports three of them. Please try to vary the
50
+ implementations if you have any problems. For example the printer `IT80-002` will have trouble aligning
51
+ images that are not printed in Column-mode.
52
+
53
+ The available printing implementations are:
54
+
55
+ * `bitImageRaster`: prints with the `GS v 0`-command
56
+ * `graphics`: prints with the `GS ( L`-command
57
+ * `bitImageColumn`: prints with the `ESC *`-command
58
+
59
+ When trying to center an image make sure you have initialized the printer with a valid profile, that
60
+ contains a media width pixel field. Otherwise the centering will have no effect.
61
+
62
+ :param img_source: PIL image or filename to load: `jpg`, `gif`, `png` or `bmp`
63
+ :param high_density_vertical: print in high density in vertical direction *default:* True
64
+ :param high_density_horizontal: print in high density in horizontal direction *default:* True
65
+ :param impl: choose image printing mode between `bitImageRaster`, `graphics` or `bitImageColumn`
66
+ :param fragment_height: Images larger than this will be split into multiple fragments *default:* 960
67
+ :param center: Center image horizontally *default:* False
68
+
69
+ """
70
+ im = EscposImage(img_source)
71
+
72
+ try:
73
+ if self.profile.profile_data["media"]["width"]["pixels"] == "Unknown":
74
+ logging.debug(
75
+ "The media.width.pixel field of the printer profile is not set. "
76
+ + "The center flag will have no effect."
77
+ )
78
+
79
+ max_width = int(self.profile.profile_data["media"]["width"]["pixels"])
80
+
81
+ if im.width > max_width:
82
+ raise ImageWidthError(f"{im.width} > {max_width}")
83
+
84
+ if center:
85
+ im.center(max_width)
86
+ except KeyError:
87
+ # If the printer's pixel width is not known, print anyways...
88
+ pass
89
+ except ValueError:
90
+ # If the max_width cannot be converted to an int, print anyways...
91
+ pass
92
+
93
+ if im.height > fragment_height:
94
+ fragments = im.split(fragment_height)
95
+ cmd_index_offset = 0
96
+ for fragment in fragments:
97
+ cmd_index_offset = self.image(
98
+ fragment,
99
+ high_density_vertical=high_density_vertical,
100
+ high_density_horizontal=high_density_horizontal,
101
+ impl=impl,
102
+ fragment_height=fragment_height,
103
+ cmd_index_offset=cmd_index_offset,
104
+ )
105
+ # XXX dummy printer doesn't have '_sleep_in_fragment' method
106
+ # self._sleep_in_fragment()
107
+ return None
108
+
109
+ if impl == "bitImageRaster":
110
+ # GS v 0, raster format bit image
111
+ density_byte = (0 if high_density_horizontal else 1) + (
112
+ 0 if high_density_vertical else 2
113
+ )
114
+ header = (
115
+ GS
116
+ + b"v0"
117
+ + bytes((density_byte,))
118
+ + self._int_low_high(im.width_bytes, 2)
119
+ + self._int_low_high(im.height, 2)
120
+ )
121
+ self._raw(header + im.to_raster_format())
122
+
123
+ if impl == "graphics":
124
+ # GS ( L raster format graphics
125
+ img_header = self._int_low_high(im.width, 2) + self._int_low_high(
126
+ im.height, 2
127
+ )
128
+ tone = b"0"
129
+ colors = b"1"
130
+ ym = b"\x01" if high_density_vertical else b"\x02"
131
+ xm = b"\x01" if high_density_horizontal else b"\x02"
132
+ header = tone + xm + ym + colors + img_header
133
+ raster_data = im.to_raster_format()
134
+ self._image_send_graphics_data(b"0", b"p", header + raster_data)
135
+ self._image_send_graphics_data(b"0", b"2", b"")
136
+
137
+ if impl == "bitImageColumn":
138
+ # ESC *, column format bit image
139
+ density_byte = (1 if high_density_horizontal else 0) + (
140
+ 32 if high_density_vertical else 0
141
+ )
142
+ header = (
143
+ ESC
144
+ + b"*"
145
+ + six.int2byte(density_byte)
146
+ + self._int_low_high(im.width, 2)
147
+ )
148
+ self._raw(PrinterCommand(cmd_index_offset, ESC + b"3" + six.int2byte(16)))
149
+ for i, blob in enumerate(im.to_column_format(high_density_vertical)):
150
+ cmd_index = i + 1 + cmd_index_offset
151
+ self._raw(PrinterCommand(cmd_index, header + blob + b"\n"))
152
+ self._raw(ESC + b"2")
153
+ return cmd_index_offset + i + 1
154
+
155
+ return None
156
+
157
+ Escpos.image = _image
158
+
159
+
160
+ if 1:
161
+
162
+ # Usb printer: reduce likelihood for timeout gibberish
163
+
164
+ _Usb_raw_original = UsbPrinter._raw
165
+
166
+ def _Usb_raw(self, *args, **kwargs) -> None:
167
+ _Usb_raw_original(self, *args, **kwargs)
168
+ # NOTE Patch to reduce likelihood for timeout gibberish
169
+ while self._read():
170
+ time.sleep(0.01)
171
+
172
+ UsbPrinter._raw = _Usb_raw
@@ -0,0 +1,26 @@
1
+ from marko import block
2
+
3
+ # Allow multiple blank lines to create multiple blank lines
4
+
5
+
6
+ class BlankLine(block.BlockElement):
7
+ """Blank lines"""
8
+
9
+ priority = 5
10
+
11
+ def __init__(self, start: int) -> None:
12
+ self._anchor = start
13
+
14
+ @classmethod
15
+ def match(cls, source) -> bool:
16
+ line = source.next_line()
17
+ return line is not None and not line.strip()
18
+
19
+ @classmethod
20
+ def parse(cls, source) -> int:
21
+ m = source.match
22
+ source.consume()
23
+ return m
24
+
25
+
26
+ block.BlankLine = BlankLine # type: ignore
tinyprint/patches.py ADDED
@@ -0,0 +1,28 @@
1
+ """Monkey patches"""
2
+
3
+ from marko import block
4
+
5
+ # Allow multiple blank lines to create multiple blank lines
6
+
7
+
8
+ class BlankLine(block.BlockElement):
9
+ """Blank lines"""
10
+
11
+ priority = 5
12
+
13
+ def __init__(self, start: int) -> None:
14
+ self._anchor = start
15
+
16
+ @classmethod
17
+ def match(cls, source) -> bool:
18
+ line = source.next_line()
19
+ return line is not None and not line.strip()
20
+
21
+ @classmethod
22
+ def parse(cls, source) -> int:
23
+ m = source.match
24
+ source.consume()
25
+ return m
26
+
27
+
28
+ block.BlankLine = BlankLine
@@ -0,0 +1,291 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ from functools import cached_property
5
+ import mimetypes
6
+ import os
7
+ from pathlib import Path
8
+ from tempfile import TemporaryDirectory
9
+ from typing import Callable, TypeAlias, Optional
10
+
11
+ from escpos.escpos import Escpos # type: ignore
12
+ from escpos.constants import TXT_STYLE # type: ignore[import-untyped]
13
+ import marko # type: ignore
14
+ from PIL import Image # type: ignore
15
+ from pdf2image import convert_from_path # type: ignore
16
+
17
+ from tinyprint.jobconfig import JobConfig, Resource, Orientation
18
+
19
+ Page: TypeAlias = Callable[[Escpos], None]
20
+
21
+
22
+ def create_pages_from_resources(config, printer_profile) -> tuple[Page, ...]:
23
+ """Convert a list of resources into printable page functions."""
24
+ page_list: list[Page] = []
25
+ for resource in config.resource_list:
26
+ preprocessor_class = get_preprocessor_for_resource(resource)
27
+ preprocessor = preprocessor_class(config, printer_profile)
28
+ page_list.extend(preprocessor(resource))
29
+ return tuple(page_list)
30
+
31
+
32
+ def get_preprocessor_for_resource(resource: Resource) -> type[Preprocessor]:
33
+ """Return the appropriate preprocessor class for the given resource."""
34
+ path = resource.path
35
+ if _is_text_file(path):
36
+ return MarkdownPreprocessor
37
+ else:
38
+ m = mimetypes.guess_type(path)
39
+ if m[0]:
40
+ if m[0].startswith("image"):
41
+ return ImagePreprocessor
42
+ elif m[0] == "application/pdf":
43
+ return PdfPreprocessor
44
+ raise NotImplementedError(f"can't guess type of {path}")
45
+
46
+
47
+ # helper
48
+ def _is_text_file(path, blocksize=4096):
49
+ try:
50
+ with open(path, "rb") as f:
51
+ f.read(blocksize).decode("utf-8")
52
+ return True
53
+ except UnicodeDecodeError:
54
+ return False
55
+
56
+
57
+ class Preprocessor(abc.ABC):
58
+ def __init__(self, config: JobConfig, printer_profile):
59
+ self.config = config
60
+ self.printer_profile = printer_profile
61
+
62
+ @abc.abstractmethod
63
+ def __call__(self, resource: Resource) -> tuple[Page, ...]: ...
64
+
65
+
66
+ class ImagePreprocessor(Preprocessor):
67
+ def __call__(self, resource: Resource):
68
+ return (get_image_page(resource, self.config, self.printer_profile),)
69
+
70
+
71
+ def get_image_page(resource, config, printer_profile):
72
+ path, fragment_height = resource.path, resource.fragment_height
73
+
74
+ # Immediately load & process image - in case image path is volatile
75
+ # (as it's the case if PdfPreprocessor parses us an img path), we
76
+ # don't loose the image. The downside of this is that we need to keep
77
+ # the image in our RAM. But because we resize, and the resolution of
78
+ # the ESC/POS printer is usually quite low, this is acceptable and
79
+ # should only create problem in case of very large books.
80
+ with Image.open(path) as im:
81
+ if config.orientation != Orientation.HORIZONTAL:
82
+ im = im.rotate(angle=-90, expand=True)
83
+ max_width = printer_profile.profile_data["media"]["width"]["pixels"]
84
+ assert max_width != "Unknown", "max_width of printer unknown!"
85
+ max_width = int(max_width * printer_profile.img_scale_factor)
86
+ width = im.width
87
+ heigth = im.height
88
+ factor = max_width / width
89
+ final_im = im.resize(
90
+ (
91
+ int(width * factor * printer_profile.img_x_scale_factor),
92
+ int(heigth * factor * printer_profile.img_y_scale_factor),
93
+ )
94
+ )
95
+
96
+ color = ("black", "red")[resource.color]
97
+
98
+ def _(printer):
99
+ printer._raw(TXT_STYLE["color"][color])
100
+ try:
101
+ # Add extra space between to make spaces between cuts even
102
+ # XXX Does number '10' need to change depending on printer?
103
+ if config.cut:
104
+ printer.line_spacing(10)
105
+ printer.ln()
106
+ printer.line_spacing()
107
+
108
+ img_kwargs = dict(printer_profile.img_kwargs)
109
+ if fragment_height:
110
+ img_kwargs["fragment_height"] = fragment_height
111
+
112
+ printer.image(
113
+ final_im,
114
+ **img_kwargs,
115
+ )
116
+ finally:
117
+ final_im.close()
118
+
119
+ return _
120
+
121
+
122
+ class PdfPreprocessor(ImagePreprocessor):
123
+ def __call__(self, resource: Resource):
124
+ pdf_path = resource.path
125
+ with TemporaryDirectory() as tmp_path:
126
+ image_list = convert_from_path(pdf_path, output_folder=tmp_path, dpi=600)
127
+ if self.config.orientation == Orientation.HORIZONTAL:
128
+ image_list = [im.rotate(angle=-90, expand=True) for im in image_list]
129
+ page_list = []
130
+ for i, img in enumerate(image_list):
131
+ p = os.path.join(tmp_path, f"{i}.png")
132
+ img.save(p)
133
+ page_list.extend(super().__call__(Resource(p, 0)))
134
+ return tuple(page_list)
135
+
136
+
137
+ class MarkdownPreprocessor(Preprocessor):
138
+ def __init__(self, *args, **kwargs):
139
+ super().__init__(*args, **kwargs)
140
+ self.markdown = marko.Markdown(renderer=_EscposRenderer)
141
+ self.markdown._setup_extensions()
142
+
143
+ def __call__(self, resource: Resource):
144
+ with open(resource.path, "r") as f:
145
+ md = f.read()
146
+
147
+ def _(printer):
148
+ self.markdown.renderer.set_markdown_path(resource.path)
149
+ self.markdown.renderer.set_printer(printer)
150
+ self.markdown.renderer.set_config(self.config)
151
+ self.markdown.renderer.set_printer_profile(self.printer_profile)
152
+ self.markdown(md)
153
+
154
+ return (_,)
155
+
156
+
157
+ class _EscposRenderer(marko.renderer.Renderer):
158
+ # Ref: https://github.com/frostming/marko/blob/master/marko/html_renderer.py
159
+
160
+ # TODO Add auto page breaks
161
+
162
+ def render_raw_text(self, element) -> str:
163
+ self._printer.text(element.children)
164
+ # XXX: Temporarily disable auto-line break until it can be configured in UI
165
+ # word_list = element.children.split(" ")
166
+ # max_char_count = self.max_char_count
167
+ # for word in word_list:
168
+ # char_count = len(word)
169
+ # # If word is too big for one line, just skip line breaking.
170
+ # if char_count > max_char_count:
171
+ # self._printer.text(word)
172
+ # self.line_position = self.line_position % max_char_count
173
+ # continue
174
+ # add_whitespace = self.line_position > 0
175
+ # new_pos = self.line_position + char_count + add_whitespace
176
+ # diff = new_pos - max_char_count
177
+ # if diff <= 0:
178
+ # if add_whitespace:
179
+ # self._printer.text(" ")
180
+ # self._printer.text(word)
181
+ # self.line_position = new_pos
182
+ # else: # diff > 0
183
+ # space_count = max_char_count - self.line_position
184
+ # if space_count:
185
+ # self._printer.text(" " * space_count)
186
+ # self._printer.text(word)
187
+ # self.line_position = char_count
188
+ return ""
189
+
190
+ def render_emphasis(self, element) -> str:
191
+ self._set(bold=True)
192
+ self.render_children(element)
193
+ self._set(bold=False)
194
+ return ""
195
+
196
+ def render_image(self, element) -> str:
197
+ path = _resolve_path(element.dest, self._markdown_path)
198
+ resource = Resource(path=path)
199
+ get_image_page(resource, self._config, self._printer_profile)(self._printer)
200
+ return ""
201
+
202
+ def render_strong_emphasis(self, element) -> str:
203
+ return self.render_emphasis(element)
204
+
205
+ def render_blank_line(self, element=None):
206
+ self.render_line_break(line_count=1)
207
+ return ""
208
+
209
+ def render_line_break(self, element=None, line_count: Optional[int] = None) -> str:
210
+ soft = element.soft if element else True
211
+ if line_count is None:
212
+ if soft:
213
+ line_count = 1
214
+ else:
215
+ line_count = 2
216
+ self._printer.ln(line_count)
217
+ self.line_position = 0
218
+ return ""
219
+
220
+ def render_thematic_break(self, element) -> str:
221
+ self._printer.cut()
222
+ return ""
223
+
224
+ def render_paragraph(self, element) -> str:
225
+ self.render_children(element)
226
+ self.render_line_break()
227
+ return ""
228
+
229
+ def render_heading(self, element) -> str:
230
+ self.max_char_count = self.printer_profile.big_char_count
231
+ self._set(double_height=True, double_width=True)
232
+ self.render_children(element)
233
+ self._set(double_height=False, double_width=False, normal_textsize=True)
234
+ self.render_line_break(line_count=3)
235
+ self.max_char_count = self.printer_profile.default_char_count
236
+ return ""
237
+
238
+ def set_printer(self, printer):
239
+ self._printer = printer
240
+
241
+ def set_config(self, config):
242
+ self._config = config
243
+
244
+ def set_printer_profile(self, printer_profile):
245
+ self._printer_profile = printer_profile
246
+
247
+ def set_markdown_path(self, markdown_path):
248
+ self._markdown_path = markdown_path
249
+
250
+ def _set(self, *args, **kwargs):
251
+ if self._printer:
252
+ self._printer.set(*args, **kwargs)
253
+ else:
254
+ raise RuntimeError("printer not yet set")
255
+
256
+ @property
257
+ def line_position(self):
258
+ try:
259
+ return self._line_position
260
+ except AttributeError:
261
+ return 0
262
+
263
+ @line_position.setter
264
+ def line_position(self, line_position: int):
265
+ self._line_position = line_position
266
+
267
+ @cached_property
268
+ def printer_profile(self):
269
+ return self._printer_profile
270
+
271
+ @property
272
+ def max_char_count(self):
273
+ try:
274
+ return self._max_char_count
275
+ except AttributeError:
276
+ self.max_char_count = self.printer_profile.default_char_count
277
+ return self.max_char_count
278
+
279
+ @max_char_count.setter
280
+ def max_char_count(self, max_char_count: int):
281
+ self._max_char_count = max_char_count
282
+
283
+
284
+ def _resolve_path(path: str, markdown_path: str) -> str:
285
+ p = Path(path)
286
+
287
+ if p.is_absolute():
288
+ return path
289
+
290
+ markdown_dir = Path(markdown_path).parent
291
+ return str((markdown_dir / p).resolve())
@@ -0,0 +1,2 @@
1
+ from . import manager
2
+ from . import profile
@@ -0,0 +1,137 @@
1
+ """Printer communication manager for handling ESC/POS printer interactions."""
2
+
3
+ import functools
4
+ import logging
5
+ import time
6
+ import threading
7
+ from typing import Optional
8
+
9
+ import six
10
+ import usb # type: ignore
11
+
12
+ from escpos.constants import GS # type: ignore
13
+ from escpos.config import Config as escpos_Config # type: ignore
14
+ from escpos.printer import Dummy # type: ignore
15
+
16
+ from tinyprint.printer.profile import patch_printer_profile
17
+
18
+
19
+ class Manager:
20
+ """Manages printer communication including connection handling and command sending."""
21
+
22
+ def __init__(self, config):
23
+ """Initialize the printer manager.
24
+
25
+ Args:
26
+ config: Configuration object containing printer settings.
27
+ """
28
+ self.config = config
29
+ self._logger = logging.getLogger(f"{__name__}.Manager")
30
+ self._print_job: Optional[threading.Thread] = None
31
+ self.is_printing = False
32
+
33
+ @functools.cached_property
34
+ def printer(self):
35
+ if not self.config.printer_config:
36
+ self._logger.warning("no printer config: use dummy printer")
37
+ p = Dummy()
38
+ else:
39
+ c = escpos_Config()
40
+ c.load(self.config.printer_config)
41
+ p = c.printer()
42
+ patch_printer_profile(p)
43
+ return p
44
+
45
+ def print(self, cmd_list):
46
+ """Start printing in background thread"""
47
+ self.is_printing = True
48
+
49
+ def _():
50
+ self._print(cmd_list)
51
+ self.is_printing = False
52
+
53
+ self._print_job = threading.Thread(target=_)
54
+ self._print_job.start()
55
+
56
+ def _print(self, cmd_list: list[bytes]):
57
+ try:
58
+ for cmd in cmd_list:
59
+ if not self.is_printing:
60
+ break
61
+ self._print1(cmd)
62
+ finally:
63
+ # No matter what happens - make final cut.
64
+ # Final cut
65
+ # 66 => move to cut position
66
+ # 00 => then make full cut
67
+ self.printer._raw(GS + b"V" + six.int2byte(66) + b"\x00")
68
+
69
+ def _print1(self, cmd: bytes):
70
+ printer = self.printer
71
+ sleep_time = self.config.sleep_time or printer.profile.sleep_time
72
+ try:
73
+ printer._raw(cmd)
74
+ # NOTE ( USB device printer fix )
75
+ #
76
+ # Do not lose any command:
77
+ # Python sends faster commands than the matrix
78
+ # printer can print. And the USB device doesn't
79
+ # block until one print command is finished, but
80
+ # returns immediately. If we run into a USBTimeout,
81
+ # this becomes a serious problem, because then
82
+ # we restart the printer & only resend the last command.
83
+ # In case the previous command wasn't processed yet,
84
+ # we'd lose this command (usually the paper cut).
85
+ # To avoid this, we wait for a little time, to better
86
+ # synchronize Python and the Matrix printer.
87
+ if sleep_time and len(cmd) > 5:
88
+ self._sleep(sleep_time)
89
+ # NOTE ( USB device printer fix )
90
+ # Don't give up when a time out happens - it seems USB
91
+ # connection is sometimes unstable & breaks.
92
+ except usb.core.USBTimeoutError:
93
+ self._logger.warning("timed out ... reset printer")
94
+ # We first read all data from printer to reduce the
95
+ # likelihood that the printer outputs glitchy gibberish
96
+ # to the print.
97
+ while printer._read():
98
+ time.sleep(0.01)
99
+ # Then reset printer to make it workable again
100
+ self._sleep(10)
101
+ printer.close()
102
+ self._sleep(10)
103
+ printer.open()
104
+ self._sleep(10)
105
+ # Finally try send command again
106
+ printer._raw(cmd)
107
+
108
+ def _sleep(self, total_time: float):
109
+ """Sleep that returns immediatelly if no longer printing"""
110
+ check_interval: float = 1
111
+ end_time = time.monotonic() + total_time
112
+
113
+ while time.monotonic() < end_time:
114
+ if not self.is_printing:
115
+ return
116
+ time.sleep(min(check_interval, end_time - time.monotonic()))
117
+
118
+ def wait(self, timeout: Optional[float] = None):
119
+ """Wait/block until print is finished"""
120
+ if self._print_job:
121
+ self._print_job.join(timeout)
122
+ self._print_job = None
123
+ self.is_printing = False
124
+
125
+ def stop(self):
126
+ """Stop currently running printing"""
127
+ if self._print_job and self._print_job.is_alive():
128
+ self.is_printing = False
129
+ self.wait()
130
+
131
+ def close(self):
132
+ self._logger.info("close printer")
133
+ self.stop()
134
+ self.printer.close()
135
+
136
+ def __del__(self):
137
+ self.close()