From 6924b9c2aa0eecde033b37af201d4569461ac2ab Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sat, 3 Feb 2024 13:31:58 +0000 Subject: [PATCH] Update margins logic Discard margin handling in the printing driver, which used to add mental load when trying to figure out the logic of how margins are implemented. Having all margin handling in "userspace" of render engines (i.e. not in the print driver abstraction level), will enable us to implement more complicated logic in the future. --- src/dymoprint/cli/cli.py | 39 +++--- src/dymoprint/gui/gui.py | 87 +++++++------ src/dymoprint/gui/q_dymo_labels_list.py | 117 ++++++++++++++---- src/dymoprint/lib/dymo_labeler.py | 28 +++-- src/dymoprint/lib/render_engines/__init__.py | 7 ++ .../render_engines/horizontally_combined.py | 36 ------ src/dymoprint/lib/render_engines/margins.py | 107 ++++++++++++++++ .../lib/render_engines/print_payload.py | 32 +++++ .../lib/render_engines/print_preview.py | 32 +++++ src/dymoprint/lib/utils.py | 6 +- 10 files changed, 352 insertions(+), 139 deletions(-) create mode 100644 src/dymoprint/lib/render_engines/margins.py create mode 100644 src/dymoprint/lib/render_engines/print_payload.py create mode 100644 src/dymoprint/lib/render_engines/print_preview.py diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index ea00227..f11a58c 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -18,7 +18,6 @@ DEFAULT_MARGIN_PX, PIXELS_PER_MM, USE_QR, - VERTICAL_PREVIEW_MARGIN_PX, e_qrcode, ) from dymoprint.lib.dymo_labeler import DymoLabeler @@ -29,6 +28,8 @@ BarcodeWithTextRenderEngine, HorizontallyCombinedRenderEngine, PictureRenderEngine, + PrintPayloadRenderEngine, + PrintPreviewRenderEngine, QrRenderEngine, RenderContext, TestPatternRenderEngine, @@ -294,42 +295,36 @@ def run(): else None ) - render = HorizontallyCombinedRenderEngine( - render_engines, - min_payload_len_px=min_payload_len_px, - max_payload_len_px=max_payload_len_px, + render_engine = HorizontallyCombinedRenderEngine(render_engines) + render_kwargs = dict( + render_engine=render_engine, justify=args.justify, + visible_horizontal_margin_px=margin_px, + labeler_margin_px=DymoLabeler.get_labeler_margin_px(), + max_width_px=max_payload_len_px, + min_width_px=min_payload_len_px, ) - dymo_labeler = DymoLabeler( - margin_px=margin_px, - tape_size_mm=args.tape_size_mm, - ) + dymo_labeler = DymoLabeler(tape_size_mm=args.tape_size_mm) render_context = RenderContext(height_px=dymo_labeler.height_px) - bitmap = render.render(render_context) # print or show the label if args.preview or args.preview_inverted or args.imagemagick or args.browser: + render = PrintPreviewRenderEngine(**render_kwargs) + bitmap = render.render(render_context) LOG.debug("Demo mode: showing label..") - # fix size, adding print borders - expanded_bitmap = Image.new( - "1", - ( - bitmap.width + margin_px * 2, - bitmap.height + VERTICAL_PREVIEW_MARGIN_PX * 2, - ), - ) - expanded_bitmap.paste(bitmap, (margin_px, VERTICAL_PREVIEW_MARGIN_PX)) if args.preview or args.preview_inverted: - label_rotated = expanded_bitmap.transpose(Image.ROTATE_270) + label_rotated = bitmap.transpose(Image.ROTATE_270) print(image_to_unicode(label_rotated, invert=args.preview_inverted)) if args.imagemagick: - ImageOps.invert(expanded_bitmap).show() + ImageOps.invert(bitmap).show() if args.browser: with NamedTemporaryFile(suffix=".png", delete=False) as fp: - ImageOps.invert(expanded_bitmap).save(fp) + ImageOps.invert(bitmap).save(fp) webbrowser.open(f"file://{fp.name}") else: + render = PrintPayloadRenderEngine(**render_kwargs) + bitmap = render.render(render_context) dymo_labeler.print(bitmap) diff --git a/src/dymoprint/gui/gui.py b/src/dymoprint/gui/gui.py index 011b872..4e50392 100644 --- a/src/dymoprint/gui/gui.py +++ b/src/dymoprint/gui/gui.py @@ -20,7 +20,7 @@ ) from dymoprint.gui.common import crash_msg_box -from dymoprint.lib.constants import DEFAULT_MARGIN_PX, ICON_DIR +from dymoprint.lib.constants import ICON_DIR from dymoprint.lib.dymo_labeler import ( DymoLabeler, DymoLabelerDetectError, @@ -39,12 +39,12 @@ class DymoPrintWindow(QWidget): SUPPORTED_TAPE_SIZE_MM = (19, 12, 9, 6) DEFAULT_TAPE_SIZE_MM_INDEX = 1 - label_bitmap: Optional[Image.Image] + print_label_bitmap: Optional[Image.Image] dymo_labeler: DymoLabeler def __init__(self): super().__init__() - self.label_bitmap = None + self.print_label_bitmap = None self.detected_device = None self.window_layout = QVBoxLayout() @@ -54,11 +54,11 @@ def __init__(self): self.label_render = QLabel() self.error_label = QLabel() self.print_button = QPushButton() - self.margin_px = QSpinBox() + self.horizontal_margin_mm = QSpinBox() self.tape_size_mm = QComboBox() self.foreground_color = QComboBox() self.background_color = QComboBox() - self.min_label_len_mm = QSpinBox() + self.min_label_width_mm = QSpinBox() self.justify = QComboBox() self.info_label = QLabel() self.last_error = None @@ -84,14 +84,15 @@ def init_elements(self): shadow.setBlurRadius(15) self.label_render.setGraphicsEffect(shadow) - self.margin_px.setMinimum(20) - self.margin_px.setMaximum(1000) - self.margin_px.setValue(DEFAULT_MARGIN_PX) + h_margins_mm = round(DymoLabeler.LABELER_HORIZONTAL_MARGIN_MM) + self.horizontal_margin_mm.setMinimum(h_margins_mm) + self.horizontal_margin_mm.setMaximum(100) + self.horizontal_margin_mm.setValue(h_margins_mm) for tape_size_mm in self.SUPPORTED_TAPE_SIZE_MM: self.tape_size_mm.addItem(str(tape_size_mm), tape_size_mm) self.tape_size_mm.setCurrentIndex(self.DEFAULT_TAPE_SIZE_MM_INDEX) - self.min_label_len_mm.setMinimum(0) - self.min_label_len_mm.setMaximum(1000) + self.min_label_width_mm.setMinimum(h_margins_mm * 2) + self.min_label_width_mm.setMaximum(300) self.justify.addItems(["center", "left", "right"]) self.foreground_color.addItems( @@ -109,30 +110,31 @@ def init_timers(self): self.check_status() self.status_time = QTimer() self.status_time.timeout.connect(self.check_status) - self.status_time.setInterval(2000) - self.status_time.start(2000) + self.status_time.setInterval(20000) + self.status_time.start(20000) def init_connections(self): - self.margin_px.valueChanged.connect(self.label_list.render_label) - self.margin_px.valueChanged.connect(self.update_params) + self.horizontal_margin_mm.valueChanged.connect(self.label_list.render_label) + self.horizontal_margin_mm.valueChanged.connect(self.update_params) self.tape_size_mm.currentTextChanged.connect(self.update_params) - self.min_label_len_mm.valueChanged.connect(self.update_params) + self.min_label_width_mm.valueChanged.connect(self.update_params) self.justify.currentTextChanged.connect(self.update_params) self.foreground_color.currentTextChanged.connect(self.label_list.render_label) self.background_color.currentTextChanged.connect(self.label_list.render_label) - self.label_list.renderSignal.connect(self.update_label_render) + self.label_list.renderPrintPreviewSignal.connect(self.update_preview_render) + self.label_list.renderPrintPayloadSignal.connect(self.update_print_render) self.print_button.clicked.connect(self.print_label) def init_layout(self): settings_widget = QToolBar(self) - settings_widget.addWidget(QLabel("Margin:")) - settings_widget.addWidget(self.margin_px) + settings_widget.addWidget(QLabel("Margin [mm]:")) + settings_widget.addWidget(self.horizontal_margin_mm) settings_widget.addSeparator() - settings_widget.addWidget(QLabel("Tape Size:")) + settings_widget.addWidget(QLabel("Tape Size [mm]:")) settings_widget.addWidget(self.tape_size_mm) settings_widget.addSeparator() - settings_widget.addWidget(QLabel("Min Label Len [mm]:")) - settings_widget.addWidget(self.min_label_len_mm) + settings_widget.addWidget(QLabel("Min Label Length [mm]:")) + settings_widget.addWidget(self.min_label_width_mm) settings_widget.addSeparator() settings_widget.addWidget(QLabel("Justify:")) settings_widget.addWidget(self.justify) @@ -175,28 +177,24 @@ def init_layout(self): def update_params(self): justify: str = self.justify.currentText() - margin_px: int = self.margin_px.value() - min_label_mm_len: int = self.min_label_len_mm.value() - tape_size_mm: int = self.tape_size_mm.currentData() + horizontal_margin_mm: float = self.horizontal_margin_mm.value() + min_label_width_mm: float = self.min_label_width_mm.value() + tape_size_mm: float = self.tape_size_mm.currentData() - self.dymo_labeler.margin_px = margin_px self.dymo_labeler.tape_size_mm = tape_size_mm self.render_context.height_px = self.dymo_labeler.height_px - min_payload_len_px = max(0, (min_label_mm_len * 7) - margin_px * 2) - self.label_list.update_params(self.render_context, min_payload_len_px, justify) - - def update_label_render(self, label_bitmap): - self.label_bitmap = label_bitmap - label_image = Image.new( - "L", - ( - self.margin_px.value() + label_bitmap.width + self.margin_px.value(), - label_bitmap.height, - ), + + self.label_list.update_params( + h_margin_mm=horizontal_margin_mm, + min_label_width_mm=min_label_width_mm, + render_context=self.render_context, + justify=justify, ) - label_image.paste(label_bitmap, (self.margin_px.value(), 0)) - label_image_inv = ImageOps.invert(label_image).copy() - qim = ImageQt.ImageQt(label_image_inv) + + def update_preview_render(self, label_bitmap): + preview_label_image = label_bitmap.convert("L") + preview_label_image_inv = ImageOps.invert(preview_label_image).copy() + qim = ImageQt.ImageQt(preview_label_image_inv) q_image = QPixmap.fromImage(qim) mask = q_image.createMaskFromColor( @@ -210,15 +208,16 @@ def update_label_render(self, label_bitmap): self.label_render.setPixmap(q_image) self.label_render.adjustSize() - self.info_label.setText(f"← {px_to_mm(label_image.size[0])} mm →") + self.info_label.setText(f"← {px_to_mm(label_bitmap.size[0])} mm →") + + def update_print_render(self, print_label_bitmap): + self.print_label_bitmap = print_label_bitmap def print_label(self): try: - if self.label_bitmap is None: + if self.print_label_bitmap is None: raise RuntimeError("No label to print! Call update_label_render first.") - self.dymo_labeler.print( - self.label_bitmap, - ) + self.dymo_labeler.print(self.print_label_bitmap) except DymoLabelerPrintError as err: crash_msg_box(self, "Printing Failed!", err) diff --git a/src/dymoprint/gui/q_dymo_labels_list.py b/src/dymoprint/gui/q_dymo_labels_list.py index 51b9582..bb3c2e5 100644 --- a/src/dymoprint/gui/q_dymo_labels_list.py +++ b/src/dymoprint/gui/q_dymo_labels_list.py @@ -14,7 +14,15 @@ QrDymoLabelWidget, TextDymoLabelWidget, ) -from dymoprint.lib.render_engines import HorizontallyCombinedRenderEngine, RenderContext +from dymoprint.lib.dymo_labeler import DymoLabeler +from dymoprint.lib.render_engines import ( + HorizontallyCombinedRenderEngine, + MarginsMode, + PrintPayloadRenderEngine, + PrintPreviewRenderEngine, + RenderContext, +) +from dymoprint.lib.utils import mm_to_px LOG = logging.getLogger(__name__) @@ -30,7 +38,10 @@ class QDymoLabelList(QListWidget): Attributes: ---------- - renderSignal (QtCore.pyqtSignal): A signal emitted when the label is rendered. + renderPrintPreviewSignal (QtCore.pyqtSignal): A signal emitted when the preview + is rendered. + renderPrintPayloadSignal (QtCore.pyqtSignal): A signal emitted when the print + payload is rendered. render_context (RenderContext): The render context used for rendering the label. Methods: @@ -41,20 +52,33 @@ class QDymoLabelList(QListWidget): the label rendering. update_render_engine(self, render_engine): Updates the render context used for rendering the label. - render_label(self): Renders the label using the current render context and - emits the renderSignal. + render_preview(self): Renders the payload using the current render context and + emits the renderPrintPreviewSignal. + render_print(self): Renders the print payload using the current render context + and emits the renderPrintPayloadSignal. + render_label(self): Renders the both preview and print payloads using the + current render context and emits the corresponding signals. contextMenuEvent(self, event): Overrides the default context menu event to add or delete label widgets. """ - renderSignal = QtCore.pyqtSignal(Image.Image, name="renderSignal") + renderPrintPreviewSignal = QtCore.pyqtSignal( + Image.Image, name="renderPrintPreviewSignal" + ) + renderPrintSrenderPrintPayloadSignalignal = QtCore.pyqtSignal( + Image.Image, name="renderPrintPayloadSignal" + ) render_context: Optional[RenderContext] itemWidget: TextDymoLabelWidget + h_margin_mm: float + min_label_width_mm: Optional[float] + justify: str - def __init__(self, min_payload_len_px=0, justify="center", parent=None): + def __init__(self, parent=None): super().__init__(parent) - self.min_payload_len_px = min_payload_len_px - self.justify = justify + self.margin_px = None + self.min_label_width_mm = None + self.justify = "center" self.render_context = None self.setAlternatingRowColors(True) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) @@ -79,19 +103,22 @@ def dropEvent(self, e) -> None: def update_params( self, + h_margin_mm: float, + min_label_width_mm: float, render_context: RenderContext, - min_payload_len_px: int, - justify="center", + justify: str = "center", ): """Update the render context used for rendering the label. Args: ---- - justify: justification [center,left,right] - min_payload_len_px: minimum payload size in pixels + h_margin_mm: horizontal margin [mm] + min_label_width_mm: minimum label width [mm] render_context (RenderContext): The new render context to use. + justify: justification [center,left,right] """ - self.min_payload_len_px = min_payload_len_px + self.h_margin_mm = h_margin_mm + self.min_label_width_mm = min_label_width_mm self.justify = justify self.render_context = render_context for i in range(self.count()): @@ -100,31 +127,71 @@ def update_params( self.render_label() @property - def render_engines(self): - engines = [] + def _payload_render_engine(self): + render_engines = [] for i in range(self.count()): item = self.item(i) item_widget = self.itemWidget(self.item(i)) if item_widget and item: item.setSizeHint(item_widget.sizeHint()) - engines.append(item_widget.render_engine) - return engines + render_engines.append(item_widget.render_engine) + return HorizontallyCombinedRenderEngine(render_engines=render_engines) - def render_label(self): + def _render_with_margins(self, mode: MarginsMode, signal: QtCore.pyqtSignal): """Render the label using the current render context and emit renderSignal.""" - render_engine = HorizontallyCombinedRenderEngine( - render_engines=self.render_engines, - min_payload_len_px=self.min_payload_len_px, - max_payload_len_px=None, + render_engine = PrintPreviewRenderEngine( + render_engine=self._payload_render_engine, justify=self.justify, + visible_horizontal_margin_px=mm_to_px(self.h_margin_mm), + labeler_margin_px=DymoLabeler.get_labeler_margin_px(), + max_width_px=None, + min_width_px=mm_to_px(self.min_label_width_mm), ) try: - label_bitmap = render_engine.render(self.render_context) + bitmap = render_engine.render(self.render_context) except BaseException as err: # noqa: BLE001 crash_msg_box(self, "Render Engine Failed!", err) - label_bitmap = EmptyRenderEngine().render(self.render_context) + bitmap = EmptyRenderEngine().render(self.render_context) + + signal.emit(bitmap) - self.renderSignal.emit(label_bitmap) + def render_preview(self): + render_engine = PrintPreviewRenderEngine( + render_engine=self._payload_render_engine, + justify=self.justify, + visible_horizontal_margin_px=mm_to_px(self.h_margin_mm), + labeler_margin_px=DymoLabeler.get_labeler_margin_px(), + max_width_px=None, + min_width_px=mm_to_px(self.min_label_width_mm), + ) + try: + bitmap = render_engine.render(self.render_context) + except BaseException as err: # noqa: BLE001 + crash_msg_box(self, "Render Engine Failed!", err) + bitmap = EmptyRenderEngine().render(self.render_context) + + self.renderPrintPreviewSignal.emit(bitmap) + + def render_print(self): + render_engine = PrintPayloadRenderEngine( + render_engine=self._payload_render_engine, + justify=self.justify, + visible_horizontal_margin_px=mm_to_px(self.h_margin_mm), + labeler_margin_px=DymoLabeler.get_labeler_margin_px(), + max_width_px=None, + min_width_px=mm_to_px(self.min_label_width_mm), + ) + try: + bitmap = render_engine.render(self.render_context) + except BaseException as err: # noqa: BLE001 + crash_msg_box(self, "Render Engine Failed!", err) + bitmap = EmptyRenderEngine().render(self.render_context) + + self.renderPrintPayloadSignal.emit(bitmap) + + def render_label(self): + self.render_preview() + self.render_print() def contextMenuEvent(self, event): """Override the default context menu event to add or delete label widgets. diff --git a/src/dymoprint/lib/dymo_labeler.py b/src/dymoprint/lib/dymo_labeler.py index bca5718..ffff8c1 100755 --- a/src/dymoprint/lib/dymo_labeler.py +++ b/src/dymoprint/lib/dymo_labeler.py @@ -15,8 +15,9 @@ from PIL import Image from usb.core import NoBackendError, USBError -from dymoprint.lib.constants import DEFAULT_MARGIN_PX, ESC, SYN +from dymoprint.lib.constants import ESC, SYN from dymoprint.lib.detect import DetectedDevice, DymoUSBError, detect_device +from dymoprint.lib.utils import mm_to_px LOG = logging.getLogger(__name__) DEFAULT_TAPE_SIZE_MM = 12 @@ -219,24 +220,22 @@ def _get_status(self): self._status_request() return self._send_command() - def print_label(self, lines: list[list[int]], margin_px=DEFAULT_MARGIN_PX): + def print_label(self, lines: list[list[int]]): """Print the label described by lines. Automatically split the label if it's larger than maxLines. """ while len(lines) > self._maxLines + 1: - self._raw_print_label(lines[0 : self._maxLines], margin_px=0) + self._raw_print_label(lines[0 : self._maxLines]) del lines[0 : self._maxLines] - self._raw_print_label(lines, margin_px=margin_px) + self._raw_print_label(lines) - def _raw_print_label(self, lines: list[list[int]], margin_px=DEFAULT_MARGIN_PX): + def _raw_print_label(self, lines: list[list[int]]): """Print the label described by lines (HLF).""" # Here used to be a matrix optimization code that caused problems in issue #87 self._tape_color(0) for line in lines: self._line(line) - if margin_px > 0: - self._skip_lines(margin_px * 2) self._status_request() status = self._get_status() LOG.debug(f"Post-send response: {status}") @@ -244,15 +243,15 @@ def _raw_print_label(self, lines: list[list[int]], margin_px=DEFAULT_MARGIN_PX): class DymoLabeler: device: DetectedDevice - margin_px: int tape_size_mm: int + LABELER_HORIZONTAL_MARGIN_MM = 8.1 + LABELER_VERTICAL_MARGIN_MM = 1.9 + def __init__( self, - margin_px: int = DEFAULT_MARGIN_PX, tape_size_mm: int = DEFAULT_TAPE_SIZE_MM, ): - self.margin_px = margin_px self.tape_size_mm = tape_size_mm self.device = None @@ -272,6 +271,13 @@ def _functions(self): tape_size_mm=self.tape_size_mm, ) + @classmethod + def get_labeler_margin_px(cls) -> tuple[float, float]: + return ( + mm_to_px(cls.LABELER_HORIZONTAL_MARGIN_MM), + mm_to_px(cls.LABELER_VERTICAL_MARGIN_MM), + ) + def detect(self): try: self.device = detect_device() @@ -315,7 +321,7 @@ def print( try: LOG.debug("Printing label..") - self._functions.print_label(label_matrix, margin_px=self.margin_px) + self._functions.print_label(label_matrix) LOG.debug("Done printing.") usb.util.dispose_resources(self.device.dev) LOG.debug("Cleaned up.") diff --git a/src/dymoprint/lib/render_engines/__init__.py b/src/dymoprint/lib/render_engines/__init__.py index 8056aed..c7daac9 100644 --- a/src/dymoprint/lib/render_engines/__init__.py +++ b/src/dymoprint/lib/render_engines/__init__.py @@ -4,7 +4,10 @@ from dymoprint.lib.render_engines.horizontally_combined import ( HorizontallyCombinedRenderEngine, ) +from dymoprint.lib.render_engines.margins import MarginsMode, MarginsRenderEngine from dymoprint.lib.render_engines.picture import NoPictureFilePath, PictureRenderEngine +from dymoprint.lib.render_engines.print_payload import PrintPayloadRenderEngine +from dymoprint.lib.render_engines.print_preview import PrintPreviewRenderEngine from dymoprint.lib.render_engines.qr import NoContentError, QrRenderEngine from dymoprint.lib.render_engines.render_context import RenderContext from dymoprint.lib.render_engines.render_engine import RenderEngine @@ -16,9 +19,13 @@ BarcodeWithTextRenderEngine, EmptyRenderEngine, HorizontallyCombinedRenderEngine, + MarginsMode, + MarginsRenderEngine, NoContentError, NoPictureFilePath, PictureRenderEngine, + PrintPayloadRenderEngine, + PrintPreviewRenderEngine, QrRenderEngine, RenderContext, RenderEngine, diff --git a/src/dymoprint/lib/render_engines/horizontally_combined.py b/src/dymoprint/lib/render_engines/horizontally_combined.py index c6919e8..931b98b 100644 --- a/src/dymoprint/lib/render_engines/horizontally_combined.py +++ b/src/dymoprint/lib/render_engines/horizontally_combined.py @@ -7,27 +7,15 @@ from dymoprint.lib.render_engines.render_engine import RenderEngine -class BitmapTooBigError(ValueError): - def __init__(self, width_px, max_width_px): - msg = f"width_px: {width_px}, max_width_px: {max_width_px}" - super().__init__(msg) - - class HorizontallyCombinedRenderEngine(RenderEngine): PADDING = 4 def __init__( self, render_engines: list[RenderEngine], - min_payload_len_px: int = 0, - max_payload_len_px: int | None = None, - justify: str = "center", ): super().__init__() self.render_engines = render_engines - self.min_payload_len_px = min_payload_len_px - self.max_payload_len_px = max_payload_len_px - self.justify = justify def render(self, context: RenderContext) -> Image.Image: render_engines = self.render_engines or [EmptyRenderEngine()] @@ -50,28 +38,4 @@ def render(self, context: RenderContext) -> Image.Image: merged_bitmap.paste(bitmap, box=(x_offset, y_offset)) x_offset += bitmap.width + self.PADDING - if ( - self.max_payload_len_px is not None - and merged_bitmap.width > self.max_payload_len_px - ): - raise BitmapTooBigError(merged_bitmap.width, self.max_payload_len_px) - - if self.min_payload_len_px > merged_bitmap.width: - offset = 0 - if self.justify == "center": - offset = max( - 0, int((self.min_payload_len_px - merged_bitmap.width) / 2) - ) - if self.justify == "right": - offset = max(0, int(self.min_payload_len_px - merged_bitmap.width)) - expanded_merged_bitmap = Image.new( - "1", - ( - self.min_payload_len_px, - merged_bitmap.height, - ), - ) - expanded_merged_bitmap.paste(merged_bitmap, box=(offset, 0)) - return expanded_merged_bitmap - return merged_bitmap diff --git a/src/dymoprint/lib/render_engines/margins.py b/src/dymoprint/lib/render_engines/margins.py new file mode 100644 index 0000000..8c8c542 --- /dev/null +++ b/src/dymoprint/lib/render_engines/margins.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from enum import Enum + +from PIL import Image + +from dymoprint.lib.render_engines.render_context import RenderContext +from dymoprint.lib.render_engines.render_engine import RenderEngine + + +class BitmapTooBigError(ValueError): + def __init__(self, width_px, max_width_px): + msg = f"width_px: {width_px}, max_width_px: {max_width_px}" + super().__init__(msg) + + +class MarginsMode(Enum): + PRINT = 1 + PREVIEW = 2 + + +class MarginsRenderEngine(RenderEngine): + def __init__( + self, + render_engine: RenderEngine, + mode: MarginsMode, + justify: str = "center", + visible_horizontal_margin_px: float = 0, + labeler_margin_px: tuple[float, float] = (0, 0), + max_width_px: float | None = None, + min_width_px: float = 0, + ): + super().__init__() + labeler_horizontal_margin_px, labeler_vertical_margin_px = labeler_margin_px + assert visible_horizontal_margin_px >= 0 + assert labeler_horizontal_margin_px >= 0 + assert labeler_vertical_margin_px >= 0 + assert not max_width_px or max_width_px >= 0 + assert min_width_px >= 0 + self.mode = mode + self.justify = justify + self.visible_horizontal_margin_px = visible_horizontal_margin_px + self.labeler_horizontal_margin_px = labeler_horizontal_margin_px + self.labeler_vertical_margin_px = labeler_vertical_margin_px + self.max_width_px = max_width_px + self.min_width_px = min_width_px + self.render_engine = render_engine + + def calculate_visible_width(self, payload_width_px: int) -> float: + minimal_label_width_px = ( + payload_width_px + self.visible_horizontal_margin_px * 2 + ) + if self.max_width_px is not None and minimal_label_width_px > self.max_width_px: + raise BitmapTooBigError(minimal_label_width_px, self.max_width_px) + + if self.min_width_px > minimal_label_width_px: + label_width_px = self.min_width_px + else: + label_width_px = minimal_label_width_px + return label_width_px + + def render(self, context: RenderContext) -> Image.Image: + payload_bitmap = self.render_engine.render(context) + payload_width_px = payload_bitmap.width + label_width_px = self.calculate_visible_width(payload_width_px) + padding_px = label_width_px - payload_width_px # sum of margins from both sides + + if self.justify == "left": + horizontal_offset_px = self.visible_horizontal_margin_px + elif self.justify == "center": + horizontal_offset_px = padding_px / 2 + elif self.justify == "right": + horizontal_offset_px = padding_px - self.visible_horizontal_margin_px + assert horizontal_offset_px >= self.visible_horizontal_margin_px + + # In print mode: + # ============== + # There is a gap between the printer head and the cutter (for the sake of this + # example, let us say it is DX pixels wide). + # We assume the printing starts when the print head is in offset DX from the + # label's edge (just under the cutter). + # After we print the payload, we need to offset the label DX pixels, in order + # to move the edge of the printed payload past the cutter, othewise the cutter + # will cut inside the printed payload. + # Afterwards, we need to offset another DX pixels, so that the cut will have + # some margin from the payload edge. The reason we move DX pixels this time, is + # in order to have simmetry with the initial margin between label edge and start + # of printed payload. + # + # There's also some vertical margin between printed area and the label edge + + vertial_offset_px: float = 0 + if self.mode == MarginsMode.PRINT: + # print head is already in offset from label's edge under the cutter + horizontal_offset_px -= self.labeler_horizontal_margin_px + # no need to add vertical margins to bitmap + bitmap_height = payload_bitmap.height + elif self.mode == MarginsMode.PREVIEW: + # add vertical margins to bitmap + bitmap_height = payload_bitmap.height + self.labeler_vertical_margin_px * 2 + vertial_offset_px = self.labeler_vertical_margin_px + + bitmap = Image.new("1", (label_width_px, bitmap_height)) + bitmap.paste( + payload_bitmap, box=(round(horizontal_offset_px), round(vertial_offset_px)) + ) + return bitmap diff --git a/src/dymoprint/lib/render_engines/print_payload.py b/src/dymoprint/lib/render_engines/print_payload.py new file mode 100644 index 0000000..131b6cc --- /dev/null +++ b/src/dymoprint/lib/render_engines/print_payload.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from PIL import Image + +from dymoprint.lib.render_engines.margins import MarginsMode, MarginsRenderEngine +from dymoprint.lib.render_engines.render_context import RenderContext +from dymoprint.lib.render_engines.render_engine import RenderEngine + + +class PrintPayloadRenderEngine(RenderEngine): + def __init__( + self, + render_engine: RenderEngine, + justify: str = "center", + visible_horizontal_margin_px: float = 0, + labeler_margin_px: tuple[float, float] = (0, 0), + max_width_px: float | None = None, + min_width_px: float = 0, + ): + super().__init__() + self.render_engine = MarginsRenderEngine( + render_engine=render_engine, + mode=MarginsMode.PRINT, + justify=justify, + visible_horizontal_margin_px=visible_horizontal_margin_px, + labeler_margin_px=labeler_margin_px, + max_width_px=max_width_px, + min_width_px=min_width_px, + ) + + def render(self, context: RenderContext) -> Image.Image: + return self.render_engine.render(context) diff --git a/src/dymoprint/lib/render_engines/print_preview.py b/src/dymoprint/lib/render_engines/print_preview.py new file mode 100644 index 0000000..55d911e --- /dev/null +++ b/src/dymoprint/lib/render_engines/print_preview.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from PIL import Image + +from dymoprint.lib.render_engines.margins import MarginsMode, MarginsRenderEngine +from dymoprint.lib.render_engines.render_context import RenderContext +from dymoprint.lib.render_engines.render_engine import RenderEngine + + +class PrintPreviewRenderEngine(RenderEngine): + def __init__( + self, + render_engine: RenderEngine, + justify: str = "center", + visible_horizontal_margin_px: float = 0, + labeler_margin_px: tuple[float, float] = (0, 0), + max_width_px: float | None = None, + min_width_px: float = 0, + ): + super().__init__() + self.render_engine = MarginsRenderEngine( + render_engine=render_engine, + mode=MarginsMode.PREVIEW, + justify=justify, + visible_horizontal_margin_px=visible_horizontal_margin_px, + labeler_margin_px=labeler_margin_px, + max_width_px=max_width_px, + min_width_px=min_width_px, + ) + + def render(self, context: RenderContext) -> Image.Image: + return self.render_engine.render(context) diff --git a/src/dymoprint/lib/utils.py b/src/dymoprint/lib/utils.py index 984cac9..5eaca94 100755 --- a/src/dymoprint/lib/utils.py +++ b/src/dymoprint/lib/utils.py @@ -33,12 +33,16 @@ def draw_image(bitmap): del drawobj -def px_to_mm(px): +def px_to_mm(px) -> float: mm = px / PIXELS_PER_MM # Round up to nearest 0.1mm return math.ceil(mm * 10) / 10 +def mm_to_px(mm) -> float: + return math.ceil(mm * PIXELS_PER_MM) + + @contextlib.contextmanager def system_run(): try: