Source code for ptouch.label

# SPDX-FileCopyrightText: 2024-2026 Nicolai Buchwitz <nb@tipi-net.de>
#
# SPDX-License-Identifier: LGPL-2.1-or-later

"""Label classes for Brother P-touch label printers."""

from enum import Flag, auto

from PIL import Image, ImageDraw, ImageFont

from .tape import Tape


[docs] class Label: """A label to be printed on a specific tape. Attributes ---------- image : PIL.Image.Image The image to print. tape : Tape The tape this label is designed for. """
[docs] def __init__(self, image: Image.Image, tape: type[Tape] | Tape) -> None: """Initialize a label. Parameters ---------- image : PIL.Image.Image The image to print. tape : type[Tape] | Tape The tape class (e.g., Tape36mm) or instance. """ self.image = image self.tape = tape() if isinstance(tape, type) else tape
[docs] def prepare(self, height: int, resolution_dpi: int = 180) -> None: """Prepare the label for printing. Called by the printer before printing. Override in subclasses that need deferred rendering (e.g., TextLabel). Parameters ---------- height : int Print height in pixels (tape_config.print_pins). resolution_dpi : int, default 180 Printer resolution in DPI for mm to pixel conversion. """ pass
[docs] class TextLabel(Label): """A label containing rendered text. The image is rendered automatically when printed, using the correct height for the printer/tape combination. """
[docs] class Align(Flag): """Text alignment options. Combine with | operator, e.g. Align.LEFT | Align.TOP.""" LEFT = auto() HCENTER = auto() RIGHT = auto() TOP = auto() VCENTER = auto() BOTTOM = auto() # Convenience alias for both centered CENTER = HCENTER | VCENTER
[docs] def __init__( self, text: str, tape: type[Tape] | Tape, font: str | ImageFont.FreeTypeFont, font_size: int | None = None, align: "TextLabel.Align | None" = None, min_width_mm: float | None = None, fixed_width_mm: float | None = None, auto_size: bool = True, ) -> None: """Initialize a text label. Parameters ---------- text : str Text to render. tape : type[Tape] | Tape The tape class (e.g., Tape36mm) or instance. font : str or ImageFont.FreeTypeFont Path to TrueType font file, or a pre-loaded ImageFont object. font_size : int or None, optional Font size in pixels. Only used when auto_size=False. align : TextLabel.Align, default TextLabel.Align.CENTER Text alignment. Combine horizontal (LEFT, HCENTER, RIGHT) and vertical (TOP, VCENTER, BOTTOM) with |, e.g. Align.LEFT | Align.TOP. Use Align.CENTER for both horizontally and vertically centered. min_width_mm : float or None, optional Minimum label width in millimeters. If specified, the label will be padded to at least this width. Ignored when fixed_width_mm is set. fixed_width_mm : float or None, optional Fixed label width in millimeters. If specified, the label is locked to exactly this width and the font is scaled down so the text fits within it (it does not expand the label to the text size). auto_size : bool, default True If True, automatically size the font to 80% of the print height. If False, use font_size (for path strings) or the ImageFont's built-in size. """ self.text = text self.tape = tape() if isinstance(tape, type) else tape if not isinstance(font, (str, ImageFont.FreeTypeFont)): raise ValueError( f"font must be a path string or ImageFont.FreeTypeFont, got {type(font).__name__}" ) self.font = font self.font_size = font_size self.align = align if align is not None else TextLabel.Align.CENTER self.min_width_mm = min_width_mm self.fixed_width_mm = fixed_width_mm self.auto_size = auto_size self._image: Image.Image | None = None
@property def image(self) -> Image.Image: """Get the rendered image.""" if self._image is None: raise RuntimeError( "TextLabel has not been rendered yet. " "Call prepare(height) or let the printer handle it." ) return self._image
[docs] def prepare(self, height: int, resolution_dpi: int = 180) -> None: """Render the text to an image. Parameters ---------- height : int Image height in pixels (tape_config.print_pins). resolution_dpi : int, default 180 Printer resolution in DPI for mm to pixel conversion. """ if self._image is not None: return # Already rendered # When auto-sizing, fit all lines into 80% of the print height so # multi-line text (separated by "\n") shrinks to fit instead of # overflowing the tape. num_lines = self.text.count("\n") + 1 auto_font_size = max(1, int(height * 0.8 / num_lines)) # Build a loader that can re-create the font at an arbitrary size, so we # can shrink it to fit a fixed width. can_resize is False for bitmap # fonts that cannot be scaled. if isinstance(self.font, str): if self.auto_size: font_size = auto_font_size else: font_size = self.font_size if self.font_size is not None else auto_font_size def load_font(size: int) -> ImageFont.FreeTypeFont: return ImageFont.truetype(self.font, size) can_resize = True elif hasattr(self.font, "font_variant") and getattr(self.font, "size", None): font_size = auto_font_size if self.auto_size else int(self.font.size) def load_font(size: int) -> ImageFont.FreeTypeFont: return self.font.font_variant(size=size) can_resize = True else: # Bitmap font or anything not scalable: use as-is. font_size = getattr(self.font, "size", None) can_resize = False font = load_font(font_size) if can_resize else self.font # Measure text size temp_img = Image.new("RGB", (1, 1)) temp_draw = ImageDraw.Draw(temp_img) bbox = temp_draw.textbbox((0, 0), self.text, font=font) text_width = bbox[2] - bbox[0] padding = 10 if self.fixed_width_mm is not None: # Lock the label to exactly this width; shrink the font to fit # instead of expanding the label to the text. image_width = int(self.fixed_width_mm * resolution_dpi / 25.4) available = image_width - 2 * padding if available > 0 and text_width > available and can_resize and font_size: new_size = max(1, int(font_size * available / text_width)) font = load_font(new_size) bbox = temp_draw.textbbox((0, 0), self.text, font=font) text_width = bbox[2] - bbox[0] else: # Create image sized to content with padding. image_width = int(text_width + 2 * padding) # Apply minimum width if specified if self.min_width_mm is not None: min_width_px = int(self.min_width_mm * resolution_dpi / 25.4) image_width = max(image_width, min_width_px) image = Image.new("RGB", (image_width, height), (255, 255, 255)) draw = ImageDraw.Draw(image) # Horizontal alignment (account for bbox offset) if TextLabel.Align.LEFT in self.align: text_x = padding - bbox[0] elif TextLabel.Align.RIGHT in self.align: text_x = image_width - padding - bbox[2] else: # HCENTER (default) text_x = (image_width - bbox[0] - bbox[2]) // 2 # Vertical alignment (account for bbox offset) if TextLabel.Align.TOP in self.align: text_y = -bbox[1] elif TextLabel.Align.BOTTOM in self.align: text_y = height - bbox[3] else: # VCENTER (default) text_y = (height - bbox[1] - bbox[3]) // 2 draw.text((text_x, text_y), self.text, font=font, fill=(0, 0, 0)) self._image = image
# Backwards compatibility alias Align = TextLabel.Align