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
|
@@ -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,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()
|