From 9884e0f54f304cc9a019f4ef82c5a996c9e6afa1 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Wed, 31 Jan 2024 18:53:37 +0000 Subject: [PATCH 01/36] Fix wrong bitmap was shown in preview Due to a bug, we previewed the original bitmap, before the addition of margins. --- src/dymoprint/cli/cli.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index 2efd514..d442c28 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -305,27 +305,27 @@ def run(): tape_size_mm=args.tape_size_mm, ) render_context = RenderContext(height_px=dymo_labeler.height_px) - label_bitmap = render.render(render_context) + bitmap = render.render(render_context) # print or show the label if args.preview or args.preview_inverted or args.imagemagick or args.browser: LOG.debug("Demo mode: showing label..") # fix size, adding print borders - label_image = Image.new( - "1", (margin + label_bitmap.width + margin, label_bitmap.height) + expanded_bitmap = Image.new( + "1", (margin + bitmap.width + margin, bitmap.height) ) - label_image.paste(label_bitmap, (margin, 0)) + expanded_bitmap.paste(bitmap, (margin, 0)) if args.preview or args.preview_inverted: - label_rotated = label_bitmap.transpose(Image.ROTATE_270) + label_rotated = expanded_bitmap.transpose(Image.ROTATE_270) print(image_to_unicode(label_rotated, invert=args.preview_inverted)) if args.imagemagick: - ImageOps.invert(label_image).show() + ImageOps.invert(expanded_bitmap).show() if args.browser: with NamedTemporaryFile(suffix=".png", delete=False) as fp: - ImageOps.invert(label_image).save(fp) + ImageOps.invert(expanded_bitmap).save(fp) webbrowser.open(f"file://{fp.name}") else: - dymo_labeler.print(label_bitmap) + dymo_labeler.print(bitmap) def main(): From ee8d29178e1c600ae8986b26831f4ce2d9066ff5 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Wed, 31 Jan 2024 19:13:30 +0000 Subject: [PATCH 02/36] Show actual vertical margins in preview mode --- src/dymoprint/cli/cli.py | 6 ++++-- src/dymoprint/lib/constants.py | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index d442c28..b3e6253 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -18,6 +18,7 @@ DEFAULT_MARGIN_PX, PIXELS_PER_MM, USE_QR, + VERTICAL_PREVIEW_MARGIN_PX, e_qrcode, ) from dymoprint.lib.dymo_labeler import DymoLabeler @@ -312,9 +313,10 @@ def run(): LOG.debug("Demo mode: showing label..") # fix size, adding print borders expanded_bitmap = Image.new( - "1", (margin + bitmap.width + margin, bitmap.height) + "1", + (bitmap.width + margin * 2, bitmap.height + VERTICAL_PREVIEW_MARGIN_PX * 2), ) - expanded_bitmap.paste(bitmap, (margin, 0)) + expanded_bitmap.paste(bitmap, (margin, VERTICAL_PREVIEW_MARGIN_PX)) if args.preview or args.preview_inverted: label_rotated = expanded_bitmap.transpose(Image.ROTATE_270) print(image_to_unicode(label_rotated, invert=args.preview_inverted)) diff --git a/src/dymoprint/lib/constants.py b/src/dymoprint/lib/constants.py index aa68de9..7687451 100755 --- a/src/dymoprint/lib/constants.py +++ b/src/dymoprint/lib/constants.py @@ -64,6 +64,7 @@ FONT_SIZERATIO = 7 / 8 DEFAULT_MARGIN_PX = 56 +VERTICAL_PREVIEW_MARGIN_PX = 13 DPI = 180 MM_PER_INCH = 25.4 From 16ec16b3e520a140aa87a4e772165c9bb33cfe76 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Wed, 31 Jan 2024 19:24:58 +0000 Subject: [PATCH 03/36] Coding: rename margins variable --- src/dymoprint/cli/cli.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index b3e6253..ea00227 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -286,10 +286,10 @@ def run(): min_label_mm_len = args.min_length max_label_mm_len = args.max_length - margin = args.margin_px - min_payload_len_px = mm_to_payload_px(min_label_mm_len, margin) + margin_px = args.margin_px + min_payload_len_px = mm_to_payload_px(min_label_mm_len, margin_px) max_payload_len_px = ( - mm_to_payload_px(max_label_mm_len, margin) + mm_to_payload_px(max_label_mm_len, margin_px) if max_label_mm_len is not None else None ) @@ -302,7 +302,7 @@ def run(): ) dymo_labeler = DymoLabeler( - margin_px=args.margin_px, + margin_px=margin_px, tape_size_mm=args.tape_size_mm, ) render_context = RenderContext(height_px=dymo_labeler.height_px) @@ -314,9 +314,12 @@ def run(): # fix size, adding print borders expanded_bitmap = Image.new( "1", - (bitmap.width + margin * 2, bitmap.height + VERTICAL_PREVIEW_MARGIN_PX * 2), + ( + bitmap.width + margin_px * 2, + bitmap.height + VERTICAL_PREVIEW_MARGIN_PX * 2, + ), ) - expanded_bitmap.paste(bitmap, (margin, VERTICAL_PREVIEW_MARGIN_PX)) + 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) print(image_to_unicode(label_rotated, invert=args.preview_inverted)) From 96390da1e43eaf47ce6a7adc5e5f2ba3397c8f7d Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sat, 3 Feb 2024 13:05:33 +0000 Subject: [PATCH 04/36] gui_dev.sh: add verbosity Since this script is for development purposes, most likely is that we would like to have verbosity enabled. --- scripts/gui_dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gui_dev.sh b/scripts/gui_dev.sh index 8725d1a..f0c0857 100755 --- a/scripts/gui_dev.sh +++ b/scripts/gui_dev.sh @@ -2,6 +2,6 @@ while true; do VERBOSE=$VERBOSE \ - dymoprint_gui; + dymoprint_gui -v; sleep 1 done From 1dd19bea39ab04a7e363ba21cfc3737af3885307 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sat, 3 Feb 2024 13:26:31 +0000 Subject: [PATCH 05/36] Helper render_enginers property Makes the code a bit more readable --- src/dymoprint/gui/q_dymo_labels_list.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/dymoprint/gui/q_dymo_labels_list.py b/src/dymoprint/gui/q_dymo_labels_list.py index b7cfe73..51b9582 100644 --- a/src/dymoprint/gui/q_dymo_labels_list.py +++ b/src/dymoprint/gui/q_dymo_labels_list.py @@ -99,17 +99,21 @@ def update_params( item_widget.render_context = render_context self.render_label() - def render_label(self): - """Render the label using the current render context and emit renderSignal.""" - render_engines = [] + @property + def render_engines(self): + 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()) - render_engines.append(item_widget.render_engine) + engines.append(item_widget.render_engine) + return engines + + def render_label(self): + """Render the label using the current render context and emit renderSignal.""" render_engine = HorizontallyCombinedRenderEngine( - render_engines=render_engines, + render_engines=self.render_engines, min_payload_len_px=self.min_payload_len_px, max_payload_len_px=None, justify=self.justify, From 157a2049498d1dfd7236d19ec642d347d684bf8d Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sat, 3 Feb 2024 13:31:58 +0000 Subject: [PATCH 06/36] 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 | 40 +++---- src/dymoprint/gui/gui.py | 83 +++++++------ src/dymoprint/gui/q_dymo_labels_list.py | 109 ++++++++++++----- 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 | 112 ++++++++++++++++++ .../lib/render_engines/print_payload.py | 34 ++++++ .../lib/render_engines/print_preview.py | 35 ++++++ src/dymoprint/lib/unicode_blocks.py | 16 +-- src/dymoprint/lib/utils.py | 6 +- 11 files changed, 358 insertions(+), 148 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..c97e6dd 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,37 @@ 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) + inverted = ImageOps.invert(bitmap.convert("RGB")) + ImageOps.invert(inverted).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..96ee0df 100644 --- a/src/dymoprint/gui/gui.py +++ b/src/dymoprint/gui/gui.py @@ -2,7 +2,7 @@ import sys from typing import Optional -from PIL import Image, ImageOps, ImageQt +from PIL import Image, ImageQt from PyQt6 import QtCore from PyQt6.QtCore import QCommandLineOption, QCommandLineParser, QSize, Qt, QTimer from PyQt6.QtGui import QColor, QIcon, QPainter, QPixmap @@ -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] + label_bitmap_to_print: Optional[Image.Image] dymo_labeler: DymoLabeler def __init__(self): super().__init__() - self.label_bitmap = None + self.label_bitmap_to_print = 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( @@ -113,26 +114,27 @@ def init_timers(self): self.status_time.start(2000) 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,22 @@ 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, preview_bitmap): + qim = ImageQt.ImageQt(preview_bitmap) q_image = QPixmap.fromImage(qim) mask = q_image.createMaskFromColor( @@ -210,15 +206,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(preview_bitmap.size[0])} mm →") + + def update_print_render(self, label_bitmap_to_print): + self.label_bitmap_to_print = label_bitmap_to_print def print_label(self): try: - if self.label_bitmap is None: + if self.label_bitmap_to_print 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.label_bitmap_to_print) 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..252a0d8 100644 --- a/src/dymoprint/gui/q_dymo_labels_list.py +++ b/src/dymoprint/gui/q_dymo_labels_list.py @@ -14,7 +14,17 @@ QrDymoLabelWidget, TextDymoLabelWidget, ) -from dymoprint.lib.render_engines import HorizontallyCombinedRenderEngine, RenderContext +from dymoprint.lib.dymo_labeler import DymoLabeler +from dymoprint.lib.render_engines import ( + HorizontallyCombinedRenderEngine, + PrintPayloadRenderEngine, + PrintPreviewRenderEngine, + RenderContext, +) +from dymoprint.lib.render_engines.render_engine import ( + RenderEngineException, +) +from dymoprint.lib.utils import mm_to_px LOG = logging.getLogger(__name__) @@ -30,7 +40,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 +54,34 @@ 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" + ) + renderPrintPayloadSignal = 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) @@ -73,25 +100,30 @@ def dropEvent(self, e) -> None: Args: ---- e (QDropEvent): The drop event. + """ super().dropEvent(e) self.render_label() 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 +132,53 @@ 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): - """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, + 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: - label_bitmap = render_engine.render(self.render_context) - except BaseException as err: # noqa: BLE001 + bitmap = render_engine.render(self.render_context) + except RenderEngineException as err: crash_msg_box(self, "Render Engine Failed!", err) - label_bitmap = EmptyRenderEngine().render(self.render_context) + bitmap = EmptyRenderEngine().render(self.render_context) - self.renderSignal.emit(label_bitmap) + 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 RenderEngineException as err: + 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. @@ -132,6 +186,7 @@ def contextMenuEvent(self, event): Args: ---- event (QContextMenuEvent): The context menu event. + """ contextMenu = QMenu(self) add_text: Optional[QAction] = contextMenu.addAction("Add Text") 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..dcc25fb --- /dev/null +++ b/src/dymoprint/lib/render_engines/margins.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import math +from enum import Enum +from typing import Literal + +from PIL import Image + +from dymoprint.lib.render_engines.render_context import RenderContext +from dymoprint.lib.render_engines.render_engine import ( + RenderEngine, + RenderEngineException, +) + + +class BitmapTooBigError(RenderEngineException): + 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: Literal["left", "center", "right"] = "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 + + vertical_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 + vertical_offset_px = self.labeler_vertical_margin_px + + bitmap = Image.new("1", (math.ceil(label_width_px), math.ceil(bitmap_height))) + bitmap.paste( + payload_bitmap, box=(round(horizontal_offset_px), round(vertical_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..c7ab8cc --- /dev/null +++ b/src/dymoprint/lib/render_engines/print_payload.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Literal + +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: Literal["left", "center", "right"] = "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..44552ea --- /dev/null +++ b/src/dymoprint/lib/render_engines/print_preview.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Literal + +from PIL import Image, ImageOps + +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: Literal["left", "center", "right"] = "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: + label_bitmap = self.render_engine.render(context) + return ImageOps.invert(label_bitmap.convert("L")) diff --git a/src/dymoprint/lib/unicode_blocks.py b/src/dymoprint/lib/unicode_blocks.py index bb822f1..788bff5 100644 --- a/src/dymoprint/lib/unicode_blocks.py +++ b/src/dymoprint/lib/unicode_blocks.py @@ -13,17 +13,17 @@ dict_unicode = { - (0, 0): FB, - (1, 0): LH, - (0, 1): UH, - (1, 1): NB, + (255, 255): FB, + (0, 255): LH, + (255, 0): UH, + (0, 0): NB, } dict_unicode_inverted = { - (0, 0): NB, - (1, 0): UH, - (0, 1): LH, - (1, 1): FB, + (255, 255): NB, + (0, 255): UH, + (255, 0): LH, + (0, 0): FB, } diff --git a/src/dymoprint/lib/utils.py b/src/dymoprint/lib/utils.py index 984cac9..69f0f00 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 mm * PIXELS_PER_MM + + @contextlib.contextmanager def system_run(): try: From 36d64dc9ecb7cfe855b8c467214c21933fe2b710 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 4 Feb 2024 11:54:32 +0000 Subject: [PATCH 07/36] Refactor DymoLabeler classes Fix a bug in which we had a constant for vertical margin, which is not true, since for every label width the margin is different. Remove labeler state from DymoLabelerFunctions (it no longer initialized with current tape size). Store default tape sizes in the labeler class, instead of in the GUI's code. --- src/dymoprint/cli/cli.py | 5 ++- src/dymoprint/gui/gui.py | 19 +++++----- src/dymoprint/gui/q_dymo_labels_list.py | 9 +++-- src/dymoprint/lib/dymo_labeler.py | 48 +++++++++++++------------ 4 files changed, 46 insertions(+), 35 deletions(-) diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index c97e6dd..c75d2fa 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -295,17 +295,16 @@ def run(): else None ) + dymo_labeler = DymoLabeler(tape_size_mm=args.tape_size_mm) 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(), + labeler_margin_px=dymo_labeler.labeler_margin_px, max_width_px=max_payload_len_px, min_width_px=min_payload_len_px, ) - - dymo_labeler = DymoLabeler(tape_size_mm=args.tape_size_mm) render_context = RenderContext(height_px=dymo_labeler.height_px) # print or show the label diff --git a/src/dymoprint/gui/gui.py b/src/dymoprint/gui/gui.py index 96ee0df..5241266 100644 --- a/src/dymoprint/gui/gui.py +++ b/src/dymoprint/gui/gui.py @@ -36,9 +36,6 @@ class DymoPrintWindow(QWidget): - SUPPORTED_TAPE_SIZE_MM = (19, 12, 9, 6) - DEFAULT_TAPE_SIZE_MM_INDEX = 1 - label_bitmap_to_print: Optional[Image.Image] dymo_labeler: DymoLabeler @@ -84,13 +81,19 @@ def init_elements(self): shadow.setBlurRadius(15) self.label_render.setGraphicsEffect(shadow) - h_margins_mm = round(DymoLabeler.LABELER_HORIZONTAL_MARGIN_MM) + self.dymo_labeler = DymoLabeler() + for tape_size_mm in self.dymo_labeler.SUPPORTED_TAPE_SIZES_MM: + self.tape_size_mm.addItem(str(tape_size_mm), tape_size_mm) + tape_size_index = self.dymo_labeler.SUPPORTED_TAPE_SIZES_MM.index( + self.dymo_labeler.tape_size_mm + ) + self.tape_size_mm.setCurrentIndex(tape_size_index) + + h_margins_mm = round(self.dymo_labeler.minimum_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_width_mm.setMinimum(h_margins_mm * 2) self.min_label_width_mm.setMaximum(300) self.justify.addItems(["center", "left", "right"]) @@ -102,7 +105,6 @@ def init_elements(self): ["white", "black", "yellow", "blue", "red", "green"] ) - self.dymo_labeler = DymoLabeler() self.update_params() self.label_list.populate() @@ -185,6 +187,7 @@ def update_params(self): self.render_context.height_px = self.dymo_labeler.height_px self.label_list.update_params( + dymo_labeler=self.dymo_labeler, h_margin_mm=horizontal_margin_mm, min_label_width_mm=min_label_width_mm, render_context=self.render_context, diff --git a/src/dymoprint/gui/q_dymo_labels_list.py b/src/dymoprint/gui/q_dymo_labels_list.py index 252a0d8..1c564a1 100644 --- a/src/dymoprint/gui/q_dymo_labels_list.py +++ b/src/dymoprint/gui/q_dymo_labels_list.py @@ -73,12 +73,14 @@ class QDymoLabelList(QListWidget): ) render_context: Optional[RenderContext] itemWidget: TextDymoLabelWidget + dymo_labeler: DymoLabeler h_margin_mm: float min_label_width_mm: Optional[float] justify: str def __init__(self, parent=None): super().__init__(parent) + self.dymo_labeler = None self.margin_px = None self.min_label_width_mm = None self.justify = "center" @@ -107,6 +109,7 @@ def dropEvent(self, e) -> None: def update_params( self, + dymo_labeler: DymoLabeler, h_margin_mm: float, min_label_width_mm: float, render_context: RenderContext, @@ -116,12 +119,14 @@ def update_params( Args: ---- + dymo_labeler: an instance of DymoLabeler object 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.dymo_labeler = dymo_labeler self.h_margin_mm = h_margin_mm self.min_label_width_mm = min_label_width_mm self.justify = justify @@ -147,7 +152,7 @@ def render_preview(self): 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(), + labeler_margin_px=self.dymo_labeler.labeler_margin_px, max_width_px=None, min_width_px=mm_to_px(self.min_label_width_mm), ) @@ -164,7 +169,7 @@ def render_print(self): 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(), + labeler_margin_px=self.dymo_labeler.labeler_margin_px, max_width_px=None, min_width_px=mm_to_px(self.min_label_width_mm), ) diff --git a/src/dymoprint/lib/dymo_labeler.py b/src/dymoprint/lib/dymo_labeler.py index ffff8c1..05f4dc9 100755 --- a/src/dymoprint/lib/dymo_labeler.py +++ b/src/dymoprint/lib/dymo_labeler.py @@ -20,7 +20,6 @@ from dymoprint.lib.utils import mm_to_px LOG = logging.getLogger(__name__) -DEFAULT_TAPE_SIZE_MM = 12 POSSIBLE_USB_ERRORS = (DymoUSBError, NoBackendError, USBError) @@ -51,8 +50,6 @@ class DymoLabelerFunctions: """ - tape_size_mm: int - # Max number of print lines to send before waiting for a response. This helps # to avoid timeouts due to differences between data transfer and # printer speeds. I added this because I kept getting "IOError: [Errno @@ -69,10 +66,8 @@ def __init__( devout: usb.core.Endpoint, devin: usb.core.Endpoint, synwait: int | None = None, - tape_size_mm: int | None = None, ): """Initialize the LabelManager object (HLF).""" - self._tape_size_mm = tape_size_mm self._cmd: list[int] = [] self._response = False self._bytesPerLine = None @@ -83,13 +78,11 @@ def __init__( self._synwait = synwait @classmethod - def _max_bytes_per_line(cls, tape_size_mm: int | None = None) -> int: - if not tape_size_mm: - tape_size_mm = DEFAULT_TAPE_SIZE_MM + def _max_bytes_per_line(cls, tape_size_mm: int) -> int: return int(8 * tape_size_mm / 12) @classmethod - def height_px(cls, tape_size_mm: int | None = None): + def height_px(cls, tape_size_mm: int): return cls._max_bytes_per_line(tape_size_mm) * 8 def _send_command(self): @@ -155,9 +148,9 @@ def _status_request(self): self._build_command(cmd) self._response = True - def _dot_tab(self, value): + def _dot_tab(self, value, tape_size_mm: int): """Set the bias text height, in bytes (MLF).""" - if value < 0 or value > self._max_bytes_per_line(self._tape_size_mm): + if value < 0 or value > self._max_bytes_per_line(tape_size_mm): raise ValueError cmd = [ESC, ord("B"), value] self._build_command(cmd) @@ -190,11 +183,11 @@ def _line(self, value): cmd = [SYN, *value] self._build_command(cmd) - def _chain_mark(self): + def _chain_mark(self, tape_size_mm: int): """Set Chain Mark (MLF).""" - self._dot_tab(0) - self._bytes_per_line(self._max_bytes_per_line(self._tape_size_mm)) - self._line([0x99] * self._max_bytes_per_line(self._tape_size_mm)) + self._dot_tab(0, tape_size_mm) + self._bytes_per_line(self._max_bytes_per_line(tape_size_mm)) + self._line([0x99] * self._max_bytes_per_line(tape_size_mm)) def _skip_lines(self, value): """Set number of lines of white to print (MLF).""" @@ -245,13 +238,20 @@ class DymoLabeler: device: DetectedDevice tape_size_mm: int - LABELER_HORIZONTAL_MARGIN_MM = 8.1 - LABELER_VERTICAL_MARGIN_MM = 1.9 + LABELER_DISTANCE_BETWEEN_PRINT_HEAD_AND_CUTTER_MM = 8.1 + LABELER_PRINT_HEAD_HEIGHT_MM = 8.2 + SUPPORTED_TAPE_SIZES_MM = (19, 12, 9, 6) + DEFAULT_TAPE_SIZE_MM = 12 def __init__( self, tape_size_mm: int = DEFAULT_TAPE_SIZE_MM, ): + if tape_size_mm not in self.SUPPORTED_TAPE_SIZES_MM: + raise ValueError( + f"Unsupported tape size {tape_size_mm}mm. " + f"Supported sizes: {self.SUPPORTED_TAPE_SIZES_MM}" + ) self.tape_size_mm = tape_size_mm self.device = None @@ -268,14 +268,18 @@ def _functions(self): devout=self.device.devout, devin=self.device.devin, synwait=64, - tape_size_mm=self.tape_size_mm, ) - @classmethod - def get_labeler_margin_px(cls) -> tuple[float, float]: + @property + def minimum_horizontal_margin_mm(self): + return self.LABELER_DISTANCE_BETWEEN_PRINT_HEAD_AND_CUTTER_MM + + @property + def labeler_margin_px(self) -> tuple[float, float]: + vertical_margin_mm = (self.tape_size_mm - self.LABELER_PRINT_HEAD_HEIGHT_MM) / 2 return ( - mm_to_px(cls.LABELER_HORIZONTAL_MARGIN_MM), - mm_to_px(cls.LABELER_VERTICAL_MARGIN_MM), + mm_to_px(self.minimum_horizontal_margin_mm), + mm_to_px(vertical_margin_mm), ) def detect(self): From cf8efef22064047c63ba7a9fc3688393da75e8af Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Tue, 6 Feb 2024 21:10:16 +0000 Subject: [PATCH 08/36] Move preview rendering to preview render engine Discard the QT image functionality in favor of Pillow's --- pyproject.toml | 1 + src/dymoprint/cli/cli.py | 6 ++++- src/dymoprint/gui/gui.py | 20 ++++++--------- .../lib/render_engines/print_preview.py | 23 ++++++++++++++--- .../lib/render_engines/render_context.py | 4 ++- src/dymoprint/lib/unicode_blocks.py | 25 +++++++++++++------ 6 files changed, 53 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b21701a..4d4ae19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ commands = dymoprint --version dymoprint --help dymoprint --preview "single line" + dymoprint --preview-inverted "single line" dymoprint --preview multiple lines dymoprint --preview -qr "qr text" dymoprint --preview -c code128 "bc txt" diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index c75d2fa..4d13e79 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -305,7 +305,11 @@ def run(): max_width_px=max_payload_len_px, min_width_px=min_payload_len_px, ) - render_context = RenderContext(height_px=dymo_labeler.height_px) + render_context = RenderContext( + background_color="white", + foreground_color="black", + height_px=dymo_labeler.height_px, + ) # print or show the label if args.preview or args.preview_inverted or args.imagemagick or args.browser: diff --git a/src/dymoprint/gui/gui.py b/src/dymoprint/gui/gui.py index 5241266..14660d1 100644 --- a/src/dymoprint/gui/gui.py +++ b/src/dymoprint/gui/gui.py @@ -5,7 +5,7 @@ from PIL import Image, ImageQt from PyQt6 import QtCore from PyQt6.QtCore import QCommandLineOption, QCommandLineParser, QSize, Qt, QTimer -from PyQt6.QtGui import QColor, QIcon, QPainter, QPixmap +from PyQt6.QtGui import QIcon, QPixmap from PyQt6.QtWidgets import ( QApplication, QComboBox, @@ -121,8 +121,8 @@ def init_connections(self): self.tape_size_mm.currentTextChanged.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.foreground_color.currentTextChanged.connect(self.update_params) + self.background_color.currentTextChanged.connect(self.update_params) 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) @@ -184,6 +184,10 @@ def update_params(self): tape_size_mm: float = self.tape_size_mm.currentData() self.dymo_labeler.tape_size_mm = tape_size_mm + + # Update render context + self.render_context.foreground_color = self.foreground_color.currentText() + self.render_context.background_color = self.background_color.currentText() self.render_context.height_px = self.dymo_labeler.height_px self.label_list.update_params( @@ -197,16 +201,6 @@ def update_params(self): def update_preview_render(self, preview_bitmap): qim = ImageQt.ImageQt(preview_bitmap) q_image = QPixmap.fromImage(qim) - - mask = q_image.createMaskFromColor( - QColor("255, 255, 255"), Qt.MaskMode.MaskOutColor - ) - q_image.fill(QColor(self.background_color.currentText())) - p = QPainter(q_image) - p.setPen(QColor(self.foreground_color.currentText())) - p.drawPixmap(q_image.rect(), mask, mask.rect()) - p.end() - self.label_render.setPixmap(q_image) self.label_render.adjustSize() self.info_label.setText(f"← {px_to_mm(preview_bitmap.size[0])} mm →") diff --git a/src/dymoprint/lib/render_engines/print_preview.py b/src/dymoprint/lib/render_engines/print_preview.py index 44552ea..ccbf3d5 100644 --- a/src/dymoprint/lib/render_engines/print_preview.py +++ b/src/dymoprint/lib/render_engines/print_preview.py @@ -2,7 +2,7 @@ from typing import Literal -from PIL import Image, ImageOps +from PIL import Image, ImageColor, ImageOps from dymoprint.lib.render_engines.margins import MarginsMode, MarginsRenderEngine from dymoprint.lib.render_engines.render_context import RenderContext @@ -30,6 +30,23 @@ def __init__( min_width_px=min_width_px, ) - def render(self, context: RenderContext) -> Image.Image: + def _get_preview_bitmap(self, context: RenderContext): label_bitmap = self.render_engine.render(context) - return ImageOps.invert(label_bitmap.convert("L")) + bitmap = ImageOps.invert(label_bitmap.convert("L")).convert("RGBA") + pixel_map = { + "black": context.foreground_color, + "white": context.background_color, + } + pixel_color_map = { + ImageColor.getcolor(k, "RGBA"): ImageColor.getcolor(v, "RGBA") + for k, v in pixel_map.items() + } + pixdata = bitmap.load() + width, height = bitmap.size + for x in range(0, width): + for y in range(0, height): + pixdata[x, y] = pixel_color_map[pixdata[x, y]] + return bitmap + + def render(self, context: RenderContext) -> Image.Image: + return self._get_preview_bitmap(context) diff --git a/src/dymoprint/lib/render_engines/render_context.py b/src/dymoprint/lib/render_engines/render_context.py index f9d94cc..f2bb066 100644 --- a/src/dymoprint/lib/render_engines/render_context.py +++ b/src/dymoprint/lib/render_engines/render_context.py @@ -2,7 +2,9 @@ class _RenderContextFieldName(Enum): - HEIGHT_PX = 1 + BACKGROUND_COLOR = 1 + FOREGROUND_COLOR = 2 + HEIGHT_PX = 3 class RenderContext: diff --git a/src/dymoprint/lib/unicode_blocks.py b/src/dymoprint/lib/unicode_blocks.py index 788bff5..a6d4442 100644 --- a/src/dymoprint/lib/unicode_blocks.py +++ b/src/dymoprint/lib/unicode_blocks.py @@ -11,19 +11,28 @@ assert FB == "\N{FULL BLOCK}" assert NB == "\N{NO-BREAK SPACE}" +BLACK_PIXEL = (255, 255, 255, 255) +WHITE_PIXEL = (0, 0, 0, 255) +# when the image height is odd, the preview image height is extended by 1 pixel +# this padding result in the added line being composed of transparent pixels. +TRANSPARENT_PIXEL = (0, 0, 0, 0) dict_unicode = { - (255, 255): FB, - (0, 255): LH, - (255, 0): UH, - (0, 0): NB, + (BLACK_PIXEL, BLACK_PIXEL): FB, + (WHITE_PIXEL, BLACK_PIXEL): LH, + (BLACK_PIXEL, WHITE_PIXEL): UH, + (WHITE_PIXEL, WHITE_PIXEL): NB, + (BLACK_PIXEL, TRANSPARENT_PIXEL): UH, + (WHITE_PIXEL, TRANSPARENT_PIXEL): NB, } dict_unicode_inverted = { - (255, 255): NB, - (0, 255): UH, - (255, 0): LH, - (0, 0): FB, + (BLACK_PIXEL, BLACK_PIXEL): NB, + (WHITE_PIXEL, BLACK_PIXEL): UH, + (BLACK_PIXEL, WHITE_PIXEL): LH, + (WHITE_PIXEL, WHITE_PIXEL): FB, + (BLACK_PIXEL, TRANSPARENT_PIXEL): NB, + (WHITE_PIXEL, TRANSPARENT_PIXEL): UH, } From 04f1bb495fd6fc18aa6cddeccc387358668d1cce Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sat, 10 Feb 2024 07:44:37 +0000 Subject: [PATCH 09/36] Fix vertical margin calculation I only have 12mm tape at my disposal, so I assume labeler height is 8.2 mm, so that the labeler can print on the whole 6mm tape, which means margins are 0mm. This logic might change in the future, when more tape sizes will be available for testing. --- src/dymoprint/lib/dymo_labeler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/dymoprint/lib/dymo_labeler.py b/src/dymoprint/lib/dymo_labeler.py index 05f4dc9..9ea7d6b 100755 --- a/src/dymoprint/lib/dymo_labeler.py +++ b/src/dymoprint/lib/dymo_labeler.py @@ -276,7 +276,10 @@ def minimum_horizontal_margin_mm(self): @property def labeler_margin_px(self) -> tuple[float, float]: - vertical_margin_mm = (self.tape_size_mm - self.LABELER_PRINT_HEAD_HEIGHT_MM) / 2 + vertical_margin_mm = max( + 0, (self.tape_size_mm - self.LABELER_PRINT_HEAD_HEIGHT_MM) / 2 + ) + return ( mm_to_px(self.minimum_horizontal_margin_mm), mm_to_px(vertical_margin_mm), From b94c13f912cfb6cee1a1885c713e8d8278ad3893 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sat, 10 Feb 2024 10:18:26 +0000 Subject: [PATCH 10/36] Show preview margins --- src/dymoprint/cli/cli.py | 3 +- src/dymoprint/gui/gui.py | 7 ++ src/dymoprint/gui/q_dymo_labels_list.py | 2 +- src/dymoprint/lib/render_engines/margins.py | 8 +- .../lib/render_engines/print_payload.py | 2 +- .../lib/render_engines/print_preview.py | 82 +++++++++++++++++-- .../lib/render_engines/render_context.py | 1 + 7 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index 4d13e79..a8f8513 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -309,6 +309,7 @@ def run(): background_color="white", foreground_color="black", height_px=dymo_labeler.height_px, + preview_show_margins=False, ) # print or show the label @@ -328,7 +329,7 @@ def run(): webbrowser.open(f"file://{fp.name}") else: render = PrintPayloadRenderEngine(**render_kwargs) - bitmap = render.render(render_context) + bitmap, _ = render.render(render_context) dymo_labeler.print(bitmap) diff --git a/src/dymoprint/gui/gui.py b/src/dymoprint/gui/gui.py index 14660d1..14874c5 100644 --- a/src/dymoprint/gui/gui.py +++ b/src/dymoprint/gui/gui.py @@ -8,6 +8,7 @@ from PyQt6.QtGui import QIcon, QPixmap from PyQt6.QtWidgets import ( QApplication, + QCheckBox, QComboBox, QGraphicsDropShadowEffect, QHBoxLayout, @@ -58,6 +59,7 @@ def __init__(self): self.min_label_width_mm = QSpinBox() self.justify = QComboBox() self.info_label = QLabel() + self.preview_show_margins = QCheckBox() self.last_error = None self.dymo_labeler = None @@ -104,6 +106,7 @@ def init_elements(self): self.background_color.addItems( ["white", "black", "yellow", "blue", "red", "green"] ) + self.preview_show_margins.setChecked(False) self.update_params() self.label_list.populate() @@ -126,6 +129,7 @@ def init_connections(self): 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) + self.preview_show_margins.stateChanged.connect(self.update_params) def init_layout(self): settings_widget = QToolBar(self) @@ -145,6 +149,8 @@ def init_layout(self): settings_widget.addWidget(self.foreground_color) settings_widget.addWidget(QLabel(" on ")) settings_widget.addWidget(self.background_color) + settings_widget.addWidget(QLabel("Show margins:")) + settings_widget.addWidget(self.preview_show_margins) render_widget = QWidget(self) label_render_widget = QWidget(render_widget) @@ -189,6 +195,7 @@ def update_params(self): self.render_context.foreground_color = self.foreground_color.currentText() self.render_context.background_color = self.background_color.currentText() self.render_context.height_px = self.dymo_labeler.height_px + self.render_context.preview_show_margins = self.preview_show_margins.isChecked() self.label_list.update_params( dymo_labeler=self.dymo_labeler, diff --git a/src/dymoprint/gui/q_dymo_labels_list.py b/src/dymoprint/gui/q_dymo_labels_list.py index 1c564a1..f064ac3 100644 --- a/src/dymoprint/gui/q_dymo_labels_list.py +++ b/src/dymoprint/gui/q_dymo_labels_list.py @@ -174,7 +174,7 @@ def render_print(self): min_width_px=mm_to_px(self.min_label_width_mm), ) try: - bitmap = render_engine.render(self.render_context) + bitmap, _ = render_engine.render(self.render_context) except RenderEngineException as err: crash_msg_box(self, "Render Engine Failed!", err) bitmap = EmptyRenderEngine().render(self.render_context) diff --git a/src/dymoprint/lib/render_engines/margins.py b/src/dymoprint/lib/render_engines/margins.py index dcc25fb..0df211a 100644 --- a/src/dymoprint/lib/render_engines/margins.py +++ b/src/dymoprint/lib/render_engines/margins.py @@ -64,7 +64,7 @@ def calculate_visible_width(self, payload_width_px: int) -> float: label_width_px = minimal_label_width_px return label_width_px - def render(self, context: RenderContext) -> Image.Image: + def render(self, context: RenderContext) -> tuple[Image.Image, dict[str, float]]: payload_bitmap = self.render_engine.render(context) payload_width_px = payload_bitmap.width label_width_px = self.calculate_visible_width(payload_width_px) @@ -109,4 +109,8 @@ def render(self, context: RenderContext) -> Image.Image: bitmap.paste( payload_bitmap, box=(round(horizontal_offset_px), round(vertical_offset_px)) ) - return bitmap + meta = { + "horizontal_offset_px": horizontal_offset_px, + "vertical_offset_px": vertical_offset_px, + } + return bitmap, meta diff --git a/src/dymoprint/lib/render_engines/print_payload.py b/src/dymoprint/lib/render_engines/print_payload.py index c7ab8cc..e93ef70 100644 --- a/src/dymoprint/lib/render_engines/print_payload.py +++ b/src/dymoprint/lib/render_engines/print_payload.py @@ -30,5 +30,5 @@ def __init__( min_width_px=min_width_px, ) - def render(self, context: RenderContext) -> Image.Image: + def render(self, context: RenderContext) -> tuple[Image.Image, dict[str, float]]: 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 index ccbf3d5..9e0f223 100644 --- a/src/dymoprint/lib/render_engines/print_preview.py +++ b/src/dymoprint/lib/render_engines/print_preview.py @@ -2,7 +2,7 @@ from typing import Literal -from PIL import Image, ImageColor, ImageOps +from PIL import Image, ImageColor, ImageDraw, ImageOps from dymoprint.lib.render_engines.margins import MarginsMode, MarginsRenderEngine from dymoprint.lib.render_engines.render_context import RenderContext @@ -10,6 +10,11 @@ class PrintPreviewRenderEngine(RenderEngine): + X_MARGIN_PX = 30 + Y_MARGIN_PX = 30 + DX = X_MARGIN_PX * 0.3 + DY = Y_MARGIN_PX * 0.3 + def __init__( self, render_engine: RenderEngine, @@ -30,9 +35,9 @@ def __init__( min_width_px=min_width_px, ) - def _get_preview_bitmap(self, context: RenderContext): - label_bitmap = self.render_engine.render(context) - bitmap = ImageOps.invert(label_bitmap.convert("L")).convert("RGBA") + def _get_label_bitmap(self, context: RenderContext): + render_bitmap, meta = self.render_engine.render(context) + bitmap = ImageOps.invert(render_bitmap.convert("L")).convert("RGBA") pixel_map = { "black": context.foreground_color, "white": context.background_color, @@ -46,7 +51,72 @@ def _get_preview_bitmap(self, context: RenderContext): for x in range(0, width): for y in range(0, height): pixdata[x, y] = pixel_color_map[pixdata[x, y]] - return bitmap + return bitmap, meta + + def _show_margins(self, label_bitmap, preview_bitmap, meta, context): + draw = ImageDraw.Draw(preview_bitmap) + x_margin = meta["horizontal_offset_px"] + y_margin = meta["vertical_offset_px"] + preview_width, preview_height = preview_bitmap.size + label_width, label_height = label_bitmap.size + margin_color = context.foreground_color + + # left vertical margin + draw.line( + xy=( + self.X_MARGIN_PX + x_margin, + 0, + self.X_MARGIN_PX + x_margin, + preview_height, + ), + fill=margin_color, + ) + # right vertical margin + draw.line( + xy=( + self.X_MARGIN_PX + label_width - x_margin, + 0, + self.X_MARGIN_PX + label_width - x_margin, + preview_height, + ), + fill=margin_color, + ) + # top horizontal margin + ( + draw.line( + xy=( + 0, + self.Y_MARGIN_PX + y_margin, + preview_width, + self.Y_MARGIN_PX + y_margin, + ), + fill=margin_color, + ), + ) + # bottom horizontal margin + ( + draw.line( + xy=( + 0, + self.Y_MARGIN_PX + label_height - y_margin, + preview_width, + self.Y_MARGIN_PX + label_height - y_margin, + ), + fill=margin_color, + ), + ) def render(self, context: RenderContext) -> Image.Image: - return self._get_preview_bitmap(context) + label_bitmap, meta = self._get_label_bitmap(context) + if context.preview_show_margins: + label_width, label_height = label_bitmap.size + preview_width = label_width + self.X_MARGIN_PX * 2 + preview_height = label_height + self.Y_MARGIN_PX * 2 + preview_bitmap = Image.new( + "RGBA", (preview_width, preview_height), color=(255, 0, 0, 0) + ) + preview_bitmap.paste(label_bitmap, box=(self.X_MARGIN_PX, self.Y_MARGIN_PX)) + self._show_margins(label_bitmap, preview_bitmap, meta, context) + return preview_bitmap + else: + return label_bitmap diff --git a/src/dymoprint/lib/render_engines/render_context.py b/src/dymoprint/lib/render_engines/render_context.py index f2bb066..652960f 100644 --- a/src/dymoprint/lib/render_engines/render_context.py +++ b/src/dymoprint/lib/render_engines/render_context.py @@ -5,6 +5,7 @@ class _RenderContextFieldName(Enum): BACKGROUND_COLOR = 1 FOREGROUND_COLOR = 2 HEIGHT_PX = 3 + PREVIEW_SHOW_MARGINS = 4 class RenderContext: From bf3cb39181b303888f0319118ccf320ecc5edf22 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sat, 10 Feb 2024 12:14:25 +0000 Subject: [PATCH 11/36] Show dimensions --- src/dymoprint/gui/gui.py | 7 +- .../lib/render_engines/print_preview.py | 137 ++++++++++++++---- 2 files changed, 110 insertions(+), 34 deletions(-) diff --git a/src/dymoprint/gui/gui.py b/src/dymoprint/gui/gui.py index 14874c5..9882d11 100644 --- a/src/dymoprint/gui/gui.py +++ b/src/dymoprint/gui/gui.py @@ -29,7 +29,7 @@ ) from dymoprint.lib.logger import configure_logging, set_verbose from dymoprint.lib.render_engines import RenderContext -from dymoprint.lib.utils import px_to_mm, system_run +from dymoprint.lib.utils import system_run from .q_dymo_labels_list import QDymoLabelList @@ -58,7 +58,6 @@ def __init__(self): self.background_color = QComboBox() self.min_label_width_mm = QSpinBox() self.justify = QComboBox() - self.info_label = QLabel() self.preview_show_margins = QCheckBox() self.last_error = None self.dymo_labeler = None @@ -162,9 +161,6 @@ def init_layout(self): label_render_layout.addWidget( self.label_render, alignment=QtCore.Qt.AlignmentFlag.AlignCenter ) - label_render_layout.addWidget( - self.info_label, alignment=QtCore.Qt.AlignmentFlag.AlignCenter - ) print_render_layout.addWidget( self.print_button, alignment=QtCore.Qt.AlignmentFlag.AlignRight ) @@ -210,7 +206,6 @@ def update_preview_render(self, preview_bitmap): q_image = QPixmap.fromImage(qim) self.label_render.setPixmap(q_image) self.label_render.adjustSize() - self.info_label.setText(f"← {px_to_mm(preview_bitmap.size[0])} mm →") def update_print_render(self, label_bitmap_to_print): self.label_bitmap_to_print = label_bitmap_to_print diff --git a/src/dymoprint/lib/render_engines/print_preview.py b/src/dymoprint/lib/render_engines/print_preview.py index 9e0f223..4e16e3c 100644 --- a/src/dymoprint/lib/render_engines/print_preview.py +++ b/src/dymoprint/lib/render_engines/print_preview.py @@ -7,13 +7,17 @@ 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 +from dymoprint.lib.utils import px_to_mm class PrintPreviewRenderEngine(RenderEngine): - X_MARGIN_PX = 30 + X_MARGIN_PX = 80 Y_MARGIN_PX = 30 DX = X_MARGIN_PX * 0.3 DY = Y_MARGIN_PX * 0.3 + MARGIN_COLOR = "red" + MARK_COLOR = "yellow" + TEXT_COLOR = "blue" def __init__( self, @@ -59,7 +63,11 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): y_margin = meta["vertical_offset_px"] preview_width, preview_height = preview_bitmap.size label_width, label_height = label_bitmap.size - margin_color = context.foreground_color + + preview_width_mark_y = preview_height - self.Y_MARGIN_PX + self.DY + label_width_mark_y = preview_height - self.DY + preview_width_mark_x = self.X_MARGIN_PX - self.DX + label_width_mark_x = self.DX # left vertical margin draw.line( @@ -67,9 +75,9 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): self.X_MARGIN_PX + x_margin, 0, self.X_MARGIN_PX + x_margin, - preview_height, + preview_width_mark_y, ), - fill=margin_color, + fill=self.MARGIN_COLOR, ) # right vertical margin draw.line( @@ -77,45 +85,118 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): self.X_MARGIN_PX + label_width - x_margin, 0, self.X_MARGIN_PX + label_width - x_margin, - preview_height, + preview_width_mark_y, ), - fill=margin_color, + fill=self.MARGIN_COLOR, ) # top horizontal margin - ( - draw.line( - xy=( - 0, - self.Y_MARGIN_PX + y_margin, - preview_width, - self.Y_MARGIN_PX + y_margin, - ), - fill=margin_color, + draw.line( + xy=( + preview_width_mark_x, + self.DY + y_margin, + preview_width, + self.DY + y_margin, ), + fill=self.MARGIN_COLOR, ) # bottom horizontal margin - ( - draw.line( - xy=( - 0, - self.Y_MARGIN_PX + label_height - y_margin, - preview_width, - self.Y_MARGIN_PX + label_height - y_margin, - ), - fill=margin_color, + draw.line( + xy=( + preview_width_mark_x, + self.DY + label_height - y_margin, + preview_width, + self.DY + label_height - y_margin, + ), + fill=self.MARGIN_COLOR, + ) + # horizontal line for payload width + draw.line( + xy=( + self.X_MARGIN_PX + x_margin, + preview_width_mark_y, + self.X_MARGIN_PX + label_width - x_margin, + preview_width_mark_y, + ), + fill=self.MARK_COLOR, + ) + # horizontal line for label width + draw.line( + xy=( + self.X_MARGIN_PX, + label_width_mark_y, + self.X_MARGIN_PX + label_width, + label_width_mark_y, + ), + fill=self.MARK_COLOR, + ) + # vertical line for payload height + draw.line( + xy=( + preview_width_mark_x, + self.DY + y_margin, + preview_width_mark_x, + self.DY + label_height - y_margin, + ), + fill=self.MARK_COLOR, + ) + # vertical line for label height + draw.line( + xy=( + label_width_mark_x, + self.DY, + label_width_mark_x, + self.DY + label_height, ), + fill=self.MARK_COLOR, ) + labels = [ + # payload width + { + "xy": (self.X_MARGIN_PX + label_width / 2, preview_width_mark_y), + "text": f"{px_to_mm(label_width - x_margin * 2)} mm", + "anchor": "mm", + "align": "center", + }, + # label width + { + "xy": (self.X_MARGIN_PX + label_width / 2, label_width_mark_y), + "text": f"{px_to_mm(label_width)} mm", + "anchor": "mm", + "align": "center", + }, + # payload height + { + "xy": (preview_width_mark_x, self.DY + label_height / 2 - self.DY), + "text": f"{px_to_mm(label_height - y_margin * 2)} mm", + "anchor": "mm", + "align": "center", + }, + # label height + { + "xy": (label_width_mark_x, self.DY + label_height / 2 + self.DY), + "text": f"{px_to_mm(label_height)} mm", + "anchor": "mm", + "align": "center", + }, + ] + for label in labels: + bbox = draw.textbbox(**label) # type: ignore[arg-type] + draw.rectangle(bbox, fill=(0, 0, 0, 0)) + draw.text(**label, fill=self.TEXT_COLOR) # type: ignore[arg-type] + def render(self, context: RenderContext) -> Image.Image: label_bitmap, meta = self._get_label_bitmap(context) if context.preview_show_margins: label_width, label_height = label_bitmap.size - preview_width = label_width + self.X_MARGIN_PX * 2 - preview_height = label_height + self.Y_MARGIN_PX * 2 + preview_width = label_width + self.X_MARGIN_PX + self.DX + preview_height = label_height + self.Y_MARGIN_PX + self.DY preview_bitmap = Image.new( - "RGBA", (preview_width, preview_height), color=(255, 0, 0, 0) + "RGBA", + (round(preview_width), round(preview_height)), + color=(0, 0, 0, 0), ) - preview_bitmap.paste(label_bitmap, box=(self.X_MARGIN_PX, self.Y_MARGIN_PX)) + preview_bitmap.paste(label_bitmap, box=(self.X_MARGIN_PX, round(self.DY))) self._show_margins(label_bitmap, preview_bitmap, meta, context) return preview_bitmap else: From c4ae74e77e9dfa73297eb1cdf5a92b79bdb654aa Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 10 Feb 2024 11:49:00 +0100 Subject: [PATCH 12/36] Configure CLI logging earlier This way we get logging in case there's an error with the context manager. --- src/dymoprint/cli/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index a8f8513..66b2d1f 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -334,6 +334,6 @@ def run(): def main(): + configure_logging() with system_run(): - configure_logging() run() From 5e6d1054ff8476200e31bf6a4ec8bc8cbcbf8655 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 10 Feb 2024 12:13:00 +0100 Subject: [PATCH 13/36] Rework logger to initialize as verbose I find it better to explicitly deactivate verbose logging when not needed. --- src/dymoprint/cli/cli.py | 9 +++++---- src/dymoprint/gui/gui.py | 9 +++++---- src/dymoprint/lib/logger.py | 13 +++++-------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index 66b2d1f..dfa55bc 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -22,7 +22,7 @@ ) from dymoprint.lib.dymo_labeler import DymoLabeler from dymoprint.lib.font_config import FontConfig, FontStyle, NoFontFound -from dymoprint.lib.logger import configure_logging, set_verbose +from dymoprint.lib.logger import configure_logging, is_verbose_env_vars, set_not_verbose from dymoprint.lib.render_engines import ( BarcodeRenderEngine, BarcodeWithTextRenderEngine, @@ -211,6 +211,10 @@ def mm_to_payload_px(mm, margin): def run(): args = parse_args() + if (not args.verbose) and (not is_verbose_env_vars()): + # Neither --verbose flag nor the environment variable is set. + set_not_verbose() + # read config file style = FLAG_TO_STYLE.get(args.style) try: @@ -224,9 +228,6 @@ def run(): labeltext = args.text - if args.verbose: - set_verbose() - # check if barcode, qrcode or text should be printed, use frames only on text if args.qr and not USE_QR: raise CommandLineUsageError( diff --git a/src/dymoprint/gui/gui.py b/src/dymoprint/gui/gui.py index 9882d11..6b83530 100644 --- a/src/dymoprint/gui/gui.py +++ b/src/dymoprint/gui/gui.py @@ -27,7 +27,7 @@ DymoLabelerDetectError, DymoLabelerPrintError, ) -from dymoprint.lib.logger import configure_logging, set_verbose +from dymoprint.lib.logger import configure_logging, is_verbose_env_vars, set_not_verbose from dymoprint.lib.render_engines import RenderContext from dymoprint.lib.utils import system_run @@ -246,13 +246,14 @@ def parse(app): parser.process(app) is_verbose = parser.isSet(verbose_option) - if is_verbose: - set_verbose() + if (not is_verbose) and (not is_verbose_env_vars()): + # Neither the --verbose flag nor the environment variable is set. + set_not_verbose() def main(): + configure_logging() with system_run(): - configure_logging() app = QApplication(sys.argv) parse(app) window = DymoPrintWindow() diff --git a/src/dymoprint/lib/logger.py b/src/dymoprint/lib/logger.py index b3dca4e..e0b2e31 100644 --- a/src/dymoprint/lib/logger.py +++ b/src/dymoprint/lib/logger.py @@ -2,7 +2,7 @@ import os import sys -_IS_VERBOSE = False +_IS_VERBOSE = True LOG = logging.getLogger("dymoprint") VERBOSE_NOTICE = "Run with --verbose for more information" @@ -12,7 +12,7 @@ def _is_env_var_true(env_var: str) -> bool: return val is not None and val.lower() in ("1", "true") -def _is_verbose_env_vars() -> bool: +def is_verbose_env_vars() -> bool: return _is_env_var_true("VERBOSE") @@ -20,20 +20,17 @@ def _update_log_level(): LOG.setLevel(logging.DEBUG if _IS_VERBOSE else logging.INFO) -def set_verbose(): +def set_not_verbose() -> None: global _IS_VERBOSE - _IS_VERBOSE = True + _IS_VERBOSE = False _update_log_level() -def is_verbose(): +def is_verbose() -> bool: return _IS_VERBOSE def configure_logging(): - global _IS_VERBOSE - _IS_VERBOSE = _is_verbose_env_vars() - handler = logging.StreamHandler(sys.stderr) formatter = logging.Formatter("[%(levelname)s] %(message)s") handler.setFormatter(formatter) From 04e2176a414f7e1879c863ab15d719c88e669457 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 10 Feb 2024 12:13:58 +0100 Subject: [PATCH 14/36] Rename the verbose envvar to DYMOPRINT_VERBOSE Otherwise it's not specific enough. --- scripts/gui_dev.sh | 2 +- src/dymoprint/lib/logger.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/gui_dev.sh b/scripts/gui_dev.sh index f0c0857..326c4e6 100755 --- a/scripts/gui_dev.sh +++ b/scripts/gui_dev.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash while true; do - VERBOSE=$VERBOSE \ + DYMOPRINT_VERBOSE=$DYMOPRINT_VERBOSE \ dymoprint_gui -v; sleep 1 done diff --git a/src/dymoprint/lib/logger.py b/src/dymoprint/lib/logger.py index e0b2e31..40d1cc8 100644 --- a/src/dymoprint/lib/logger.py +++ b/src/dymoprint/lib/logger.py @@ -13,7 +13,7 @@ def _is_env_var_true(env_var: str) -> bool: def is_verbose_env_vars() -> bool: - return _is_env_var_true("VERBOSE") + return _is_env_var_true("DYMOPRINT_VERBOSE") def _update_log_level(): From 40d52b3ccf0a636026846d1e08866acb33a31abb Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 10 Feb 2024 14:23:00 +0100 Subject: [PATCH 15/36] Fix exception printing Consider `e = KeyError("x")`. Then `str(e) == "'x'"`. Instead we use `repr(e) = "KeyError('x')"`. This is achieved with `f"{e!r}"`. --- src/dymoprint/gui/common.py | 8 +++----- src/dymoprint/lib/logger.py | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/dymoprint/gui/common.py b/src/dymoprint/gui/common.py index 1b3f638..ed985a4 100644 --- a/src/dymoprint/gui/common.py +++ b/src/dymoprint/gui/common.py @@ -1,16 +1,14 @@ import logging import traceback -from PyQt6.QtWidgets import ( - QMessageBox, -) +from PyQt6.QtWidgets import QMessageBox, QWidget from dymoprint.lib.logger import VERBOSE_NOTICE, is_verbose, print_exception LOG = logging.getLogger(__name__) -def crash_msg_box(parent, title, err): +def crash_msg_box(parent: QWidget, title: str, err: Exception): print_exception(err) - text = f"{err}\n\n{traceback.format_exc() if is_verbose() else VERBOSE_NOTICE}" + text = f"{err!r}\n\n{traceback.format_exc() if is_verbose() else VERBOSE_NOTICE}" QMessageBox.warning(parent, title, text) diff --git a/src/dymoprint/lib/logger.py b/src/dymoprint/lib/logger.py index 40d1cc8..976e3a4 100644 --- a/src/dymoprint/lib/logger.py +++ b/src/dymoprint/lib/logger.py @@ -39,9 +39,9 @@ def configure_logging(): LOG.addHandler(handler) -def print_exception(e): +def print_exception(e: Exception) -> None: if _IS_VERBOSE: LOG.exception(e) else: - LOG.error(e) + LOG.error(f"{e!r}") LOG.error(VERBOSE_NOTICE) From cb24bc403786e04539186bef4d37a13418c2aaf8 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 10 Feb 2024 12:18:30 +0100 Subject: [PATCH 16/36] Use functional style for config file I find it way faster to understand what's going on when one doesn't need to mentally parse a bunch of custom classes. --- src/dymoprint/lib/config_file.py | 42 +++++++++++++++----------------- src/dymoprint/lib/font_config.py | 7 +++--- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/dymoprint/lib/config_file.py b/src/dymoprint/lib/config_file.py index d757ccb..d7cc8fe 100644 --- a/src/dymoprint/lib/config_file.py +++ b/src/dymoprint/lib/config_file.py @@ -1,33 +1,31 @@ +import logging from configparser import ConfigParser +from functools import lru_cache from pathlib import Path +from typing import Any, Dict, Optional from platformdirs import user_config_dir +logger = logging.getLogger(__name__) -class SectionNotFound(Exception): - def __init__(self, config_file_path, section_name): - msg = f"Section {section_name} not fount in {config_file_path}" - super().__init__(msg) +def get_config_file() -> Path: + return Path(user_config_dir()) / "dymoprint.ini" -class ConfigFile: - _CONFIG_FILE_PATH = Path(user_config_dir()) / "dymoprint.ini" - _config_parser = None - def __init__(self): - config_parser = ConfigParser() - if config_parser.read(self._CONFIG_FILE_PATH): - self._config_parser = config_parser +@lru_cache +def get_config() -> ConfigParser: + config_parser = ConfigParser() + file_to_read = get_config_file() + if config_parser.read(file_to_read): + logger.debug(f"Read config file: {file_to_read}") + else: + logger.debug(f"Config file not found: {file_to_read}") + return config_parser - def section(self, section_name): - """Return the given config file section as dict.""" - if self._config_parser: - try: - return dict(self._config_parser[section_name]) - except KeyError: - raise SectionNotFound(self._CONFIG_FILE_PATH, section_name) from None - return None - @property - def fonts_section(self): - return self.section("FONTS") +def get_config_section(section_name) -> Optional[Dict[str, Any]]: + config = get_config() + if section_name not in config: + return None + return dict(config[section_name]) diff --git a/src/dymoprint/lib/font_config.py b/src/dymoprint/lib/font_config.py index 8b7df8e..546cee1 100644 --- a/src/dymoprint/lib/font_config.py +++ b/src/dymoprint/lib/font_config.py @@ -4,7 +4,7 @@ import dymoprint.resources.fonts from dymoprint._vendor.matplotlib import font_manager -from dymoprint.lib.config_file import ConfigFile +from dymoprint.lib.config_file import get_config_section class NoFontFound(ValueError): @@ -45,9 +45,10 @@ class FontConfig: def __init__(self, font: Optional[str] = None, style: FontStyle = _DEFAULT_STYLE): if font is None: - if fonts_section := ConfigFile().fonts_section: + fonts_config = get_config_section("FONTS") + if fonts_config is not None: style_to_font_path = { - FontStyle.from_name(k): v for k, v in fonts_section.items() + FontStyle.from_name(k): v for k, v in fonts_config.items() } else: style_to_font_path = _DEFAULT_FONT_FILENAME From 89ef0bd8b772bb89f70b3b727638ffc4b06fea07 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 10 Feb 2024 14:14:54 +0100 Subject: [PATCH 17/36] Remove unneeded classes for font config It's much better to consider the module itself as the object. --- src/dymoprint/cli/cli.py | 20 ++-- src/dymoprint/gui/q_dymo_label_widgets.py | 4 +- src/dymoprint/lib/font_config.py | 124 +++++++++++++--------- 3 files changed, 82 insertions(+), 66 deletions(-) diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index dfa55bc..4dda85d 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -21,7 +21,7 @@ e_qrcode, ) from dymoprint.lib.dymo_labeler import DymoLabeler -from dymoprint.lib.font_config import FontConfig, FontStyle, NoFontFound +from dymoprint.lib.font_config import NoFontFound, get_available_fonts, get_font_path from dymoprint.lib.logger import configure_logging, is_verbose_env_vars, set_not_verbose from dymoprint.lib.render_engines import ( BarcodeRenderEngine, @@ -42,10 +42,10 @@ LOG = logging.getLogger(__name__) FLAG_TO_STYLE = { - "r": FontStyle.REGULAR, - "b": FontStyle.BOLD, - "i": FontStyle.ITALIC, - "n": FontStyle.NARROW, + "r": "regular", + "b": "bold", + "i": "italic", + "n": "narrow", } @@ -218,14 +218,12 @@ def run(): # read config file style = FLAG_TO_STYLE.get(args.style) try: - font_config = FontConfig(font=args.font, style=style) + font_path = get_font_path(font=args.font, style=style) except NoFontFound as e: - valid_font_names = [f.stem for f in FontConfig.available_fonts()] + valid_font_names = [f.stem for f in get_available_fonts()] msg = f"{e}. Valid fonts are: {', '.join(valid_font_names)}" raise CommandLineUsageError(msg) from None - font_filename = font_config.path - labeltext = args.text # check if barcode, qrcode or text should be printed, use frames only on text @@ -263,7 +261,7 @@ def run(): elif args.barcode_text: render_engines.append( BarcodeWithTextRenderEngine( - labeltext.pop(0), args.barcode_text, font_filename, args.frame_width_px + labeltext.pop(0), args.barcode_text, font_path, args.frame_width_px ) ) @@ -271,7 +269,7 @@ def run(): render_engines.append( TextRenderEngine( text_lines=labeltext, - font_file_name=font_filename, + font_file_name=font_path, frame_width_px=args.frame_width_px, font_size_ratio=int(args.scale) / 100.0, align=args.align, diff --git a/src/dymoprint/gui/q_dymo_label_widgets.py b/src/dymoprint/gui/q_dymo_label_widgets.py index 791f3e2..598a821 100644 --- a/src/dymoprint/gui/q_dymo_label_widgets.py +++ b/src/dymoprint/gui/q_dymo_label_widgets.py @@ -18,7 +18,7 @@ from dymoprint.gui.common import crash_msg_box from dymoprint.lib.constants import AVAILABLE_BARCODES, ICON_DIR -from dymoprint.lib.font_config import FontConfig +from dymoprint.lib.font_config import get_available_fonts from dymoprint.lib.render_engines import ( BarcodeRenderEngine, BarcodeWithTextRenderEngine, @@ -36,7 +36,7 @@ class FontStyle(QComboBox): def __init__(self): super().__init__() # Populate font_style - for font_path in FontConfig.available_fonts(): + for font_path in get_available_fonts(): name = font_path.stem absolute_path = font_path.absolute() self.addItem(name, absolute_path) diff --git a/src/dymoprint/lib/font_config.py b/src/dymoprint/lib/font_config.py index 546cee1..67c2169 100644 --- a/src/dymoprint/lib/font_config.py +++ b/src/dymoprint/lib/font_config.py @@ -1,11 +1,13 @@ -from enum import Enum +import logging from pathlib import Path -from typing import Optional +from typing import Dict, List, Optional import dymoprint.resources.fonts from dymoprint._vendor.matplotlib import font_manager from dymoprint.lib.config_file import get_config_section +logger = logging.getLogger(__name__) + class NoFontFound(ValueError): def __init__(self, name): @@ -13,63 +15,79 @@ def __init__(self, name): super().__init__(msg) -class FontStyle(Enum): - REGULAR = 1 - BOLD = 2 - ITALIC = 3 - NARROW = 4 - - @classmethod - def from_name(cls, name): - return { - "regular": cls.REGULAR, - "bold": cls.BOLD, - "italic": cls.ITALIC, - "narrow": cls.NARROW, - }.get(name) +class NoStyleFound(ValueError): + def __init__(self, style): + msg = f"No style named {style} found" + super().__init__(msg) _DEFAULT_FONTS_DIR = Path(dymoprint.resources.fonts.__file__).parent -_DEFAULT_FONT_FILENAME = { - FontStyle.REGULAR: str(_DEFAULT_FONTS_DIR / "Carlito-Regular.ttf"), - FontStyle.BOLD: str(_DEFAULT_FONTS_DIR / "Carlito-Bold.ttf"), - FontStyle.ITALIC: str(_DEFAULT_FONTS_DIR / "Carlito-Italic.ttf"), - FontStyle.NARROW: str(_DEFAULT_FONTS_DIR / "Carlito-BoldItalic.ttf"), +_DEFAULT_STYLE = "regular" +_DEFAULT_STYLES_TO_FONT_PATH: Dict[str, Path] = { + "regular": _DEFAULT_FONTS_DIR / "Carlito-Regular.ttf", + "bold": _DEFAULT_FONTS_DIR / "Carlito-Bold.ttf", + "italic": _DEFAULT_FONTS_DIR / "Carlito-Italic.ttf", + "narrow": _DEFAULT_FONTS_DIR / "Carlito-BoldItalic.ttf", } -class FontConfig: - _DEFAULT_STYLE = FontStyle.REGULAR +def _get_styles_to_font_path_lookup() -> Dict[str, Path]: + """Get a lookup table for styles to font paths. - path = None + The lookup table is read from the config file, if available. + """ + styles_to_font_path = _DEFAULT_STYLES_TO_FONT_PATH.copy() + fonts_config = get_config_section("FONTS") + if fonts_config is not None: + for style_from_config, filename in fonts_config.items(): + styles_to_font_path[style_from_config] = Path(filename) + return styles_to_font_path - def __init__(self, font: Optional[str] = None, style: FontStyle = _DEFAULT_STYLE): - if font is None: - fonts_config = get_config_section("FONTS") - if fonts_config is not None: - style_to_font_path = { - FontStyle.from_name(k): v for k, v in fonts_config.items() - } - else: - style_to_font_path = _DEFAULT_FONT_FILENAME - self.path = style_to_font_path[style] + +def get_font_path( + font: Optional[str] = None, style: Optional[str] = _DEFAULT_STYLE +) -> Path: + """Get the path to a font. + + The `font` argument can be either a font name or a path to a font file. + If `font` is not provided, the default font is used. In that case, the `style` + argument can be used to specify the style of the default font. + """ + if font is not None: + if Path(font).is_file(): + path = Path(font) + else: + path = _path_from_name(name=font) + else: + styles_to_font_path = _get_styles_to_font_path_lookup() + if style in styles_to_font_path: + path = styles_to_font_path[style] else: - if Path(font).is_file(): - self.path = font - else: - self.path = self._path_from_name(name=font) - assert Path(self.path).is_file() - - @classmethod - def _path_from_name(cls, name): - available_fonts = cls.available_fonts() - matching_fonts = [f for f in available_fonts if name.lower() == f.stem.lower()] - if len(matching_fonts) == 0: - raise NoFontFound(name) - return matching_fonts[0] - - @classmethod - def available_fonts(cls): - fonts = [f for f in _DEFAULT_FONTS_DIR.iterdir() if f.suffix == ".ttf"] - fonts.extend(Path(f) for f in font_manager.findSystemFonts()) - return sorted(fonts, key=lambda f: f.stem.lower()) + logger.debug(f"Style '{style}' unrecognized. Known: {styles_to_font_path}") + raise NoStyleFound(style) + + # Double-check that the file exists + if not Path(path).is_file(): + logger.error(f"Font file not found: {path}") + raise NoFontFound(font) + return path + + +def _path_from_name(name: str) -> Path: + """Get the path to a font from its name. + + The name should be the name of the font file, without the extension. + It is case-insensitive. + """ + available_fonts = get_available_fonts() + matching_fonts = [f for f in available_fonts if name.lower() == f.stem.lower()] + if len(matching_fonts) == 0: + raise NoFontFound(name) + return matching_fonts[0] + + +def get_available_fonts() -> List[Path]: + """Get a list of available font files.""" + fonts = [f for f in _DEFAULT_FONTS_DIR.iterdir() if f.suffix == ".ttf"] + fonts.extend(Path(f) for f in font_manager.findSystemFonts()) + return sorted(fonts, key=lambda f: f.stem.lower()) From 6b9d144b65b07ec9a683ae85b6c22672af833689 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sun, 3 Mar 2024 14:59:11 +0100 Subject: [PATCH 18/36] Refactor RenderContext It's much easier and more inspectable by type checkers to use a NamedTuple instead of a custom class with Enum. --- src/dymoprint/gui/gui.py | 16 +++---- .../lib/render_engines/render_context.py | 42 +++---------------- 2 files changed, 15 insertions(+), 43 deletions(-) diff --git a/src/dymoprint/gui/gui.py b/src/dymoprint/gui/gui.py index 6b83530..b0cdb80 100644 --- a/src/dymoprint/gui/gui.py +++ b/src/dymoprint/gui/gui.py @@ -39,6 +39,8 @@ class DymoPrintWindow(QWidget): label_bitmap_to_print: Optional[Image.Image] dymo_labeler: DymoLabeler + render_context: RenderContext + tape_size_mm: QComboBox def __init__(self): super().__init__() @@ -46,7 +48,6 @@ def __init__(self): self.detected_device = None self.window_layout = QVBoxLayout() - self.render_context = RenderContext() self.label_list = QDymoLabelList() self.label_render = QLabel() @@ -60,7 +61,6 @@ def __init__(self): self.justify = QComboBox() self.preview_show_margins = QCheckBox() self.last_error = None - self.dymo_labeler = None self.init_elements() self.init_timers() @@ -183,15 +183,17 @@ def update_params(self): justify: str = self.justify.currentText() 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() + tape_size_mm: int = self.tape_size_mm.currentData() self.dymo_labeler.tape_size_mm = tape_size_mm # Update render context - self.render_context.foreground_color = self.foreground_color.currentText() - self.render_context.background_color = self.background_color.currentText() - self.render_context.height_px = self.dymo_labeler.height_px - self.render_context.preview_show_margins = self.preview_show_margins.isChecked() + self.render_context = RenderContext( + foreground_color=self.foreground_color.currentText(), + background_color=self.background_color.currentText(), + height_px=self.dymo_labeler.height_px, + preview_show_margins=self.preview_show_margins.isChecked(), + ) self.label_list.update_params( dymo_labeler=self.dymo_labeler, diff --git a/src/dymoprint/lib/render_engines/render_context.py b/src/dymoprint/lib/render_engines/render_context.py index 652960f..a8c9468 100644 --- a/src/dymoprint/lib/render_engines/render_context.py +++ b/src/dymoprint/lib/render_engines/render_context.py @@ -1,38 +1,8 @@ -from enum import Enum +from typing import NamedTuple -class _RenderContextFieldName(Enum): - BACKGROUND_COLOR = 1 - FOREGROUND_COLOR = 2 - HEIGHT_PX = 3 - PREVIEW_SHOW_MARGINS = 4 - - -class RenderContext: - _context: dict - - def __init__(self, **kwargs): - self._context = dict() - for k, v in kwargs.items(): - self._context[_RenderContextFieldName[k.upper()].name.lower()] = v - - # add property per field name (e.g. context.height_px) - for field in _RenderContextFieldName: - - def get_fget(field=field): - return lambda _self: _self._context[field.name.lower()] - - def get_fset(field=field): - def fset(_self, val): - _self._context[field.name.lower()] = val - - return fset - - setattr( - self.__class__, - field.name.lower(), - property(get_fget(field), get_fset(field)), - ) - - def __str__(self): - return self._context.__str__() +class RenderContext(NamedTuple): + height_px: int + preview_show_margins: bool = True + background_color: str = "white" + foreground_color: str = "black" From ca7e0e47c7a65866a1040fcd18b0119b186bac24 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sun, 3 Mar 2024 15:46:55 +0100 Subject: [PATCH 19/36] Update Ruff pre-commit version --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee8f2d2..8cfd5f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: args: ['--fix=lf'] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.13 + rev: v0.3.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From a16922f00fd5ecfdf994294f19aa65c83735e589 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sun, 3 Mar 2024 15:49:03 +0100 Subject: [PATCH 20/36] Update pyproject.toml to new ruff conventions --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4d4ae19..4d4cbc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,6 +139,9 @@ drop = [ line-length = 88 extend-exclude = ["_vendor"] src = ["src"] +target-version = "py38" + +[tool.ruff.lint] select = [ "D", # pydocstyle "E", # pycodestyle errors @@ -183,7 +186,6 @@ ignore = [ "ISC001", # single-line-implicit-string-concatenation "ISC002", # multi-line-implicit-string-concatenation ] -target-version = "py38" [tool.mypy] exclude = ["_vendor"] From ccacf6c5f406064106ac704f666bc0e39073cdea Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sun, 3 Mar 2024 15:50:18 +0100 Subject: [PATCH 21/36] Use updated ruff docstring formatting --- src/dymoprint/gui/q_dymo_label_widgets.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/dymoprint/gui/q_dymo_label_widgets.py b/src/dymoprint/gui/q_dymo_label_widgets.py index 598a821..4a12cff 100644 --- a/src/dymoprint/gui/q_dymo_label_widgets.py +++ b/src/dymoprint/gui/q_dymo_label_widgets.py @@ -57,6 +57,7 @@ class BaseDymoLabelWidget(QWidget): Emits the itemRenderSignal when the content of the label is changed. render_label() Abstract method to be implemented by subclasses for rendering the label. + """ render_context: RenderContext @@ -98,6 +99,7 @@ class TextDymoLabelWidget(BaseDymoLabelWidget): frame_width_px (QSpinBox): The frame width selection spinner. Signals: itemRenderSignal: A signal emitted when the content of the label changes. + """ align: QComboBox @@ -162,6 +164,7 @@ def render_engine_impl(self): Returns ------- TextRenderEngine: The rendered engine. + """ selected_alignment = self.align.currentText() assert selected_alignment in ("left", "center", "right") @@ -182,6 +185,7 @@ class QrDymoLabelWidget(BaseDymoLabelWidget): render_context (RenderContext): The render context to use for rendering the QR code. parent (QWidget, optional): The parent widget. Defaults to None. + """ def __init__(self, render_context, parent=None): @@ -192,6 +196,7 @@ def __init__(self, render_context, parent=None): render_context (RenderContext): The render context to use for rendering the QR code. parent (QWidget, optional): The parent widget. Defaults to None. + """ super().__init__(parent) self.render_context = render_context @@ -213,6 +218,7 @@ def render_engine_impl(self): Returns ------- QrRenderEngine: The render engine. + """ try: return QrRenderEngine(content=self.label.text()) @@ -247,6 +253,7 @@ class BarcodeDymoLabelWidget(BaseDymoLabelWidget): __init__(self, render_context, parent=None): Initializes the widget. render_label_impl(self): Renders the barcode label using the current content and barcode type. + """ label: QLineEdit @@ -360,6 +367,7 @@ def render_engine_impl(self): ------- RenderEngine: The rendered engine (either BarcodeRenderEngine or BarcodeWithTextRenderEngine). + """ if self.show_text_checkbox.isChecked(): render_engine = BarcodeWithTextRenderEngine( @@ -385,6 +393,7 @@ class ImageDymoLabelWidget(BaseDymoLabelWidget): ---- context (RenderContext): The render context to use for rendering the label. parent (QWidget, optional): The parent widget. Defaults to None. + """ def __init__(self, render_context, parent=None): @@ -395,6 +404,7 @@ def __init__(self, render_context, parent=None): render_context (RenderContext): The render context to use for rendering the label. parent (QWidget, optional): The parent widget. Defaults to None. + """ super().__init__(parent) self.render_context = render_context @@ -427,6 +437,7 @@ def render_engine_impl(self): Returns ------- PictureRenderEngine: The rendered engine. + """ try: return PictureRenderEngine(picture_path=self.label.text()) From 055fa1ca863360953e4370953a9cd00ad7e6b3ee Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sun, 3 Mar 2024 15:51:14 +0100 Subject: [PATCH 22/36] Fix typos in explanatory "print mode" comment --- src/dymoprint/lib/render_engines/margins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dymoprint/lib/render_engines/margins.py b/src/dymoprint/lib/render_engines/margins.py index 0df211a..9759094 100644 --- a/src/dymoprint/lib/render_engines/margins.py +++ b/src/dymoprint/lib/render_engines/margins.py @@ -85,11 +85,11 @@ def render(self, context: RenderContext) -> tuple[Image.Image, dict[str, float]] # 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 + # to move the edge of the printed payload past the cutter, otherwise 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 + # in order to have symmetry 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 From 8851f0d55e78ec532ce62c34382f8c04898fe57e Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sun, 3 Mar 2024 15:56:16 +0100 Subject: [PATCH 23/36] Use typing to declare possible values of align --- src/dymoprint/lib/render_engines/barcode_with_text.py | 3 ++- src/dymoprint/lib/render_engines/text.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/dymoprint/lib/render_engines/barcode_with_text.py b/src/dymoprint/lib/render_engines/barcode_with_text.py index 3725c74..d24d305 100644 --- a/src/dymoprint/lib/render_engines/barcode_with_text.py +++ b/src/dymoprint/lib/render_engines/barcode_with_text.py @@ -2,6 +2,7 @@ from copy import deepcopy from pathlib import Path +from typing import Literal from PIL import Image @@ -21,7 +22,7 @@ def __init__( font_file_name: Path | str, frame_width_px: int, font_size_ratio: float = 0.9, - align: str = "center", + align: Literal["left", "center", "right"] = "center", ): super().__init__() self._barcode = BarcodeRenderEngine(content, barcode_type) diff --git a/src/dymoprint/lib/render_engines/text.py b/src/dymoprint/lib/render_engines/text.py index 0d3538d..b2b7016 100644 --- a/src/dymoprint/lib/render_engines/text.py +++ b/src/dymoprint/lib/render_engines/text.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import Literal from PIL import Image, ImageFont @@ -16,9 +17,8 @@ def __init__( font_file_name: Path | str, frame_width_px: int, font_size_ratio: float = 0.9, - align: str = "left", + align: Literal["left", "center", "right"] = "left", ): - assert align in ("left", "center", "right") if isinstance(text_lines, str): text_lines = [text_lines] From b15d8d15653e84834f7c6a641dc235f84e8ed704 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sun, 3 Mar 2024 15:57:09 +0100 Subject: [PATCH 24/36] Don't mutate RenderContext --- src/dymoprint/lib/render_engines/barcode_with_text.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/dymoprint/lib/render_engines/barcode_with_text.py b/src/dymoprint/lib/render_engines/barcode_with_text.py index d24d305..8ac7da1 100644 --- a/src/dymoprint/lib/render_engines/barcode_with_text.py +++ b/src/dymoprint/lib/render_engines/barcode_with_text.py @@ -1,6 +1,5 @@ from __future__ import annotations -from copy import deepcopy from pathlib import Path from typing import Literal @@ -33,9 +32,10 @@ def __init__( def render(self, render_context: RenderContext) -> Image.Image: bitmap = self._barcode.render(render_context) - text_render_context = deepcopy(render_context) - text_render_context.height_px = int( - text_render_context.height_px * self.TEXT_HEIGHT_SCALE_FACTOR + text_render_context = RenderContext( + height_px=int(render_context.height_px * self.TEXT_HEIGHT_SCALE_FACTOR), + foreground_color=render_context.foreground_color, + background_color=render_context.background_color, ) text_bitmap = self._text.render(text_render_context) From b790238b059de783dec61feccb4cf41bd94818c1 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sun, 3 Mar 2024 16:07:52 +0100 Subject: [PATCH 25/36] Install types-pillow to fix type checking --- .pre-commit-config.yaml | 4 +++- pyproject.toml | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8cfd5f3..cbd0c4d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,4 +33,6 @@ repos: - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.8.0 hooks: - - id: mypy + - id: mypy + additional_dependencies: + - types-pillow diff --git a/pyproject.toml b/pyproject.toml index 4d4cbc3..bebcb9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -189,6 +189,5 @@ ignore = [ [tool.mypy] exclude = ["_vendor"] -ignore_missing_imports = true check_untyped_defs = true install_types = true From d4df4dab1123fd883524031f275f67de7e6a4367 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sun, 3 Mar 2024 16:14:49 +0100 Subject: [PATCH 26/36] Eliminate render_kwargs Using a kwargs dictionary does save a few lines of code, but it prevents the type checker from working, and it's harder to read and inspect. --- src/dymoprint/cli/cli.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/dymoprint/cli/cli.py b/src/dymoprint/cli/cli.py index 4dda85d..baf292f 100755 --- a/src/dymoprint/cli/cli.py +++ b/src/dymoprint/cli/cli.py @@ -296,14 +296,6 @@ def run(): dymo_labeler = DymoLabeler(tape_size_mm=args.tape_size_mm) render_engine = HorizontallyCombinedRenderEngine(render_engines) - render_kwargs = dict( - render_engine=render_engine, - justify=args.justify, - visible_horizontal_margin_px=margin_px, - labeler_margin_px=dymo_labeler.labeler_margin_px, - max_width_px=max_payload_len_px, - min_width_px=min_payload_len_px, - ) render_context = RenderContext( background_color="white", foreground_color="black", @@ -313,7 +305,14 @@ def run(): # print or show the label if args.preview or args.preview_inverted or args.imagemagick or args.browser: - render = PrintPreviewRenderEngine(**render_kwargs) + render = PrintPreviewRenderEngine( + render_engine=render_engine, + justify=args.justify, + visible_horizontal_margin_px=margin_px, + labeler_margin_px=dymo_labeler.labeler_margin_px, + max_width_px=max_payload_len_px, + min_width_px=min_payload_len_px, + ) bitmap = render.render(render_context) LOG.debug("Demo mode: showing label..") if args.preview or args.preview_inverted: @@ -327,7 +326,14 @@ def run(): ImageOps.invert(inverted).save(fp) webbrowser.open(f"file://{fp.name}") else: - render = PrintPayloadRenderEngine(**render_kwargs) + render = PrintPayloadRenderEngine( + render_engine=render_engine, + justify=args.justify, + visible_horizontal_margin_px=margin_px, + labeler_margin_px=dymo_labeler.labeler_margin_px, + max_width_px=max_payload_len_px, + min_width_px=min_payload_len_px, + ) bitmap, _ = render.render(render_context) dymo_labeler.print(bitmap) From a041882c65be5dccff072c50e4511e5c0f5ac8d6 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sat, 23 Mar 2024 17:14:59 +0000 Subject: [PATCH 27/36] Major refactor for render engines Have separate class for each kind of rendered (text, barcode, QR, etc.). Combine renders are done using another kind of render (HorizontallyCombinedRenderEngine). Move logic to associate renders and dymo device in a separate labeler_device file. Note that the new architecture is design to support multiple labelers in the future. This commit is the first step in that direction. --- src/dymoprint/lib/render_engines/barcode_with_text.py | 7 +++++-- src/dymoprint/lib/render_engines/picture.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/dymoprint/lib/render_engines/barcode_with_text.py b/src/dymoprint/lib/render_engines/barcode_with_text.py index 8ac7da1..3e183fa 100644 --- a/src/dymoprint/lib/render_engines/barcode_with_text.py +++ b/src/dymoprint/lib/render_engines/barcode_with_text.py @@ -7,7 +7,10 @@ from dymoprint.lib.render_engines.barcode import BarcodeRenderEngine from dymoprint.lib.render_engines.render_context import RenderContext -from dymoprint.lib.render_engines.render_engine import RenderEngine +from dymoprint.lib.render_engines.render_engine import ( + RenderEngine, + RenderEngineException, +) from dymoprint.lib.render_engines.text import TextRenderEngine @@ -49,7 +52,7 @@ def render(self, render_context: RenderContext) -> Image.Image: elif self.align == "right": text_offset_y = bitmap.width - text_bitmap.width else: - raise ValueError(f"Invalid align value: {self.align}") + raise RenderEngineException(f"Invalid align value: {self.align}") bitmap.paste(text_bitmap, (text_offset_y, text_offset_x)) return bitmap diff --git a/src/dymoprint/lib/render_engines/picture.py b/src/dymoprint/lib/render_engines/picture.py index 7d7dceb..30b5a18 100644 --- a/src/dymoprint/lib/render_engines/picture.py +++ b/src/dymoprint/lib/render_engines/picture.py @@ -21,7 +21,7 @@ def __init__(self, picture_path): raise NoPictureFilePath() self.picture_path = Path(picture_path) if not self.picture_path.is_file(): - raise FileNotFoundError(f"Picture path does not exist: {picture_path}") + raise RenderEngineException(f"Picture path does not exist: {picture_path}") def render(self, context: RenderContext) -> Image.Image: height_px = context.height_px From ee92b3392ee0b2051beb4039f0cbf8f51c45da07 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 24 Mar 2024 15:01:35 +0000 Subject: [PATCH 28/36] Use more specific exception --- src/dymoprint/gui/q_dymo_label_widgets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dymoprint/gui/q_dymo_label_widgets.py b/src/dymoprint/gui/q_dymo_label_widgets.py index 4a12cff..1a6c587 100644 --- a/src/dymoprint/gui/q_dymo_label_widgets.py +++ b/src/dymoprint/gui/q_dymo_label_widgets.py @@ -30,6 +30,7 @@ RenderContext, TextRenderEngine, ) +from dymoprint.lib.render_engines.render_engine import RenderEngineException class FontStyle(QComboBox): @@ -77,7 +78,7 @@ def render_engine_impl(self): def render_engine(self): try: return self.render_engine_impl - except BaseException as err: # noqa: BLE001 + except RenderEngineException as err: crash_msg_box(self, "Render Engine Failed!", err) return EmptyRenderEngine() From 593e4c58070bbd1d6c8e464df28b0c732bd79dd7 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 24 Mar 2024 16:00:48 +0000 Subject: [PATCH 29/36] Add Markdown lint Add linter for markdown files [1] [1] https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#use-with-pre-commit --- .pre-commit-config.yaml | 5 +++++ README.md | 31 ++++++++++++++++++++----------- vendoring/README.md | 3 ++- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cbd0c4d..e3a53cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,3 +36,8 @@ repos: - id: mypy additional_dependencies: - types-pillow + +- repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.39.0 + hooks: + - id: markdownlint diff --git a/README.md b/README.md index 41436ea..c06d3d0 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ For more information about experimental device support, see [#44](https://github ## Installation -It is recommended to install dymoprint with [pipx](https://pypa.github.io/pipx/) so that it runs in an isolated virtual environment: +It is recommended to install dymoprint with +[pipx](https://pypa.github.io/pipx/) so that it runs in an isolated virtual +environment: ```bash pipx install dymoprint @@ -53,6 +55,8 @@ By default, users don't have permission to access generic USB devices, so you wi need to add a rule. The first time you run `dymoprint`, it will give instructions about how to do this: +{{< mdl-disable "" >}} + ```bash $ dymoprint "Hello world" ... @@ -62,6 +66,8 @@ You do not have sufficient access to the device. You probably want to add the a ... ``` +{{< mdl-enable "" >}} + ## Testing experimental features To install a test branch, by user `ghuser` for the branch `branchname`, run @@ -76,17 +82,20 @@ To revert back to the release version, run pipx install --force dymoprint ``` -To install a particular release version, specify `dymoprint==x.y.z` instead of `dymoprint` in the above command. +To install a particular release version, specify `dymoprint==x.y.z` instead of +`dymoprint` in the above command. ## Development and code style -To install for development, fork and clone this repository, and run (ideally within a venv): +To install for development, fork and clone this repository, and run (ideally +within a venv): ```bash pip install --editable . ``` -This project uses [pre-commit](https://pre-commit.com/) to run some checks before committing. +This project uses [pre-commit](https://pre-commit.com/) to run some checks +before committing. After installing the `pre-commit` executable, please run ```bash @@ -146,7 +155,8 @@ Any picture with JPEG standard may be printed. Beware it will be downsized to ta ```dymoprint -p mypic.jpg ""``` -Take care of the trailing "" - you may enter text here which gets printed in front of the image +Take care of the trailing "" - you may enter text here which gets printed in +front of the image ## GUI @@ -154,8 +164,8 @@ Take care of the trailing "" - you may enter text here which gets printed in fro ```dymoprint_gui``` +### GUI App Features -### Features * Live preview * margin settings * type size selector @@ -175,8 +185,8 @@ Take care of the trailing "" - you may enter text here which gets printed in fro * path to file Nodes can be freely arranged, simply drag&drop rows on the list. -To add or delete the node from the label - right-click on the list and select the action from the context menu. -To print - click the print button. +To add or delete the node from the label - right-click on the list and select +the action from the context menu. To print - click the print button. ### Example @@ -192,11 +202,10 @@ Example 3: barcode, text, image ![alt](doc/DymoPrint_example_3.png) - - ## Development -Besides the travis-ci one should run the following command on a feature implemention or change to ensure the same outcome on a real device: +Besides the travis-ci one should run the following command on a feature +implemention or change to ensure the same outcome on a real device: ```bash dymoprint Tst && \ diff --git a/vendoring/README.md b/vendoring/README.md index 8866064..39855d7 100644 --- a/vendoring/README.md +++ b/vendoring/README.md @@ -15,4 +15,5 @@ vendoring update ### [`matplotlib`](https://github.com/matplotlib/matplotlib/) -We take just a subset of `font_manager.py` used for selecting fonts. See [LICENSE](../src/dymoprint/_vendor/matplotlib/LICENSE) for the license. +We take just a subset of `font_manager.py` used for selecting fonts. See +[LICENSE](../src/dymoprint/_vendor/matplotlib/LICENSE) for the license. From d118bbba5a56faf91224343334ecb9be6bc595e9 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 24 Mar 2024 16:47:35 +0000 Subject: [PATCH 30/36] Colorize margins according to system theme --- pyproject.toml | 1 + .../lib/render_engines/print_preview.py | 37 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bebcb9b..de4a08e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "python-barcode>=0.13.1,<1", "pyusb", "PyQt6", + "darkdetect", ] classifiers = [ "Operating System :: POSIX :: Linux", diff --git a/src/dymoprint/lib/render_engines/print_preview.py b/src/dymoprint/lib/render_engines/print_preview.py index 4e16e3c..0c2e615 100644 --- a/src/dymoprint/lib/render_engines/print_preview.py +++ b/src/dymoprint/lib/render_engines/print_preview.py @@ -2,6 +2,7 @@ from typing import Literal +from darkdetect import isDark from PIL import Image, ImageColor, ImageDraw, ImageOps from dymoprint.lib.render_engines.margins import MarginsMode, MarginsRenderEngine @@ -15,9 +16,6 @@ class PrintPreviewRenderEngine(RenderEngine): Y_MARGIN_PX = 30 DX = X_MARGIN_PX * 0.3 DY = Y_MARGIN_PX * 0.3 - MARGIN_COLOR = "red" - MARK_COLOR = "yellow" - TEXT_COLOR = "blue" def __init__( self, @@ -39,6 +37,18 @@ def __init__( min_width_px=min_width_px, ) + @staticmethod + def _get_margin_color(): + return "red" if isDark() else "gray" + + @staticmethod + def _get_mark_color(): + return "yellow" if isDark() else "red" + + @staticmethod + def _get_text_color(): + return "white" if isDark() else "blue" + def _get_label_bitmap(self, context: RenderContext): render_bitmap, meta = self.render_engine.render(context) bitmap = ImageOps.invert(render_bitmap.convert("L")).convert("RGBA") @@ -68,6 +78,9 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): label_width_mark_y = preview_height - self.DY preview_width_mark_x = self.X_MARGIN_PX - self.DX label_width_mark_x = self.DX + margin_color = self._get_margin_color() + mark_color = self._get_mark_color() + text_color = self._get_text_color() # left vertical margin draw.line( @@ -77,7 +90,7 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): self.X_MARGIN_PX + x_margin, preview_width_mark_y, ), - fill=self.MARGIN_COLOR, + fill=margin_color, ) # right vertical margin draw.line( @@ -87,7 +100,7 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): self.X_MARGIN_PX + label_width - x_margin, preview_width_mark_y, ), - fill=self.MARGIN_COLOR, + fill=margin_color, ) # top horizontal margin draw.line( @@ -97,7 +110,7 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): preview_width, self.DY + y_margin, ), - fill=self.MARGIN_COLOR, + fill=margin_color, ) # bottom horizontal margin draw.line( @@ -107,7 +120,7 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): preview_width, self.DY + label_height - y_margin, ), - fill=self.MARGIN_COLOR, + fill=margin_color, ) # horizontal line for payload width draw.line( @@ -117,7 +130,7 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): self.X_MARGIN_PX + label_width - x_margin, preview_width_mark_y, ), - fill=self.MARK_COLOR, + fill=mark_color, ) # horizontal line for label width draw.line( @@ -127,7 +140,7 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): self.X_MARGIN_PX + label_width, label_width_mark_y, ), - fill=self.MARK_COLOR, + fill=mark_color, ) # vertical line for payload height draw.line( @@ -137,7 +150,7 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): preview_width_mark_x, self.DY + label_height - y_margin, ), - fill=self.MARK_COLOR, + fill=mark_color, ) # vertical line for label height draw.line( @@ -147,7 +160,7 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): label_width_mark_x, self.DY + label_height, ), - fill=self.MARK_COLOR, + fill=mark_color, ) labels = [ @@ -183,7 +196,7 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): for label in labels: bbox = draw.textbbox(**label) # type: ignore[arg-type] draw.rectangle(bbox, fill=(0, 0, 0, 0)) - draw.text(**label, fill=self.TEXT_COLOR) # type: ignore[arg-type] + draw.text(**label, fill=text_color) # type: ignore[arg-type] def render(self, context: RenderContext) -> Image.Image: label_bitmap, meta = self._get_label_bitmap(context) From a41d2b23a14658ac9063e49d9f4f016a3837aa2a Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 24 Mar 2024 17:19:51 +0000 Subject: [PATCH 31/36] Update type hint for justify --- src/dymoprint/gui/q_dymo_labels_list.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dymoprint/gui/q_dymo_labels_list.py b/src/dymoprint/gui/q_dymo_labels_list.py index f064ac3..9cedd07 100644 --- a/src/dymoprint/gui/q_dymo_labels_list.py +++ b/src/dymoprint/gui/q_dymo_labels_list.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Literal, Optional from PIL import Image from PyQt6 import QtCore @@ -113,7 +113,7 @@ def update_params( h_margin_mm: float, min_label_width_mm: float, render_context: RenderContext, - justify: str = "center", + justify: Literal["left", "center", "right"] = "center", ): """Update the render context used for rendering the label. @@ -123,7 +123,7 @@ def update_params( 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] + justify: justification [left,center, right] """ self.dymo_labeler = dymo_labeler From 6f67623c002792b51974710a90f4c350ee874cbc Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 24 Mar 2024 17:30:02 +0000 Subject: [PATCH 32/36] Update margin calculation comment --- src/dymoprint/lib/render_engines/margins.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/dymoprint/lib/render_engines/margins.py b/src/dymoprint/lib/render_engines/margins.py index 9759094..c706dd5 100644 --- a/src/dymoprint/lib/render_engines/margins.py +++ b/src/dymoprint/lib/render_engines/margins.py @@ -83,16 +83,19 @@ def render(self, context: RenderContext) -> tuple[Image.Image, dict[str, float]] # 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). + # label's edge (the label's edge begins 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, otherwise 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 symmetry with the initial margin between label edge and start - # of printed payload. + # Subsequently, in order to achieve symmetry so that the final margin matches + # the initial margin, we add another offset of DX pixels, so that the cut will + # have that same margin from both sides of the payload edge. In summary, due to + # the gap between the printer head and the cutter of DX pixels, printing an + # initial margin of 0 pixels and a final margin of 2 * DX pixels leaves an + # effective margin of DX pixels on both sides. # - # There's also some vertical margin between printed area and the label edge + # There's also some vertical margin between printed area and the label + # edge vertical_offset_px: float = 0 if self.mode == MarginsMode.PRINT: From 6a859d9e8509e64fc84ea3806c0b93be819bbe00 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 24 Mar 2024 17:34:53 +0000 Subject: [PATCH 33/36] Use typing literal instead of enums Following long discussion in the PR [1], I'll go with maresb's style preference of favoring literal type hint over an enum. [1] https://github.com/computerlyrik/dymoprint/pull/113#discussion_r1536856826 --- src/dymoprint/lib/render_engines/__init__.py | 3 +-- src/dymoprint/lib/render_engines/margins.py | 12 +++--------- src/dymoprint/lib/render_engines/print_payload.py | 4 ++-- src/dymoprint/lib/render_engines/print_preview.py | 4 ++-- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/dymoprint/lib/render_engines/__init__.py b/src/dymoprint/lib/render_engines/__init__.py index c7daac9..85e314a 100644 --- a/src/dymoprint/lib/render_engines/__init__.py +++ b/src/dymoprint/lib/render_engines/__init__.py @@ -4,7 +4,7 @@ from dymoprint.lib.render_engines.horizontally_combined import ( HorizontallyCombinedRenderEngine, ) -from dymoprint.lib.render_engines.margins import MarginsMode, MarginsRenderEngine +from dymoprint.lib.render_engines.margins import 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 @@ -19,7 +19,6 @@ BarcodeWithTextRenderEngine, EmptyRenderEngine, HorizontallyCombinedRenderEngine, - MarginsMode, MarginsRenderEngine, NoContentError, NoPictureFilePath, diff --git a/src/dymoprint/lib/render_engines/margins.py b/src/dymoprint/lib/render_engines/margins.py index c706dd5..37799da 100644 --- a/src/dymoprint/lib/render_engines/margins.py +++ b/src/dymoprint/lib/render_engines/margins.py @@ -1,7 +1,6 @@ from __future__ import annotations import math -from enum import Enum from typing import Literal from PIL import Image @@ -19,16 +18,11 @@ def __init__(self, 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, + mode: Literal["print", "preview"], justify: Literal["left", "center", "right"] = "center", visible_horizontal_margin_px: float = 0, labeler_margin_px: tuple[float, float] = (0, 0), @@ -98,12 +92,12 @@ def render(self, context: RenderContext) -> tuple[Image.Image, dict[str, float]] # edge vertical_offset_px: float = 0 - if self.mode == MarginsMode.PRINT: + if self.mode == "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: + elif self.mode == "preview": # add vertical margins to bitmap bitmap_height = payload_bitmap.height + self.labeler_vertical_margin_px * 2 vertical_offset_px = self.labeler_vertical_margin_px diff --git a/src/dymoprint/lib/render_engines/print_payload.py b/src/dymoprint/lib/render_engines/print_payload.py index e93ef70..4a9e12e 100644 --- a/src/dymoprint/lib/render_engines/print_payload.py +++ b/src/dymoprint/lib/render_engines/print_payload.py @@ -4,7 +4,7 @@ from PIL import Image -from dymoprint.lib.render_engines.margins import MarginsMode, MarginsRenderEngine +from dymoprint.lib.render_engines.margins import MarginsRenderEngine from dymoprint.lib.render_engines.render_context import RenderContext from dymoprint.lib.render_engines.render_engine import RenderEngine @@ -22,7 +22,7 @@ def __init__( super().__init__() self.render_engine = MarginsRenderEngine( render_engine=render_engine, - mode=MarginsMode.PRINT, + mode="print", justify=justify, visible_horizontal_margin_px=visible_horizontal_margin_px, labeler_margin_px=labeler_margin_px, diff --git a/src/dymoprint/lib/render_engines/print_preview.py b/src/dymoprint/lib/render_engines/print_preview.py index 0c2e615..5f76715 100644 --- a/src/dymoprint/lib/render_engines/print_preview.py +++ b/src/dymoprint/lib/render_engines/print_preview.py @@ -5,7 +5,7 @@ from darkdetect import isDark from PIL import Image, ImageColor, ImageDraw, ImageOps -from dymoprint.lib.render_engines.margins import MarginsMode, MarginsRenderEngine +from dymoprint.lib.render_engines.margins import MarginsRenderEngine from dymoprint.lib.render_engines.render_context import RenderContext from dymoprint.lib.render_engines.render_engine import RenderEngine from dymoprint.lib.utils import px_to_mm @@ -29,7 +29,7 @@ def __init__( super().__init__() self.render_engine = MarginsRenderEngine( render_engine=render_engine, - mode=MarginsMode.PREVIEW, + mode="preview", justify=justify, visible_horizontal_margin_px=visible_horizontal_margin_px, labeler_margin_px=labeler_margin_px, From ad2f6036ebc66ccb7313b4e6f384ee295e5014ac Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 24 Mar 2024 17:44:49 +0000 Subject: [PATCH 34/36] Fix lint issue --- src/dymoprint/gui/gui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dymoprint/gui/gui.py b/src/dymoprint/gui/gui.py index b0cdb80..ab83a81 100644 --- a/src/dymoprint/gui/gui.py +++ b/src/dymoprint/gui/gui.py @@ -1,6 +1,6 @@ import logging import sys -from typing import Optional +from typing import Literal, Optional from PIL import Image, ImageQt from PyQt6 import QtCore @@ -180,7 +180,7 @@ def init_layout(self): self.setLayout(self.window_layout) def update_params(self): - justify: str = self.justify.currentText() + justify: Literal["left", "center", "right"] = self.justify.currentText() horizontal_margin_mm: float = self.horizontal_margin_mm.value() min_label_width_mm: float = self.min_label_width_mm.value() tape_size_mm: int = self.tape_size_mm.currentData() From 907c6dd42c3b4d4b065feb67a11a3e6ee27a7486 Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 24 Mar 2024 17:46:37 +0000 Subject: [PATCH 35/36] Update pre-commit repos --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e3a53cc..993bb17 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: args: ['--fix=lf'] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.0 + rev: v0.3.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -31,7 +31,7 @@ repos: - id: check-json5 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.9.0 hooks: - id: mypy additional_dependencies: From e5ed45304cd2e7c5815056cf25e1719bca1b4f3b Mon Sep 17 00:00:00 2001 From: Tomer Shalev Date: Sun, 24 Mar 2024 17:49:30 +0000 Subject: [PATCH 36/36] Add github.vscode-github-actions extension --- .devcontainer/devcontainer.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ebe2c8e..de41d5e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,12 +19,13 @@ "customizations": { "vscode": { "extensions": [ + "charliermarsh.ruff", "eamodio.gitlens", + "github.vscode-github-actions", "ms-azuretools.vscode-docker", + "ms-python.mypy-type-checker", "tamasfe.even-better-toml", - "zhoufeng.pyqt-integration", - "charliermarsh.ruff", - "ms-python.mypy-type-checker" + "zhoufeng.pyqt-integration" ] } },