diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 02617523..ccde5422 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,6 +37,7 @@ repos: - id: mypy additional_dependencies: - types-pillow + - types-cffi - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.39.0 diff --git a/pyproject.toml b/pyproject.toml index 738c9755..badfab71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "pyusb", "PyQt6", "darkdetect", + "typer", ] classifiers = [ "Operating System :: POSIX :: Linux", @@ -82,13 +83,14 @@ commands = pip freeze labelle --version labelle --help - labelle --preview "single line" - labelle --preview-inverted "single line" - labelle --preview multiple lines - labelle --preview -qr "qr text" - labelle --preview -c code128 "bc txt" - labelle --preview -qr "qr text" qr caption - labelle --preview -c code128 "bc txt" barcode caption + labelle --output console "single line" + labelle --output console_inverted "inverted" + labelle --output console multiple lines + labelle --output console --barcode "Barcode" --barcode-type code128 + labelle --output console --barcode-with-text "Barcode" --barcode-type code128 Caption + labelle --output console --qr QR + labelle --output console --qr QR Caption + labelle --output console --picture ./labelle.png [testenv:{clean,build}] description = @@ -191,6 +193,11 @@ ignore = [ ] [tool.mypy] -exclude = ["_vendor"] check_untyped_defs = true install_types = true +mypy_path = "src/" +packages = ["labelle"] + +[[tool.mypy.overrides]] +module="labelle._vendor.*" +ignore_errors = true diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/labelle/cli/cli.py b/src/labelle/cli/cli.py index c6359bf6..9defb15b 100755 --- a/src/labelle/cli/cli.py +++ b/src/labelle/cli/cli.py @@ -5,26 +5,38 @@ # permitted in any medium without royalty provided the copyright notice and # this notice are preserved. # === END LICENSE STATEMENT === -import argparse import logging -import webbrowser -from tempfile import NamedTemporaryFile +from pathlib import Path +from typing import List, Optional -from PIL import Image, ImageOps +import typer +from rich.console import Console +from rich.table import Table +from typing_extensions import Annotated from labelle import __version__ from labelle.lib.constants import ( - AVAILABLE_BARCODES, + DEFAULT_BARCODE_TYPE, DEFAULT_MARGIN_PX, PIXELS_PER_MM, USE_QR, + BarcodeType, + Direction, + Output, e_qrcode, ) -from labelle.lib.devices.device_manager import DeviceManager +from labelle.lib.devices.device_manager import DeviceManager, DeviceManagerNoDevices from labelle.lib.devices.dymo_labeler import DymoLabeler from labelle.lib.env_config import is_verbose_env_vars -from labelle.lib.font_config import NoFontFound, get_available_fonts, get_font_path +from labelle.lib.font_config import ( + DefaultFontStyle, + FontStyle, + NoFontFound, + get_available_fonts, + get_font_path, +) from labelle.lib.logger import configure_logging, set_not_verbose +from labelle.lib.outputs import output_bitmap from labelle.lib.render_engines import ( BarcodeRenderEngine, BarcodeWithTextRenderEngine, @@ -34,175 +46,15 @@ PrintPreviewRenderEngine, QrRenderEngine, RenderContext, + RenderEngine, TestPatternRenderEngine, TextRenderEngine, ) -from labelle.lib.unicode_blocks import image_to_unicode -from labelle.lib.utils import system_run -from labelle.metadata import our_metadata LOG = logging.getLogger(__name__) -FLAG_TO_STYLE = { - "r": "regular", - "b": "bold", - "i": "italic", - "n": "narrow", -} - - -class CommandLineUsageError(Exception): - pass - -def parse_args(): - # check for any text specified on the command line - parser = argparse.ArgumentParser(description=our_metadata["Summary"]) - parser.add_argument( - "--version", action="version", version=f"%(prog)s {__version__}" - ) - parser.add_argument( - "text", - nargs="+", - help="Text Parameter, each parameter gives a new line", - type=str, - ) - parser.add_argument( - "-f", - "--frame-width-px", - action="count", - help="Draw frame around the text, more arguments for thicker frame", - ) - parser.add_argument( - "-s", - "--style", - choices=["r", "b", "i", "n"], - default="r", - help="Set fonts style (regular,bold,italic,narrow)", - ) - parser.add_argument( - "-a", - "--align", - choices=[ - "left", - "center", - "right", - ], - default="left", - help="Align multiline text (left,center,right)", - ) - parser.add_argument( - "--test-pattern", - type=int, - default=0, - help="Prints test pattern of a desired dot width", - ) - - length_options = parser.add_argument_group("Length options") - - length_options.add_argument( - "-l", - "--min-length", - type=int, - default=0, - help="Specify minimum label length in mm", - ) - length_options.add_argument( - "--max-length", - type=int, - default=None, - help="Specify maximum label length in mm, error if the label won't fit", - ) - length_options.add_argument( - "--fixed-length", - type=int, - default=None, - help="Specify fixed label length in mm, error if the label won't fit", - ) - - length_options.add_argument( - "-j", - "--justify", - choices=[ - "left", - "center", - "right", - ], - default="center", - help=( - "Justify content of label if label content is less than the " - "minimum or fixed length (left, center, right)" - ), - ) - parser.add_argument( - "-u", "--font", nargs="?", help='Set user font, overrides "-s" parameter' - ) - parser.add_argument( - "-n", - "--preview", - action="store_true", - help="Unicode preview of label, do not send to printer", - ) - parser.add_argument( - "--preview-inverted", - action="store_true", - help="Unicode preview of label, colors inverted, do not send to printer", - ) - parser.add_argument( - "--imagemagick", - action="store_true", - help="Preview label with Imagemagick, do not send to printer", - ) - parser.add_argument( - "--browser", - action="store_true", - help="Preview label in the browser, do not send to printer", - ) - parser.add_argument( - "-qr", action="store_true", help="Printing the first text parameter as QR-code" - ) - parser.add_argument( - "-c", - "--barcode", - choices=AVAILABLE_BARCODES, - default=False, - help="Printing the first text parameter as barcode", - ) - parser.add_argument( - "--barcode-text", - choices=AVAILABLE_BARCODES, - default=False, - help="Printing the first text parameter as barcode and text under it", - ) - parser.add_argument("-p", "--picture", help="Print the specified picture") - parser.add_argument( - "-m", - "--margin-px", - type=int, - default=DEFAULT_MARGIN_PX, - help=f"Margin in px (default is {DEFAULT_MARGIN_PX})", - ) - parser.add_argument( - "--scale", type=int, default=90, help="Scaling font factor, [0,10] [%%]" - ) - parser.add_argument( - "-t", - "--tape-size-mm", - type=int, - choices=[6, 9, 12, 19], - default=12, - help="Tape size: 6,9,12,19 mm, default=12mm", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Increase logging verbosity", - ) - return parser.parse_args() - - -def mm_to_payload_px(mm, margin): +def mm_to_payload_px(mm: float, margin: float): """Convert a length in mm to a number of pixels of payload. The print resolution is 7 pixels/mm, and margin is subtracted from each side. @@ -210,85 +62,261 @@ def mm_to_payload_px(mm, margin): return max(0, (mm * PIXELS_PER_MM) - margin * 2) -def run(): - args = parse_args() +def version_callback(value: bool): + if value: + typer.echo(f"Labelle: {__version__}") + raise typer.Exit() + + +def qr_callback(qr_content: str) -> str: + # check if barcode, qrcode or text should be printed, use frames only on text + if qr_content and not USE_QR: + raise typer.BadParameter( + "QR code cannot be used without QR support installed" + ) from e_qrcode + return qr_content + - if (not args.verbose) and (not is_verbose_env_vars()): +def get_device_manager() -> DeviceManager: + device_manager = DeviceManager() + try: + device_manager.scan() + except DeviceManagerNoDevices as e: + err_console = Console(stderr=True) + err_console.print(f"Error: {e}") + raise typer.Exit() from e + return device_manager + + +app = typer.Typer() + + +@app.command(hidden=True) +def list_devices(): + device_manager = get_device_manager() + console = Console() + headers = ["Manufacturer", "Product", "Serial Number", "USB"] + table = Table(*headers, show_header=True) + for device in device_manager.devices: + table.add_row( + device.manufacturer, device.product, device.serial_number, device.usb_id + ) + console.print(table) + raise typer.Exit() + + +@app.callback(invoke_without_command=True) +def default( + ctx: typer.Context, + version: Annotated[ + Optional[bool], + typer.Option( + "--version", + callback=version_callback, + is_eager=True, + help="Show application version", + ), + ] = None, + device_pattern: Annotated[ + Optional[List[str]], + typer.Option( + "--device", + help=( + "Select a particular device by filtering for a given substring " + "in the device's manufacturer, product or serial number" + ), + rich_help_panel="Device Configuration", + ), + ] = None, + text: Annotated[ + Optional[List[str]], + typer.Argument( + help="Text, each parameter gives a new line", + rich_help_panel="Elements", + ), + ] = None, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Increase logging verbosity") + ] = False, + style: Annotated[ + FontStyle, typer.Option(help="Set fonts style", rich_help_panel="Design") + ] = DefaultFontStyle, + frame_width_px: Annotated[ + Optional[int], + typer.Option( + help="Draw frame around the text, more arguments for thicker frame", + rich_help_panel="Design", + ), + ] = None, + align: Annotated[ + Direction, typer.Option(help="Align multiline text", rich_help_panel="Design") + ] = Direction.LEFT, + justify: Annotated[ + Direction, + typer.Option( + help="Justify content of label if label content is less than the minimum or" + " fixed length", + rich_help_panel="Design", + ), + ] = Direction.LEFT, + test_pattern: Annotated[ + Optional[int], + typer.Option(help="Prints test pattern of a desired dot width"), + ] = None, + min_length: Annotated[ + Optional[float], + typer.Option( + help="Minimum label length [mm]", rich_help_panel="Label Dimensions" + ), + ] = None, + max_length: Annotated[ + Optional[float], + typer.Option( + help="Maximum label length [mm], error if the label won't fit", + rich_help_panel="Label Dimensions", + ), + ] = None, + fixed_length: Annotated[ + Optional[float], + typer.Option( + help="Fixed label length [mm], error if the label won't fit", + rich_help_panel="Label Dimensions", + ), + ] = None, + output: Annotated[ + Output, + typer.Option(help="Destination of the label render"), + ] = Output.PRINTER, + font: Annotated[ + Optional[str], + typer.Option( + help="User font. Overrides --style parameter", rich_help_panel="Design" + ), + ] = None, + qr_content: Annotated[ + Optional[str], + typer.Option( + "--qr", callback=qr_callback, help="QR code", rich_help_panel="Elements" + ), + ] = None, + barcode_content: Annotated[ + Optional[str], + typer.Option("--barcode", help="Barcode", rich_help_panel="Elements"), + ] = None, + barcode_type: Annotated[ + Optional[BarcodeType], + typer.Option( + help="Barcode type", + show_default=DEFAULT_BARCODE_TYPE.value, + rich_help_panel="Elements", + ), + ] = None, + barcode_with_text_content: Annotated[ + Optional[str], + typer.Option( + "--barcode-with-text", help="Barcode with text", rich_help_panel="Elements" + ), + ] = None, + picture: Annotated[ + Optional[Path], typer.Option(help="Picture", rich_help_panel="Elements") + ] = None, + margin_px: Annotated[ + float, + typer.Option( + help="Horizontal margins [px]", rich_help_panel="Label Dimensions" + ), + ] = DEFAULT_MARGIN_PX, + font_scale: Annotated[ + float, + typer.Option(help="Scaling font factor, [0,100] [%]", rich_help_panel="Design"), + ] = 90, + tape_size_mm: Annotated[ + Optional[int], + typer.Option(help="Tape size [mm]", rich_help_panel="Device Configuration"), + ] = None, +): + if ctx.invoked_subcommand is not None: + return + + if (not 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: - font_path = get_font_path(font=args.font, style=style) + font_path = get_font_path(font=font, style=style) except NoFontFound as e: 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 - - labeltext = args.text + raise typer.BadParameter(msg) from None - # check if barcode, qrcode or text should be printed, use frames only on text - if args.qr and not USE_QR: - raise CommandLineUsageError( - "QR code cannot be used without QR support " "installed" - ) from e_qrcode + if barcode_type and not (barcode_content or barcode_with_text_content): + raise typer.BadParameter("Cannot specify barcode type without a barcode value") - if args.barcode and args.qr: - raise CommandLineUsageError( - "Can not print both QR and Barcode on the same " "label (yet)" + if barcode_with_text_content and barcode_content: + raise typer.BadParameter( + "Cannot specify both barcode with text and regular barcode" ) - if args.fixed_length is not None and ( - args.min_length != 0 or args.max_length is not None - ): - raise CommandLineUsageError( - "Cannot't specify min/max and fixed length at the " "same time" + if fixed_length is not None and (min_length != 0 or max_length is not None): + raise typer.BadParameter( + "Cannot specify min/max and fixed length at the same time" ) - if args.max_length is not None and args.max_length < args.min_length: - raise CommandLineUsageError("Maximum length is less than minimum length") + if min_length is None: + min_length = 0.0 + if min_length < 0: + raise typer.BadParameter("Minimum length must be non-negative number") + if max_length is not None: + if max_length <= 0: + raise typer.BadParameter("Maximum length must be positive number") + if max_length < min_length: + raise typer.BadParameter("Maximum length is less than minimum length") - render_engines = [] + render_engines: list[RenderEngine] = [] - if args.test_pattern: - render_engines.append(TestPatternRenderEngine(args.test_pattern)) + if test_pattern: + render_engines.append(TestPatternRenderEngine(test_pattern)) - if args.qr: - render_engines.append(QrRenderEngine(labeltext.pop(0))) + if qr_content: + render_engines.append(QrRenderEngine(qr_content)) - elif args.barcode: - render_engines.append(BarcodeRenderEngine(labeltext.pop(0), args.barcode)) - - elif args.barcode_text: + if barcode_with_text_content: render_engines.append( BarcodeWithTextRenderEngine( - labeltext.pop(0), args.barcode_text, font_path, args.frame_width_px + content=barcode_with_text_content, + barcode_type=barcode_type, + font_file_name=font_path, + frame_width_px=frame_width_px, ) ) - if labeltext: + if barcode_content: + render_engines.append( + BarcodeRenderEngine(content=barcode_content, barcode_type=barcode_type) + ) + + if text: render_engines.append( TextRenderEngine( - text_lines=labeltext, + text_lines=text, font_file_name=font_path, - frame_width_px=args.frame_width_px, - font_size_ratio=int(args.scale) / 100.0, - align=args.align, + frame_width_px=frame_width_px, + font_size_ratio=int(font_scale) / 100.0, + align=align, ) ) - if args.picture: - render_engines.append(PictureRenderEngine(args.picture)) + if picture: + render_engines.append(PictureRenderEngine(picture)) - if args.fixed_length is not None: - min_label_mm_len = args.fixed_length - max_label_mm_len = args.fixed_length + if fixed_length is None: + min_label_mm_len = min_length + max_label_mm_len = max_length else: - min_label_mm_len = args.min_length - max_label_mm_len = args.max_length + min_label_mm_len = fixed_length + max_label_mm_len = fixed_length - 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_px) @@ -296,7 +324,14 @@ def run(): else None ) - dymo_labeler = DymoLabeler(tape_size_mm=args.tape_size_mm) + if output == Output.PRINTER: + device_manager = get_device_manager() + device = device_manager.find_and_select_device(patterns=device_pattern) + device.setup() + else: + device = None + + dymo_labeler = DymoLabeler(tape_size_mm=tape_size_mm, device=device) render_engine = HorizontallyCombinedRenderEngine(render_engines) render_context = RenderContext( background_color="white", @@ -306,49 +341,34 @@ def run(): ) # print or show the label - if args.preview or args.preview_inverted or args.imagemagick or args.browser: - render = PrintPreviewRenderEngine( + render: RenderEngine + if output == Output.PRINTER: + render = PrintPayloadRenderEngine( render_engine=render_engine, - justify=args.justify, + justify=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: - label_rotated = bitmap.transpose(Image.ROTATE_270) - print(image_to_unicode(label_rotated, invert=args.preview_inverted)) - if args.imagemagick: - ImageOps.invert(bitmap).show() - if args.browser: - with NamedTemporaryFile(suffix=".png", delete=False) as fp: - inverted = ImageOps.invert(bitmap.convert("RGB")) - ImageOps.invert(inverted).save(fp) - webbrowser.open(f"file://{fp.name}") + bitmap, _ = render.render_with_meta(render_context) + dymo_labeler.print(bitmap) else: - render = PrintPayloadRenderEngine( + render = PrintPreviewRenderEngine( render_engine=render_engine, - justify=args.justify, + justify=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) - - device_manager = DeviceManager() - device = device_manager.scan() - device = device_manager.find_and_select_device() - device.setup() - dymo_labeler.print(bitmap) + bitmap = render.render(render_context) + output_bitmap(bitmap, output) def main(): configure_logging() - with system_run(): - run() + app() if __name__ == "__main__": diff --git a/src/labelle/gui/gui.py b/src/labelle/gui/gui.py index 00824e22..23ac24c0 100644 --- a/src/labelle/gui/gui.py +++ b/src/labelle/gui/gui.py @@ -1,246 +1,144 @@ import logging import sys -from typing import Literal, Optional +from typing import Optional -from PIL import Image, ImageQt +from PIL import Image from PyQt6 import QtCore -from PyQt6.QtCore import QCommandLineOption, QCommandLineParser, QSize, Qt, QTimer -from PyQt6.QtGui import QIcon, QPixmap -from PyQt6.QtWidgets import ( - QApplication, - QCheckBox, - QComboBox, - QGraphicsDropShadowEffect, - QHBoxLayout, - QLabel, - QPushButton, - QSpinBox, - QToolBar, - QVBoxLayout, - QWidget, -) +from PyQt6.QtCore import QCommandLineOption, QCommandLineParser +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget from labelle.gui.common import crash_msg_box +from labelle.gui.q_actions import QActions +from labelle.gui.q_device_selector import QDeviceSelector +from labelle.gui.q_labels_list import QLabelList +from labelle.gui.q_render import QRender +from labelle.gui.q_settings_toolbar import QSettingsToolbar, Settings from labelle.lib.constants import ICON_DIR -from labelle.lib.devices.device_manager import DeviceManager, DeviceManagerError -from labelle.lib.devices.dymo_labeler import ( - DymoLabeler, - DymoLabelerPrintError, -) +from labelle.lib.devices.device_manager import DeviceManager +from labelle.lib.devices.dymo_labeler import DymoLabeler, DymoLabelerPrintError from labelle.lib.env_config import is_verbose_env_vars from labelle.lib.logger import configure_logging, set_not_verbose from labelle.lib.render_engines import RenderContext from labelle.lib.utils import system_run -from .q_dymo_labels_list import QDymoLabelList - LOG = logging.getLogger(__name__) class LabelleWindow(QWidget): - label_bitmap_to_print: Optional[Image.Image] - device_manager: DeviceManager - dymo_labeler: DymoLabeler - render_context: RenderContext - tape_size_mm: QComboBox + _label_bitmap_to_print: Optional[Image.Image] + _device_manager: DeviceManager + _dymo_labeler: DymoLabeler + _render_context: RenderContext + _render_widget: QWidget def __init__(self): super().__init__() - self.label_bitmap_to_print = None - self.detected_device = None + self._label_bitmap_to_print = None + self._detected_device = None - self.window_layout = QVBoxLayout() + self._window_layout = QVBoxLayout() - self.label_list = QDymoLabelList() - self.label_render = QLabel() - self.error_label = QLabel() - self.print_button = QPushButton() - self.horizontal_margin_mm = QSpinBox() - self.tape_size_mm = QComboBox() - self.foreground_color = QComboBox() - self.background_color = QComboBox() - self.min_label_width_mm = QSpinBox() - self.justify = QComboBox() - self.preview_show_margins = QCheckBox() - self.last_error = None + self._device_selector = QDeviceSelector(self) + self._label_list = QLabelList() + self._render = QRender(self) + self._actions = QActions(self) + self._settings_toolbar = QSettingsToolbar(self) + self._render_widget = QWidget(self) - self.init_elements() - self.init_timers() - self.init_connections() - self.init_layout() + self._init_elements() + self._init_connections() + self._init_layout() - self.label_list.render_label() + self._device_selector.repopulate() + self._settings_toolbar.on_settings_changed() + self._label_list.populate() + self._label_list.render_label() - def init_elements(self): + def _init_elements(self): self.setWindowTitle("Labelle GUI") self.setWindowIcon(QIcon(str(ICON_DIR / "logo_small.png"))) self.setGeometry(200, 200, 1100, 400) - printer_icon = QIcon.fromTheme("printer") - self.print_button.setIcon(printer_icon) - self.print_button.setFixedSize(64, 64) - self.print_button.setIconSize(QSize(48, 48)) - - shadow = QGraphicsDropShadowEffect() - shadow.setBlurRadius(15) - self.label_render.setGraphicsEffect(shadow) - self.device_manager = DeviceManager() - 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._device_manager = DeviceManager() + self._dymo_labeler = DymoLabeler() + self._settings_toolbar.update_labeler_context( + supported_tape_sizes=self._dymo_labeler.SUPPORTED_TAPE_SIZES_MM, + installed_tape_size=self._dymo_labeler.tape_size_mm, + minimum_horizontal_margin_mm=self._dymo_labeler.minimum_horizontal_margin_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) - - 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( - ["black", "white", "yellow", "blue", "red", "green"] + def _init_connections(self): + self._label_list.renderPrintPreviewSignal.connect(self._update_preview_render) + self._label_list.renderPrintPayloadSignal.connect(self._update_print_render) + self._actions.print_label_signal.connect(self._on_print_label) + self._settings_toolbar.settings_changed_signal.connect( + self._on_settings_changed ) - self.background_color.addItems( - ["white", "black", "yellow", "blue", "red", "green"] + self._device_selector.selectedDeviceChangedSignal.connect( + self._on_device_selected ) - self.preview_show_margins.setChecked(False) - - self.update_params() - self.label_list.populate() - - def init_timers(self): - self.refresh_devices() - self.status_time = QTimer() - self.status_time.timeout.connect(self.refresh_devices) - self.status_time.start(2000) - - def init_connections(self): - 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_width_mm.valueChanged.connect(self.update_params) - self.justify.currentTextChanged.connect(self.update_params) - 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) - self.preview_show_margins.stateChanged.connect(self.update_params) - - def init_layout(self): - settings_widget = QToolBar(self) - settings_widget.addWidget(QLabel("Margin [mm]:")) - settings_widget.addWidget(self.horizontal_margin_mm) - settings_widget.addSeparator() - settings_widget.addWidget(QLabel("Tape Size [mm]:")) - settings_widget.addWidget(self.tape_size_mm) - settings_widget.addSeparator() - 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) - settings_widget.addSeparator() - settings_widget.addWidget(QLabel("Tape Colors: ")) - 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) - print_render_widget = QWidget(render_widget) + def _init_layout(self): + self._actions.setParent(self._render_widget) + self._render.setParent(self._render_widget) - render_layout = QHBoxLayout(render_widget) - label_render_layout = QVBoxLayout(label_render_widget) - print_render_layout = QVBoxLayout(print_render_widget) - label_render_layout.addWidget( - self.label_render, alignment=QtCore.Qt.AlignmentFlag.AlignCenter - ) - print_render_layout.addWidget( - self.print_button, alignment=QtCore.Qt.AlignmentFlag.AlignRight - ) - print_render_layout.addWidget( - self.error_label, alignment=QtCore.Qt.AlignmentFlag.AlignCenter - ) + render_layout = QHBoxLayout(self._render_widget) render_layout.addWidget( - label_render_widget, alignment=QtCore.Qt.AlignmentFlag.AlignRight + self._render, alignment=QtCore.Qt.AlignmentFlag.AlignRight ) render_layout.addWidget( - print_render_widget, alignment=QtCore.Qt.AlignmentFlag.AlignRight + self._actions, alignment=QtCore.Qt.AlignmentFlag.AlignRight ) - self.window_layout.addWidget(settings_widget) - self.window_layout.addWidget(self.label_list) - self.window_layout.addWidget(render_widget) - self.setLayout(self.window_layout) - - def update_params(self): - 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() + self._window_layout.addWidget(self._device_selector) + self._window_layout.addWidget(self._settings_toolbar) + self._window_layout.addWidget(self._label_list) + self._window_layout.addWidget(self._render_widget) + self.setLayout(self._window_layout) - self.dymo_labeler.tape_size_mm = tape_size_mm + def _on_settings_changed(self, settings: Settings): + assert self._dymo_labeler is not None + self._dymo_labeler.tape_size_mm = settings.tape_size_mm # Update render context - 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._render_context = RenderContext( + foreground_color=settings.foreground_color, + background_color=settings.background_color, + height_px=self._dymo_labeler.height_px, + preview_show_margins=settings.preview_show_margins, ) - - 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, - justify=justify, + self._label_list.update_params( + dymo_labeler=self._dymo_labeler, + h_margin_mm=settings.horizontal_margin_mm, + min_label_width_mm=settings.min_label_width_mm, + render_context=self._render_context, + justify=settings.justify, ) - def update_preview_render(self, preview_bitmap): - qim = ImageQt.ImageQt(preview_bitmap) - q_image = QPixmap.fromImage(qim) - self.label_render.setPixmap(q_image) - self.label_render.adjustSize() + is_ready = self._dymo_labeler.is_ready + self._settings_toolbar.setEnabled(is_ready) + self._label_list.setEnabled(is_ready) + self._render_widget.setEnabled(is_ready) + + def _update_preview_render(self, preview_bitmap): + self._render.update_preview_render(preview_bitmap) - def update_print_render(self, label_bitmap_to_print): - self.label_bitmap_to_print = label_bitmap_to_print + def _update_print_render(self, label_bitmap_to_print): + self._label_bitmap_to_print = label_bitmap_to_print - def print_label(self): + def _on_print_label(self): try: - if self.label_bitmap_to_print 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_to_print) + assert self._dymo_labeler is not None + self._dymo_labeler.print(self._label_bitmap_to_print) except DymoLabelerPrintError as err: crash_msg_box(self, "Printing Failed!", err) - def refresh_devices(self): - self.error_label.setText("") - try: - self.device_manager.scan() - device = self.device_manager.find_and_select_device() - device.setup() - self.dymo_labeler.device = device - is_enabled = True - except DeviceManagerError as e: - error = str(e) - if self.last_error != error: - self.last_error = error - LOG.error(error) - self.error_label.setText(error) - is_enabled = False - self.print_button.setEnabled(is_enabled) - self.print_button.setCursor( - Qt.CursorShape.ArrowCursor if is_enabled else Qt.CursorShape.ForbiddenCursor - ) + def _on_device_selected(self): + self._dymo_labeler.device = self._device_selector.selected_device + self._settings_toolbar.on_settings_changed() def parse(app): diff --git a/src/labelle/gui/q_actions.py b/src/labelle/gui/q_actions.py new file mode 100644 index 00000000..30c6796d --- /dev/null +++ b/src/labelle/gui/q_actions.py @@ -0,0 +1,65 @@ +import logging +from typing import Optional + +from PyQt6 import QtCore +from PyQt6.QtCore import QSize, Qt +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget + +LOG = logging.getLogger(__name__) + + +class QActions(QWidget): + _error_label: QLabel + _is_enabled: bool + _last_error: Optional[str] + _print_button: QPushButton + + print_label_signal = QtCore.pyqtSignal(name="printLabel") + + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(parent) + self._error_label = QLabel() + self._is_enabled = False + self._last_error = None + self._print_button = QPushButton() + + self._init_elements() + self._init_connections() + self._init_layout() + + def _init_elements(self): + printer_icon = QIcon.fromTheme("printer") + self._print_button.setIcon(printer_icon) + self._print_button.setFixedSize(64, 64) + self._print_button.setIconSize(QSize(48, 48)) + + def _init_connections(self): + self._print_button.clicked.connect(self._on_print_label) + + def _init_layout(self): + layout = QVBoxLayout(self) + layout.addWidget( + self._print_button, alignment=QtCore.Qt.AlignmentFlag.AlignRight + ) + layout.addWidget( + self._error_label, alignment=QtCore.Qt.AlignmentFlag.AlignCenter + ) + + def _on_print_label(self): + self.print_label_signal.emit() + + def clear_error(self): + self._error_label.setText("") + self._last_error = None + self._print_button.setEnabled(True) + self._print_button.setCursor(Qt.CursorShape.ArrowCursor) + + def set_error(self, error: str): + if self._last_error == error: + return + self._last_error = error + self._error_label.setText(error) + LOG.error(error) + self._print_button.setDisabled(True) + self._print_button.setCursor(Qt.CursorShape.ForbiddenCursor) diff --git a/src/labelle/gui/q_device_selector.py b/src/labelle/gui/q_device_selector.py new file mode 100644 index 00000000..75c6d940 --- /dev/null +++ b/src/labelle/gui/q_device_selector.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import logging + +from PyQt6 import QtCore +from PyQt6.QtWidgets import ( + QComboBox, + QLabel, + QToolBar, +) + +from labelle.lib.devices.online_device_manager import OnlineDeviceManager +from labelle.lib.devices.usb_device import UsbDevice + +LOG = logging.getLogger(__name__) + + +class QDeviceSelector(QToolBar): + _device_manager: OnlineDeviceManager + _selected_device: UsbDevice | None + + selectedDeviceChangedSignal = QtCore.pyqtSignal(name="selectedDeviceChangedSignal") + + def __init__(self, parent=None): + super().__init__(parent) + self._devices = QComboBox(self) + self._error_label = QLabel(self) + + self._selected_device = None + self._action_devices = None + self._action_error_label = None + + self._init_elements() + self._init_connections() + self._init_layout() + + self._last_scan_error_changed() + self.selectedDeviceChangedSignal.emit() + + def _init_elements(self): + self.device_manager = OnlineDeviceManager() + + def _init_connections(self): + self.device_manager.devices_changed_signal.connect(self.repopulate) + self.device_manager.last_scan_error_changed_signal.connect( + self._last_scan_error_changed + ) + self._devices.currentIndexChanged.connect(self._index_changed) + + def repopulate(self): + old_hashes = {device.hash for device in self.device_manager.devices} + self._devices.clear() + for idx, device in enumerate(self.device_manager.devices): + self._devices.insertItem(idx, device.product, device.hash) + if ( + self._selected_device is not None + and self._selected_device.hash == device.hash + ): + self._devices.setCurrentIndex(idx) + valid = len(self.device_manager.devices) > 0 + if valid: + if self._selected_device is None: + self._index_changed(0) + else: + self._index_changed(-1) + assert self._action_devices is not None + assert self._action_error_label is not None + self._action_devices.setVisible(valid) + self._action_error_label.setVisible(not valid) + new_hashes = {device.hash for device in self.device_manager.devices} + if new_hashes != old_hashes: + self.selectedDeviceChangedSignal.emit() + + def _index_changed(self, index): + if index >= 0: + self._selected_device = self.device_manager.devices[index] + else: + self._selected_device = None + self.selectedDeviceChangedSignal.emit() + + def _last_scan_error_changed(self): + last_scan_error = self.device_manager.last_scan_error or "" + self._error_label.setText(str(last_scan_error)) + self.repopulate() + + def _init_layout(self): + self._devices.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) + self._action_devices = self.addWidget(self._devices) + self._action_error_label = self.addWidget(self._error_label) + + @property + def selected_device(self) -> UsbDevice | None: + device = None + if self._devices.currentIndex() >= 0: + device = self.device_manager.devices[self._devices.currentIndex()] + return device diff --git a/src/labelle/gui/q_dymo_label_widgets.py b/src/labelle/gui/q_label_widgets.py similarity index 96% rename from src/labelle/gui/q_dymo_label_widgets.py rename to src/labelle/gui/q_label_widgets.py index 3021b0a7..dbc38350 100644 --- a/src/labelle/gui/q_dymo_label_widgets.py +++ b/src/labelle/gui/q_label_widgets.py @@ -17,7 +17,7 @@ ) from labelle.gui.common import crash_msg_box -from labelle.lib.constants import AVAILABLE_BARCODES, ICON_DIR +from labelle.lib.constants import ICON_DIR, BarcodeType, Direction from labelle.lib.env_config import is_dev_mode_no_margins from labelle.lib.font_config import get_available_fonts from labelle.lib.render_engines import ( @@ -29,6 +29,7 @@ PictureRenderEngine, QrRenderEngine, RenderContext, + RenderEngine, TextRenderEngine, ) from labelle.lib.render_engines.render_engine import RenderEngineException @@ -45,7 +46,7 @@ def __init__(self): self.setCurrentText("Carlito-Regular") -class BaseDymoLabelWidget(QWidget): +class BaseLabelWidget(QWidget): """A base class for creating Dymo label widgets. Signals: @@ -84,7 +85,7 @@ def render_engine(self): return EmptyRenderEngine() -class TextDymoLabelWidget(BaseDymoLabelWidget): +class TextDymoLabelWidget(BaseLabelWidget): """A widget for rendering text on a Dymo label. Args: @@ -169,8 +170,7 @@ def render_engine_impl(self): TextRenderEngine: The rendered engine. """ - selected_alignment = self.align.currentText() - assert selected_alignment in ("left", "center", "right") + selected_alignment = Direction(self.align.currentText()) return TextRenderEngine( text_lines=self.label.toPlainText().splitlines(), font_file_name=self.font_style.currentData(), @@ -180,7 +180,7 @@ def render_engine_impl(self): ) -class QrDymoLabelWidget(BaseDymoLabelWidget): +class QrDymoLabelWidget(BaseLabelWidget): """A widget for rendering QR codes on Dymo labels. Args: @@ -229,7 +229,7 @@ def render_engine_impl(self): return EmptyRenderEngine() -class BarcodeDymoLabelWidget(BaseDymoLabelWidget): +class BarcodeDymoLabelWidget(BaseLabelWidget): """A widget for rendering barcode labels using the Dymo label printer. Args: @@ -305,7 +305,7 @@ def __init__(self, render_context, parent=None): self.barcode_type_label = QLabel("Type:") self.barcode_type = QComboBox() - self.barcode_type.addItems(AVAILABLE_BARCODES) + self.barcode_type.addItems(bt.value for bt in BarcodeType) # Checkbox for toggling text fields self.show_text_label = QLabel("Text:") @@ -372,6 +372,7 @@ def render_engine_impl(self): BarcodeWithTextRenderEngine). """ + render_engine: RenderEngine if self.show_text_checkbox.isChecked(): render_engine = BarcodeWithTextRenderEngine( content=self.label.text(), @@ -389,7 +390,7 @@ def render_engine_impl(self): return render_engine -class ImageDymoLabelWidget(BaseDymoLabelWidget): +class ImageDymoLabelWidget(BaseLabelWidget): """A widget for rendering image-based Dymo labels. Args: diff --git a/src/labelle/gui/q_dymo_labels_list.py b/src/labelle/gui/q_labels_list.py similarity index 92% rename from src/labelle/gui/q_dymo_labels_list.py rename to src/labelle/gui/q_labels_list.py index 2c883a89..d0faabb5 100644 --- a/src/labelle/gui/q_dymo_labels_list.py +++ b/src/labelle/gui/q_labels_list.py @@ -1,5 +1,5 @@ import logging -from typing import Literal, Optional +from typing import Optional from PIL import Image from PyQt6 import QtCore @@ -7,13 +7,14 @@ from PyQt6.QtWidgets import QAbstractItemView, QListWidget, QListWidgetItem, QMenu from labelle.gui.common import crash_msg_box -from labelle.gui.q_dymo_label_widgets import ( +from labelle.gui.q_label_widgets import ( BarcodeDymoLabelWidget, EmptyRenderEngine, ImageDymoLabelWidget, QrDymoLabelWidget, TextDymoLabelWidget, ) +from labelle.lib.constants import Direction from labelle.lib.devices.dymo_labeler import DymoLabeler from labelle.lib.render_engines import ( HorizontallyCombinedRenderEngine, @@ -29,7 +30,7 @@ LOG = logging.getLogger(__name__) -class QDymoLabelList(QListWidget): +class QLabelList(QListWidget): """A custom QListWidget for displaying and managing Dymo label widgets. Args: @@ -73,22 +74,23 @@ class QDymoLabelList(QListWidget): ) render_context: Optional[RenderContext] itemWidget: TextDymoLabelWidget - dymo_labeler: DymoLabeler + dymo_labeler: Optional[DymoLabeler] h_margin_mm: float min_label_width_mm: Optional[float] - justify: str + justify: Direction def __init__(self, parent=None): super().__init__(parent) self.dymo_labeler = None - self.margin_px = None + self.h_margin_mm = 0.0 self.min_label_width_mm = None - self.justify = "center" + self.justify = Direction.CENTER self.render_context = None self.setAlternatingRowColors(True) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) def populate(self): + assert self.render_context is not None for item_widget in [TextDymoLabelWidget(self.render_context)]: item = QListWidgetItem(self) item.setSizeHint(item_widget.sizeHint()) @@ -113,7 +115,7 @@ def update_params( h_margin_mm: float, min_label_width_mm: float, render_context: RenderContext, - justify: Literal["left", "center", "right"] = "center", + justify: Direction = Direction.CENTER, ): """Update the render context used for rendering the label. @@ -148,6 +150,8 @@ def _payload_render_engine(self): return HorizontallyCombinedRenderEngine(render_engines=render_engines) def render_preview(self): + assert self.dymo_labeler is not None + assert self.render_context is not None render_engine = PrintPreviewRenderEngine( render_engine=self._payload_render_engine, justify=self.justify, @@ -165,6 +169,8 @@ def render_preview(self): self.renderPrintPreviewSignal.emit(bitmap) def render_print(self): + assert self.dymo_labeler is not None + assert self.render_context is not None render_engine = PrintPayloadRenderEngine( render_engine=self._payload_render_engine, justify=self.justify, @@ -174,7 +180,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_with_meta(self.render_context) except RenderEngineException as err: crash_msg_box(self, "Render Engine Failed!", err) bitmap = EmptyRenderEngine().render(self.render_context) @@ -193,6 +199,7 @@ def contextMenuEvent(self, event): event (QContextMenuEvent): The context menu event. """ + assert self.render_context is not None contextMenu = QMenu(self) add_text: Optional[QAction] = contextMenu.addAction("Add Text") add_qr: Optional[QAction] = contextMenu.addAction("Add QR") diff --git a/src/labelle/gui/q_render.py b/src/labelle/gui/q_render.py new file mode 100644 index 00000000..aca862dc --- /dev/null +++ b/src/labelle/gui/q_render.py @@ -0,0 +1,25 @@ +import logging +from typing import Optional + +from PIL import ImageQt +from PyQt6.QtGui import QPixmap +from PyQt6.QtWidgets import QGraphicsDropShadowEffect, QLabel, QWidget + +LOG = logging.getLogger(__name__) + + +class QRender(QLabel): + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(parent) + self._init_elements() + + def _init_elements(self): + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(15) + self.setGraphicsEffect(shadow) + + def update_preview_render(self, preview_bitmap): + qim = ImageQt.ImageQt(preview_bitmap) + q_image = QPixmap.fromImage(qim) + self.setPixmap(q_image) + self.adjustSize() diff --git a/src/labelle/gui/q_settings_toolbar.py b/src/labelle/gui/q_settings_toolbar.py new file mode 100644 index 00000000..fb7cb504 --- /dev/null +++ b/src/labelle/gui/q_settings_toolbar.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from PyQt6 import QtCore +from PyQt6.QtWidgets import QCheckBox, QComboBox, QLabel, QSpinBox, QToolBar, QWidget + +from labelle.lib.constants import Direction + +LOG = logging.getLogger(__name__) + + +FOREGROUND_COLOR__VALUES = ["black", "white", "yellow", "blue", "red", "green"] +BACKGROUND_COLOR__VALUES = ["white", "black", "yellow", "blue", "red", "green"] +HORIZONTAL_MARGIN_MM__MAX_VALUE = 100 +MIN_LABEL_WIDTH_MM__MIN_VALUE = 300 +PREVIEW_SHOW_MARGINS__DEFAULT_VALUE = False + + +@dataclass +class Settings: + background_color: str + foreground_color: str + horizontal_margin_mm: float + justify: Direction + min_label_width_mm: float + preview_show_margins: bool + tape_size_mm: int + + +class QSettingsToolbar(QToolBar): + settings_changed_signal = QtCore.pyqtSignal(Settings, name="settingsChangedSignal") + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._background_color = QComboBox() + self._foreground_color = QComboBox() + self._horizontal_margin_mm = QSpinBox() + self._justify = QComboBox() + self._min_label_width_mm = QSpinBox() + self._preview_show_margins = QCheckBox() + self._tape_size_mm = QComboBox() + + self._init_elements() + self._init_connections() + self._init_layout() + self.on_settings_changed() + + def _init_elements(self): + self._horizontal_margin_mm.setMaximum(HORIZONTAL_MARGIN_MM__MAX_VALUE) + self._min_label_width_mm.setMaximum(MIN_LABEL_WIDTH_MM__MIN_VALUE) + self._justify.addItems(d.value for d in Direction) + self._foreground_color.addItems(FOREGROUND_COLOR__VALUES) + self._background_color.addItems(BACKGROUND_COLOR__VALUES) + self._preview_show_margins.setChecked(PREVIEW_SHOW_MARGINS__DEFAULT_VALUE) + + def update_labeler_context( + self, + supported_tape_sizes: tuple[int, ...], + installed_tape_size: int, + minimum_horizontal_margin_mm: float, + ): + for tape_size_mm in supported_tape_sizes: + self._tape_size_mm.addItem(str(tape_size_mm), tape_size_mm) + tape_size_index = supported_tape_sizes.index(installed_tape_size) + self._tape_size_mm.setCurrentIndex(tape_size_index) + + h_margins_mm = round(minimum_horizontal_margin_mm) + self._horizontal_margin_mm.setMinimum(h_margins_mm) + if not self._horizontal_margin_mm.value(): + self._horizontal_margin_mm.setValue(h_margins_mm) + self._min_label_width_mm.setMinimum(h_margins_mm * 2) + + def _init_connections(self): + self._background_color.currentTextChanged.connect(self.on_settings_changed) + self._foreground_color.currentTextChanged.connect(self.on_settings_changed) + self._horizontal_margin_mm.valueChanged.connect(self.on_settings_changed) + self._justify.currentTextChanged.connect(self.on_settings_changed) + self._min_label_width_mm.valueChanged.connect(self.on_settings_changed) + self._preview_show_margins.stateChanged.connect(self.on_settings_changed) + self._tape_size_mm.currentTextChanged.connect(self.on_settings_changed) + + def _init_layout(self): + self.addWidget(QLabel("Margin [mm]:")) + self.addWidget(self._horizontal_margin_mm) + self.addSeparator() + self.addWidget(QLabel("Tape Size [mm]:")) + self.addWidget(self._tape_size_mm) + self.addSeparator() + self.addWidget(QLabel("Min Label Length [mm]:")) + self.addWidget(self._min_label_width_mm) + self.addSeparator() + self.addWidget(QLabel("Justify:")) + self.addWidget(self._justify) + self.addSeparator() + self.addWidget(QLabel("Tape Colors: ")) + self.addWidget(self._foreground_color) + self.addWidget(QLabel(" on ")) + self.addWidget(self._background_color) + self.addWidget(QLabel("Show margins:")) + self.addWidget(self._preview_show_margins) + + @property + def settings(self): + return Settings( + background_color=self._background_color.currentText(), + foreground_color=self._foreground_color.currentText(), + horizontal_margin_mm=self._horizontal_margin_mm.value(), + justify=Direction(self._justify.currentText()), + min_label_width_mm=self._min_label_width_mm.value(), + preview_show_margins=self._preview_show_margins.isChecked(), + tape_size_mm=self._tape_size_mm.currentData(), + ) + + def on_settings_changed(self): + self.settings_changed_signal.emit(self.settings) diff --git a/src/labelle/lib/constants.py b/src/labelle/lib/constants.py index f66245fb..37748c97 100755 --- a/src/labelle/lib/constants.py +++ b/src/labelle/lib/constants.py @@ -13,7 +13,7 @@ # either sysfs is unavailable or unusable by this script for some reason. # Please beware that DEV_NODE must be set to None when not used, else you will # be bitten by the NameError exception. - +from enum import Enum from pathlib import Path import labelle.resources.fonts @@ -69,20 +69,44 @@ ICON_DIR = Path(labelle.resources.icons.__file__).parent -AVAILABLE_BARCODES = [ - "code39", - "code128", - "ean", - "ean13", - "ean8", - "gs1", - "gtin", - "isbn", - "isbn10", - "isbn13", - "issn", - "jan", - "pzn", - "upc", - "upca", -] + +class BarcodeType(str, Enum): + CODE128 = "code128" + CODE39 = "code39" + CODEBAR = "codebar" + EAN = "ean" + EAN13 = "ean13" + EAN13_GUARD = "ean13-guard" + EAN14 = "ean14" + EAN8 = "ean8" + EAN8_GUARD = "ean8-guard" + GS1 = "gs1" + GS1_128 = "gs1_128" + GTIN = "gtin" + ISBN = "isbn" + ISBN10 = "isbn10" + ISBN13 = "isbn13" + ISSN = "issn" + ITF = "itf" + JAN = "jan" + NW_7 = "nw-7" + PZN = "pzn" + UPC = "upc" + UPCA = "upca" + + +DEFAULT_BARCODE_TYPE = BarcodeType.CODE128 + + +class Direction(str, Enum): + LEFT = "left" + CENTER = "center" + RIGHT = "right" + + +class Output(str, Enum): + PRINTER = "printer" + CONSOLE = "console" + CONSOLE_INVERTED = "console_inverted" + BROWSER = "browser" + IMAGEMAGICK = "imagemagick" diff --git a/src/labelle/lib/devices/device_manager.py b/src/labelle/lib/devices/device_manager.py index 52f932c0..313052cb 100644 --- a/src/labelle/lib/devices/device_manager.py +++ b/src/labelle/lib/devices/device_manager.py @@ -18,24 +18,26 @@ class DeviceManagerError(RuntimeError): pass +class DeviceManagerNoDevices(DeviceManagerError): + pass + + class DeviceManager: _devices: dict[str, UsbDevice] - last_scan_error: DeviceManagerError | None def __init__(self): self._devices = {} - try: - self.scan() - self.last_scan_error = None - except DeviceManagerError as e: - self.last_scan_error = e - def scan(self): + def scan(self) -> bool: prev = self._devices try: cur = {dev.hash: dev for dev in UsbDevice.supported_devices() if dev.hash} except POSSIBLE_USB_ERRORS as e: + self._devices.clear() raise DeviceManagerError(f"Failed scanning devices: {e}") from e + if len(cur) == 0: + self._devices.clear() + raise DeviceManagerNoDevices("No supported devices found") prev_set = set(prev) cur_set = set(cur) @@ -45,6 +47,9 @@ def scan(self): for dev in cur_set - prev_set: self._devices[dev] = cur[dev] + changed = prev_set != cur_set + return changed + @property def devices(self) -> list[UsbDevice]: try: @@ -52,12 +57,23 @@ def devices(self) -> list[UsbDevice]: except POSSIBLE_USB_ERRORS: return [] - def find_and_select_device(self) -> UsbDevice: - devices = [device for device in self.devices if device.is_supported] + def matching_devices(self, patterns: list[str] | None) -> list[UsbDevice]: + try: + matching = filter( + lambda dev: dev.is_match(patterns), self._devices.values() + ) + return sorted(matching, key=lambda dev: dev.hash) + except POSSIBLE_USB_ERRORS: + return [] + + def find_and_select_device(self, patterns: list[str] | None = None) -> UsbDevice: + devices = [ + device for device in self.matching_devices(patterns) if device.is_supported + ] if len(devices) == 0: - raise DeviceManagerError("No devices found") + raise DeviceManagerError("No matching devices found") if len(devices) > 1: - LOG.debug("Found multiple Dymo devices. Using first device") + LOG.debug("Found multiple matching Dymo devices. Using first device") else: LOG.debug("Found single device") for dev in devices: diff --git a/src/labelle/lib/devices/dymo_labeler.py b/src/labelle/lib/devices/dymo_labeler.py index 7a24e871..31d2f390 100755 --- a/src/labelle/lib/devices/dymo_labeler.py +++ b/src/labelle/lib/devices/dymo_labeler.py @@ -235,7 +235,7 @@ def _raw_print_label(self, lines: list[list[int]]): class DymoLabeler: - _device: UsbDevice + _device: UsbDevice | None tape_size_mm: int LABELER_DISTANCE_BETWEEN_PRINT_HEAD_AND_CUTTER_MM = 8.1 @@ -245,22 +245,25 @@ class DymoLabeler: def __init__( self, - tape_size_mm: int = DEFAULT_TAPE_SIZE_MM, + tape_size_mm: int | None = None, + device: UsbDevice | None = None, ): + if tape_size_mm is None: + tape_size_mm = self.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 + self._device = device @property def height_px(self): return DymoLabelerFunctions.height_px(self.tape_size_mm) @property - def _functions(self): + def _functions(self) -> DymoLabelerFunctions: assert self._device is not None return DymoLabelerFunctions( devout=self._device.devout, @@ -284,13 +287,23 @@ def labeler_margin_px(self) -> tuple[float, float]: ) @property - def device(self) -> UsbDevice: + def device(self) -> UsbDevice | None: return self._device @device.setter - def device(self, device: UsbDevice): + def device(self, device: UsbDevice | None): + try: + if device: + device.setup() + except UsbDeviceError as e: + device = None + LOG.error(e) self._device = device + @property + def is_ready(self) -> bool: + return self.device is not None + def print( self, bitmap: Image.Image, @@ -303,7 +316,7 @@ def print( # Convert the image to the proper matrix for the dymo labeler object so that # rows span the width of the label, and the first row corresponds to the left # edge of the label. - rotated_bitmap = bitmap.transpose(Image.ROTATE_270) + rotated_bitmap = bitmap.transpose(Image.Transpose.ROTATE_270) # Convert the image to raw bytes. Pixels along rows are chunked into groups of # 8 pixels, and subsequent rows are concatenated. @@ -330,7 +343,8 @@ def print( LOG.debug("Printing label..") self._functions.print_label(label_matrix) LOG.debug("Done printing.") - self._device.dispose() + if self._device is not None: + self._device.dispose() LOG.debug("Cleaned up.") except POSSIBLE_USB_ERRORS as e: raise DymoLabelerPrintError(str(e)) from e diff --git a/src/labelle/lib/devices/online_device_manager.py b/src/labelle/lib/devices/online_device_manager.py new file mode 100644 index 00000000..1948bb39 --- /dev/null +++ b/src/labelle/lib/devices/online_device_manager.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import logging + +from PyQt6 import QtCore +from PyQt6.QtCore import QTimer +from PyQt6.QtWidgets import QWidget +from usb.core import NoBackendError, USBError + +from labelle.lib.devices.device_manager import DeviceManager, DeviceManagerError +from labelle.lib.devices.usb_device import UsbDevice + +LOG = logging.getLogger(__name__) +POSSIBLE_USB_ERRORS = (NoBackendError, USBError) + + +class OnlineDeviceManager(QWidget): + _last_scan_error: DeviceManagerError | None + _status_time: QTimer + _device_manager: DeviceManager + last_scan_error_changed_signal = QtCore.pyqtSignal( + name="lastScanErrorChangedSignal" + ) + devices_changed_signal = QtCore.pyqtSignal(name="devicesChangedSignal") + + def __init__(self): + super().__init__() + self._device_manager = DeviceManager() + self._last_scan_error = None + self._init_timers() + + def _refresh_devices(self): + prev = self._last_scan_error + try: + changed = self._device_manager.scan() + self._last_scan_error = None + if changed: + self.devices_changed_signal.emit() + except DeviceManagerError as e: + self._last_scan_error = e + + if str(prev) != str(self._last_scan_error): + self.devices_changed_signal.emit() + self.last_scan_error_changed_signal.emit() + + def _init_timers(self): + self._status_time = QTimer() + self._status_time.timeout.connect(self._refresh_devices) + self._status_time.start(2000) + self._refresh_devices() + + @property + def last_scan_error(self) -> DeviceManagerError | None: + return self._last_scan_error + + @property + def devices(self) -> list[UsbDevice]: + return self._device_manager.devices diff --git a/src/labelle/lib/devices/usb_device.py b/src/labelle/lib/devices/usb_device.py index 4fdac2cf..9fdbcf17 100644 --- a/src/labelle/lib/devices/usb_device.py +++ b/src/labelle/lib/devices/usb_device.py @@ -62,10 +62,26 @@ def product(self): def serial_number(self): return self._get_dev_attribute("serial_number") + @property + def id_vendor(self): + return self._get_dev_attribute("idVendor") + @property def id_product(self): return self._get_dev_attribute("idProduct") + @property + def vendor_product_id(self): + vendor_id = int(self.id_vendor) + product_id = int(self.id_product) + return f"{vendor_id:04x}:{product_id:04x}" + + @property + def usb_id(self): + bus = self._get_dev_attribute("bus") + address = self._get_dev_attribute("address") + return f"Bus {bus:03} Device {address:03}: ID {self.vendor_product_id}" + @staticmethod def _is_supported_vendor(dev: usb.core.Device): return dev.idVendor == DEV_VENDOR @@ -197,9 +213,13 @@ def _set_configuration(self): else: raise - def setup( - self, - ): + def setup(self): + try: + self._setup() + except usb.core.USBError as e: + raise UsbDeviceError(f"Failed setup USB device: {e}") from e + + def _setup(self): self._set_configuration() intf = usb.util.find_descriptor( self._dev.get_active_configuration(), @@ -255,6 +275,19 @@ def setup( def dispose(self): usb.util.dispose_resources(self._dev) + def is_match(self, patterns: list[str] | None) -> bool: + if patterns is None: + return True + match = True + for pattern in patterns: + pattern = pattern.lower() + match &= ( + pattern in self.manufacturer.lower() + or pattern in self.product.lower() + or pattern in self.serial_number.lower() + ) + return match + @property def devin(self): return self._devin diff --git a/src/labelle/lib/font_config.py b/src/labelle/lib/font_config.py index 271ca758..d5297fd7 100644 --- a/src/labelle/lib/font_config.py +++ b/src/labelle/lib/font_config.py @@ -1,4 +1,5 @@ import logging +from enum import Enum from pathlib import Path from typing import Dict, List, Optional @@ -31,6 +32,16 @@ def __init__(self, style): } +class FontStyle(str, Enum): + REGULAR = "regular" + BOLD = "bold" + ITALIC = "italic" + NARROW = "narrow" + + +DefaultFontStyle = FontStyle(_DEFAULT_STYLE) + + def _get_styles_to_font_path_lookup() -> Dict[str, Path]: """Get a lookup table for styles to font paths. diff --git a/src/labelle/lib/outputs.py b/src/labelle/lib/outputs.py new file mode 100644 index 00000000..55ab317d --- /dev/null +++ b/src/labelle/lib/outputs.py @@ -0,0 +1,22 @@ +import webbrowser +from tempfile import NamedTemporaryFile + +import typer +from PIL import Image, ImageOps + +from labelle.lib.constants import Output +from labelle.lib.unicode_blocks import image_to_unicode + + +def output_bitmap(bitmap: Image.Image, output: Output): + if output in (Output.CONSOLE, Output.CONSOLE_INVERTED): + label_rotated = bitmap.transpose(Image.Transpose.ROTATE_270) + invert = output == Output.CONSOLE_INVERTED + typer.echo(image_to_unicode(label_rotated, invert=invert)) + if output == Output.IMAGEMAGICK: + ImageOps.invert(bitmap.convert("RGB")).show() + if output == Output.BROWSER: + with NamedTemporaryFile(suffix=".png", delete=False) as fp: + inverted = ImageOps.invert(bitmap.convert("RGB")) + ImageOps.invert(inverted).save(fp) + webbrowser.open(f"file://{fp.name}") diff --git a/src/labelle/lib/render_engines/__init__.py b/src/labelle/lib/render_engines/__init__.py index 7ac73129..e1e41567 100644 --- a/src/labelle/lib/render_engines/__init__.py +++ b/src/labelle/lib/render_engines/__init__.py @@ -15,19 +15,19 @@ from labelle.lib.render_engines.text import TextRenderEngine __all__ = [ - BarcodeRenderEngine, - BarcodeWithTextRenderEngine, - EmptyRenderEngine, - HorizontallyCombinedRenderEngine, - MarginsRenderEngine, - NoContentError, - NoPictureFilePath, - PictureRenderEngine, - PrintPayloadRenderEngine, - PrintPreviewRenderEngine, - QrRenderEngine, - RenderContext, - RenderEngine, - TestPatternRenderEngine, - TextRenderEngine, + "BarcodeRenderEngine", + "BarcodeWithTextRenderEngine", + "EmptyRenderEngine", + "HorizontallyCombinedRenderEngine", + "MarginsRenderEngine", + "NoContentError", + "NoPictureFilePath", + "PictureRenderEngine", + "PrintPayloadRenderEngine", + "PrintPreviewRenderEngine", + "QrRenderEngine", + "RenderContext", + "RenderEngine", + "TestPatternRenderEngine", + "TextRenderEngine", ] diff --git a/src/labelle/lib/render_engines/barcode.py b/src/labelle/lib/render_engines/barcode.py index 199a9f59..ac10e239 100644 --- a/src/labelle/lib/render_engines/barcode.py +++ b/src/labelle/lib/render_engines/barcode.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import barcode as barcode_module from PIL import Image from labelle.lib.barcode_writer import BarcodeImageWriter +from labelle.lib.constants import DEFAULT_BARCODE_TYPE from labelle.lib.render_engines.render_context import RenderContext from labelle.lib.render_engines.render_engine import ( RenderEngine, @@ -16,10 +19,10 @@ def __init__(self): class BarcodeRenderEngine(RenderEngine): - def __init__(self, content, barcode_type): + def __init__(self, content: str, barcode_type: str | None): super().__init__() self.content = content - self.barcode_type = barcode_type + self.barcode_type = barcode_type or DEFAULT_BARCODE_TYPE def render(self, context: RenderContext) -> Image.Image: code = barcode_module.get( diff --git a/src/labelle/lib/render_engines/barcode_with_text.py b/src/labelle/lib/render_engines/barcode_with_text.py index b2a5e307..49ec8ffa 100644 --- a/src/labelle/lib/render_engines/barcode_with_text.py +++ b/src/labelle/lib/render_engines/barcode_with_text.py @@ -1,10 +1,10 @@ from __future__ import annotations from pathlib import Path -from typing import Literal from PIL import Image +from labelle.lib.constants import Direction from labelle.lib.render_engines.barcode import BarcodeRenderEngine from labelle.lib.render_engines.render_context import RenderContext from labelle.lib.render_engines.render_engine import ( @@ -20,11 +20,11 @@ class BarcodeWithTextRenderEngine(RenderEngine): def __init__( self, content: str, - barcode_type, + barcode_type: str | None, font_file_name: Path | str, - frame_width_px: int, + frame_width_px: int | None, font_size_ratio: float = 0.9, - align: Literal["left", "center", "right"] = "center", + align: Direction = Direction.CENTER, ): super().__init__() self._barcode = BarcodeRenderEngine(content, barcode_type) diff --git a/src/labelle/lib/render_engines/horizontally_combined.py b/src/labelle/lib/render_engines/horizontally_combined.py index 891cc05f..7ea09164 100644 --- a/src/labelle/lib/render_engines/horizontally_combined.py +++ b/src/labelle/lib/render_engines/horizontally_combined.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Sequence + from PIL import Image from labelle.lib.render_engines.empty import EmptyRenderEngine @@ -12,7 +14,7 @@ class HorizontallyCombinedRenderEngine(RenderEngine): def __init__( self, - render_engines: list[RenderEngine], + render_engines: Sequence[RenderEngine], ): super().__init__() self.render_engines = render_engines diff --git a/src/labelle/lib/render_engines/margins.py b/src/labelle/lib/render_engines/margins.py index e0a47427..15cfe01e 100644 --- a/src/labelle/lib/render_engines/margins.py +++ b/src/labelle/lib/render_engines/margins.py @@ -5,6 +5,7 @@ from PIL import Image +from labelle.lib.constants import Direction from labelle.lib.env_config import is_dev_mode_no_margins from labelle.lib.render_engines.render_context import RenderContext from labelle.lib.render_engines.render_engine import ( @@ -24,11 +25,11 @@ def __init__( self, render_engine: RenderEngine, mode: Literal["print", "preview"], - justify: Literal["left", "center", "right"] = "center", + justify: Direction = Direction.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, + min_width_px: float | None = 0, ): super().__init__() labeler_horizontal_margin_px, labeler_vertical_margin_px = labeler_margin_px @@ -36,6 +37,8 @@ def __init__( assert labeler_horizontal_margin_px >= 0 assert labeler_vertical_margin_px >= 0 assert not max_width_px or max_width_px >= 0 + if min_width_px is None: + min_width_px = 0 assert min_width_px >= 0 self.mode = mode self.justify = justify @@ -53,7 +56,7 @@ def __init__( def _calculate_visible_width(self, payload_width_px: int) -> float: minimal_label_width_px = ( - payload_width_px + self.visible_horizontal_margin_px * 2 + payload_width_px + self.visible_horizontal_margin_px * 2.0 ) 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) @@ -64,17 +67,22 @@ 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) -> tuple[Image.Image, dict[str, float]]: + def render(self, _: RenderContext) -> Image.Image: + raise RuntimeError("This should never be called") + + def render_with_meta( + 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) padding_px = label_width_px - payload_width_px # sum of margins from both sides - if self.justify == "left": + if self.justify == Direction.LEFT: horizontal_offset_px = self.visible_horizontal_margin_px - elif self.justify == "center": + elif self.justify == Direction.CENTER: horizontal_offset_px = padding_px / 2 - elif self.justify == "right": + elif self.justify == Direction.RIGHT: horizontal_offset_px = padding_px - self.visible_horizontal_margin_px assert horizontal_offset_px >= self.visible_horizontal_margin_px @@ -102,10 +110,12 @@ def render(self, context: RenderContext) -> tuple[Image.Image, dict[str, float]] # 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 + bitmap_height = float(payload_bitmap.height) elif self.mode == "preview": # add vertical margins to bitmap - bitmap_height = payload_bitmap.height + self.labeler_vertical_margin_px * 2 + bitmap_height = ( + float(payload_bitmap.height) + self.labeler_vertical_margin_px * 2.0 + ) vertical_offset_px = self.labeler_vertical_margin_px bitmap = Image.new("1", (math.ceil(label_width_px), math.ceil(bitmap_height))) diff --git a/src/labelle/lib/render_engines/print_payload.py b/src/labelle/lib/render_engines/print_payload.py index 8ef7ec9f..a8388aff 100644 --- a/src/labelle/lib/render_engines/print_payload.py +++ b/src/labelle/lib/render_engines/print_payload.py @@ -1,9 +1,8 @@ from __future__ import annotations -from typing import Literal - from PIL import Image +from labelle.lib.constants import Direction from labelle.lib.render_engines.margins import MarginsRenderEngine from labelle.lib.render_engines.render_context import RenderContext from labelle.lib.render_engines.render_engine import RenderEngine @@ -13,11 +12,11 @@ class PrintPayloadRenderEngine(RenderEngine): def __init__( self, render_engine: RenderEngine, - justify: Literal["left", "center", "right"] = "center", + justify: Direction = Direction.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, + min_width_px: float | None = 0, ): super().__init__() self.render_engine = MarginsRenderEngine( @@ -30,5 +29,10 @@ def __init__( min_width_px=min_width_px, ) - def render(self, context: RenderContext) -> tuple[Image.Image, dict[str, float]]: - return self.render_engine.render(context) + def render(self, _: RenderContext) -> Image.Image: + raise RuntimeError("This should never be called") + + def render_with_meta( + self, context: RenderContext + ) -> tuple[Image.Image, dict[str, float]]: + return self.render_engine.render_with_meta(context) diff --git a/src/labelle/lib/render_engines/print_preview.py b/src/labelle/lib/render_engines/print_preview.py index c0cb9e7b..a7406517 100644 --- a/src/labelle/lib/render_engines/print_preview.py +++ b/src/labelle/lib/render_engines/print_preview.py @@ -1,10 +1,9 @@ from __future__ import annotations -from typing import Literal - from darkdetect import isDark from PIL import Image, ImageColor, ImageDraw, ImageOps +from labelle.lib.constants import Direction from labelle.lib.render_engines.margins import MarginsRenderEngine from labelle.lib.render_engines.render_context import RenderContext from labelle.lib.render_engines.render_engine import RenderEngine @@ -20,7 +19,7 @@ class PrintPreviewRenderEngine(RenderEngine): def __init__( self, render_engine: RenderEngine, - justify: Literal["left", "center", "right"] = "center", + justify: Direction = Direction.CENTER, visible_horizontal_margin_px: float = 0, labeler_margin_px: tuple[float, float] = (0, 0), max_width_px: float | None = None, @@ -50,7 +49,7 @@ 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) + render_bitmap, meta = self.render_engine.render_with_meta(context) bitmap = ImageOps.invert(render_bitmap.convert("L")).convert("RGBA") pixel_map = { "black": context.foreground_color, diff --git a/src/labelle/lib/render_engines/render_engine.py b/src/labelle/lib/render_engines/render_engine.py index 6b4bc806..71aea987 100644 --- a/src/labelle/lib/render_engines/render_engine.py +++ b/src/labelle/lib/render_engines/render_engine.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from PIL import Image @@ -12,4 +14,9 @@ class RenderEngineException(Exception): class RenderEngine(ABC): @abstractmethod def render(self, context: RenderContext) -> Image.Image: - pass + raise NotImplementedError() + + def render_with_meta( + self, context: RenderContext + ) -> tuple[Image.Image, dict[str, float] | None]: + return self.render(context), None diff --git a/src/labelle/lib/render_engines/text.py b/src/labelle/lib/render_engines/text.py index 246c0fe4..677cfaf5 100644 --- a/src/labelle/lib/render_engines/text.py +++ b/src/labelle/lib/render_engines/text.py @@ -1,10 +1,10 @@ from __future__ import annotations from pathlib import Path -from typing import Literal from PIL import Image, ImageFont +from labelle.lib.constants import Direction from labelle.lib.render_engines.render_context import RenderContext from labelle.lib.render_engines.render_engine import RenderEngine from labelle.lib.utils import draw_image @@ -15,9 +15,9 @@ def __init__( self, text_lines: str | list[str], font_file_name: Path | str, - frame_width_px: int, + frame_width_px: int | None, font_size_ratio: float = 0.9, - align: Literal["left", "center", "right"] = "left", + align: Direction = Direction.CENTER, ): if isinstance(text_lines, str): text_lines = [text_lines] @@ -27,7 +27,7 @@ def __init__( self.text_lines = text_lines self.font_file_name = font_file_name - self.frame_width_px = frame_width_px + self.frame_width_px = frame_width_px or 0 self.font_size_ratio = font_size_ratio self.align = align @@ -40,7 +40,9 @@ def render(self, context: RenderContext) -> Image.Image: font_offset_px = int((line_height - font_size_px) / 2) if self.frame_width_px: - frame_width_px = min(self.frame_width_px, font_offset_px, 3) + frame_width_px = self.frame_width_px or min( + self.frame_width_px, font_offset_px, 3 + ) else: frame_width_px = self.frame_width_px @@ -69,7 +71,7 @@ def render(self, context: RenderContext) -> Image.Image: draw.multiline_text( (label_width_px / 2, height_px / 2), multiline_text, - align=self.align, + align=self.align.value, anchor="mm", font=font, fill=1,