Source code for ptouch.printer

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

"""Base class for Brother P-touch label printers."""

import logging
import struct
from abc import ABC
from dataclasses import dataclass
from enum import Enum
from math import ceil

import packbits
from PIL import Image

from .connection import Connection
from .label import Label
from .tape import (
    HeatShrinkTube,
    HeatShrinkTube3_1_5_2mm,
    HeatShrinkTube3_1_9_0mm,
    HeatShrinkTube3_1_11_2mm,
    HeatShrinkTube3_1_21_0mm,
    HeatShrinkTube3_1_31_0mm,
    Tape,
)


[docs] @dataclass class TapeConfig: """Pin configuration for a specific printer/tape combination. Attributes ---------- left_pins : int Number of unused pins on the left margin. print_pins : int Number of pins in the printable area. right_pins : int Number of unused pins on the right margin. """ left_pins: int print_pins: int right_pins: int
[docs] class MediaType(Enum): """Media type identifiers for Brother P-touch printers.""" NO_MEDIA = 0x00 LAMINATED_TAPE = 0x01 NONLAMINATED_TAPE = 0x03 HEATSHRINK_TUBE_21 = 0x11 # 2:1 shrink ratio HEATSHRINK_TUBE_31 = 0x17 # 3:1 shrink ratio INCOMPATIBLE_TAPE = 0xFF
logger = logging.getLogger(__name__)
[docs] class LabelPrinter(ABC): """Abstract base class for Brother P-touch label printers. Subclasses must define the following class attributes: Attributes ---------- TOTAL_PINS : int Total number of print head pins. BYTES_PER_LINE : int Number of bytes per raster line. RESOLUTION_DPI : int Base (vertical) resolution in DPI. RESOLUTION_DPI_HIGH : int High (horizontal) resolution in DPI when enabled. DEFAULT_USE_COMPRESSION : bool Whether to use TIFF compression by default. PIN_CONFIGS : dict[type[Tape], TapeConfig] Dict mapping tape type to TapeConfig. """ # Subclasses must define these TOTAL_PINS: int BYTES_PER_LINE: int RESOLUTION_DPI: int RESOLUTION_DPI_HIGH: int = 0 # 0 means no high resolution support DEFAULT_USE_COMPRESSION: bool PIN_CONFIGS: dict[type[Tape], TapeConfig] # Capability flags - what the printer supports SUPPORTS_AUTO_CUT: bool = True SUPPORTS_HALF_CUT: bool = True SUPPORTS_PAGE_NUMBER_CUTS: bool = True SUPPORTS_MIRROR_PRINT: bool = True SUPPORTS_CHAIN_PRINTING: bool = True SUPPORTS_SPECIAL_TAPE: bool = True # Default values for each feature (used when not specified) DEFAULT_AUTO_CUT: bool = True DEFAULT_HALF_CUT: bool = True DEFAULT_HIGH_RESOLUTION: bool = False DEFAULT_PAGE_NUMBER_CUTS: bool = False DEFAULT_MIRROR_PRINT: bool = False DEFAULT_CHAIN_PRINTING: bool = False DEFAULT_SPECIAL_TAPE: bool = False @property def supports_high_resolution(self) -> bool: """Whether the printer supports high resolution mode.""" return self.RESOLUTION_DPI_HIGH > 0 @property def supported_tapes(self) -> list[type[Tape]]: """Get list of tape types supported by this printer. Returns ------- list[type[Tape]] List of supported tape classes, sorted by name. """ return sorted(self.PIN_CONFIGS.keys(), key=lambda t: t.__name__) # Margin constraints in mm. See manual section "2.3.3 Feed amount". MIN_MARGIN_MM: float = 2.0 MAX_MARGIN_MM: float = 127.0 DEFAULT_MARGIN_MM: float = 2.0 def _mm_to_dots(self, mm: float) -> int: """Convert millimeters to dots at base resolution.""" return round(mm * self.RESOLUTION_DPI / 25.4) def _get_media_type(self, tape: Tape) -> MediaType: """Determine the media type for a given tape. Parameters ---------- tape : Tape The tape/tube to get media type for. Returns ------- MediaType The appropriate media type for the tape. """ # Check if it's a heat shrink tube if isinstance(tape, HeatShrinkTube): # 3:1 series tubes if isinstance( tape, ( HeatShrinkTube3_1_5_2mm, HeatShrinkTube3_1_9_0mm, HeatShrinkTube3_1_11_2mm, HeatShrinkTube3_1_21_0mm, HeatShrinkTube3_1_31_0mm, ), ): return MediaType.HEATSHRINK_TUBE_31 # 2:1 series tubes (default for HeatShrinkTube) return MediaType.HEATSHRINK_TUBE_21 # Default: let printer auto-detect (works for laminated tapes) return MediaType.NO_MEDIA
[docs] def __init__( self, connection: Connection, use_compression: bool | None = None, high_resolution: bool | None = None, ) -> None: """Initialize the label printer. Parameters ---------- connection : Connection Connection to the printer (USB or network). use_compression : bool or None, optional Whether to use TIFF compression. Defaults to class setting. high_resolution : bool or None, optional Whether to use high resolution mode. Defaults to class setting. """ self.connection = connection connection.connect(self) # Send initialization commands after connection is established init_data = self._cmd_invalidate_and_initialize() self.connection.write(init_data) self.use_compression = ( self.DEFAULT_USE_COMPRESSION if use_compression is None else use_compression ) self.high_resolution = ( self.DEFAULT_HIGH_RESOLUTION if high_resolution is None else high_resolution )
[docs] def get_tape_config(self, tape: Tape) -> TapeConfig: """Get the tape configuration for a given tape. Parameters ---------- tape : Tape The tape to get configuration for. Returns ------- TapeConfig Pin configuration for the tape. Raises ------ ValueError If the tape type is not supported by this printer. """ tape_type = type(tape) if tape_type not in self.PIN_CONFIGS: supported = ", ".join( t.__name__ for t in sorted(self.PIN_CONFIGS.keys(), key=lambda t: t.width_mm) ) raise ValueError( f"{tape_type.__name__} is not supported by {self.__class__.__name__}. " f"Supported tapes: {supported}" ) return self.PIN_CONFIGS[tape_type]
def _cmd_invalidate(self, length: int = 200) -> bytes: """Send invalidate command (null bytes) to clear printer buffer.""" return b"\x00" * length def _cmd_initialize(self) -> bytes: """Send initialize command (ESC @).""" return b"\x1b\x40" def _cmd_invalidate_and_initialize(self) -> bytes: """Send invalidate followed by initialize commands.""" return self._cmd_invalidate() + self._cmd_initialize() def _cmd_raster_mode(self) -> bytes: """Set printer to raster graphics mode (ESC i a).""" return struct.pack("BBBB", 0x1B, 0x69, 0x61, 0x01) def _cmd_print_information( self, length: int, media_type: MediaType, tape_width_mm: int ) -> bytes: """Set print information command (ESC i z). Parameters ---------- length : int Number of raster lines to print. media_type : MediaType Type of media being used. tape_width_mm : int Tape width in millimeters. Returns ------- bytes Command bytes for print information. """ PI_LENGTH = 0x02 # Length valid PI_WIDTH = 0x04 # Tape width valid PI_RECOVER = 0x80 # Recover mode (priority to print quality) n1 = PI_RECOVER | PI_WIDTH | PI_LENGTH return struct.pack( "<7BL2B", 0x1B, 0x69, 0x7A, n1, media_type.value, tape_width_mm, 0x00, ceil(length), 0x00, 0x00, ) def _cmd_mode_settings(self, auto_cut: bool = True, mirror_print: bool = False) -> bytes: """Set mode settings (ESC i M). Parameters ---------- auto_cut : bool, default True Enable automatic cutting after print. mirror_print : bool, default False Enable mirror printing. Returns ------- bytes Command bytes for mode settings. """ mode = 0 if auto_cut: mode |= 1 << 6 if mirror_print: mode |= 1 << 7 return struct.pack("4B", 0x1B, 0x69, 0x4D, mode) def _cmd_advanced_mode_settings( self, half_cut: bool = False, chain_printing: bool = False, high_resolution: bool = False, special_tape: bool = False, ) -> bytes: """Advanced mode settings (ESC i K). Parameters ---------- half_cut : bool, default False Enable half-cut (cuts tape but not backing). chain_printing : bool, default False Enable chain printing (no cut between labels). high_resolution : bool, default False Enable high resolution mode. special_tape : bool, default False Enable special-tape no-cut mode. When the loaded cassette is special (non-laminated decorative) tape, the printer skips all cuts so the cutter blade does not damage the tape. The printer is expected to ignore this bit when laminated tape is loaded; behavior is therefore conditional on the cassette type. Returns ------- bytes Command bytes for advanced mode settings. Notes ----- Bit 0: Draft printing (1=draft, 0=normal) Bit 2: Half cut (1=on, 0=off) Bit 3: No chain printing (1=no chain, 0=chain) Bit 4: Special tape no-cut (1=on, 0=off) Bit 6: High resolution (1=yes, 0=no) """ mode = 0 if half_cut: mode |= 1 << 2 if not chain_printing: mode |= 1 << 3 if special_tape: mode |= 1 << 4 if high_resolution: mode |= 1 << 6 return struct.pack("4B", 0x1B, 0x69, 0x4B, mode) def _cmd_margin(self, margin: int = 14) -> bytes: """Set margin in dots (ESC i d). Parameters ---------- margin : int, default 14 Margin size in dots. Returns ------- bytes Command bytes for margin setting. """ return struct.pack("5B", 0x1B, 0x69, 0x64, margin & 0xFF, (margin >> 8) & 0xFF) def _cmd_set_compression(self, tiff_compression: bool = False) -> bytes: """Set compression mode (M). Parameters ---------- tiff_compression : bool, default False Enable TIFF/PackBits compression. Returns ------- bytes Command bytes for compression setting. """ compression = 0x02 if tiff_compression else 0x00 return struct.pack("2B", 0x4D, compression) def _cmd_page_number_cuts(self, pages: int = 1) -> bytes: """Set page number for auto-cut (ESC i A). Parameters ---------- pages : int, default 1 Number of pages to print before cutting (1 = cut each label). Returns ------- bytes Command bytes for page number setting. """ return struct.pack("4B", 0x1B, 0x69, 0x41, pages) def _prepare_image(self, image: Image.Image, tape_config: TapeConfig) -> Image.Image: """Prepare image for printing: resize, center, and convert to 1-bit. Parameters ---------- image : PIL.Image.Image PIL Image to prepare. tape_config : TapeConfig Pin configuration for the tape. Returns ------- PIL.Image.Image 1-bit PIL Image ready for rasterization. """ config = tape_config # Create container image with proper height container_image = Image.new("RGB", (image.width, config.print_pins), (255, 255, 255)) # Center content within the printable area # Note: left_pins/right_pins in Brother specs already account for physical positioning y = (config.print_pins - image.height) // 2 container_image.paste(image, (0, y)) # Convert to 1-bit with threshold img_gray = container_image.convert("L") threshold_table = [0] * 128 + [255] * 128 return img_gray.point(threshold_table, mode="1") def _generate_raster(self, img_1bit: Image.Image, tape_config: TapeConfig) -> bytes: """Generate raster data from 1-bit image. Parameters ---------- img_1bit : PIL.Image.Image 1-bit PIL Image to convert to raster data. tape_config : TapeConfig Pin configuration for the tape. Returns ------- bytes Raster data bytes for the printer. """ config = tape_config # Use pixel access object for faster pixel reading pixels = img_1bit.load() assert pixels is not None, "Failed to load image pixels" # Pre-allocate column bits array (reused for each column) column_bits = [0] * self.TOTAL_PINS # Build raster column by column raster = bytearray(img_1bit.width * self.BYTES_PER_LINE) raster_idx = 0 for column in range(img_1bit.width): # Left margin pins are already 0 from initialization # Read print area pixels for row in range(config.print_pins): # In 1-bit images: 0 = black (print), 255 = white (no print) column_bits[config.left_pins + row] = 1 if pixels[column, row] == 0 else 0 # Right margin pins are already 0 from initialization # Pack bits into bytes for i in range(0, self.TOTAL_PINS, 8): byte = 0 for j in range(8): if column_bits[i + j]: byte |= 1 << (7 - j) raster[raster_idx] = byte raster_idx += 1 # Reset print area for next column (margins stay 0) for row in range(config.print_pins): column_bits[config.left_pins + row] = 0 return bytes(raster) def _additional_control_commands(self) -> bytes: """Return printer-specific control commands. Default implementation adds page number cuts if USE_PAGE_NUMBER_CUTS is True. Override in subclasses to add device-specific commands. """ if self.DEFAULT_PAGE_NUMBER_CUTS: return self._cmd_page_number_cuts() return b"" def _build_page_control_sequence( self, num_lines: int, margin: int, tape: Tape, high_resolution: bool, is_first_page: bool, auto_cut: bool = True, half_cut: bool = False, chain_printing: bool = False, mirror_print: bool = False, special_tape: bool = False, ) -> bytes: """Build control sequence for a single page in a multi-page job. Parameters ---------- num_lines : int Number of raster lines in the image. margin : int Margin in dots (normal resolution). tape : Tape The tape/tube being used (for media type detection). high_resolution : bool Whether to use high resolution mode. is_first_page : bool Whether this is the first page (needs invalidate/initialize). auto_cut : bool, default True Enable automatic cutting. half_cut : bool, default False Enable half-cut mode. chain_printing : bool, default False Enable chain printing (no cut after label). mirror_print : bool, default False Enable mirror printing (for transparent / iron-on tape). special_tape : bool, default False Enable special-tape no-cut mode. Effect is conditional on the printer detecting non-laminated decorative tape. Returns ------- bytes Control sequence bytes for this page. """ # High resolution mode doubles lines and margin if high_resolution: num_lines *= 2 margin *= 2 control_seq = b"" # Only first page gets invalidate and initialize if is_first_page: control_seq += self._cmd_invalidate_and_initialize() control_seq += self._cmd_raster_mode() control_seq += self._additional_control_commands() # Determine media type from tape (important for heat shrink tubes) media_type = self._get_media_type(tape) control_seq += self._cmd_print_information(num_lines, media_type, tape.width_mm) control_seq += self._cmd_mode_settings(auto_cut=auto_cut, mirror_print=mirror_print) if auto_cut and self.SUPPORTS_PAGE_NUMBER_CUTS: control_seq += self._cmd_page_number_cuts(pages=1) control_seq += self._cmd_advanced_mode_settings( half_cut=half_cut, chain_printing=chain_printing, high_resolution=high_resolution, special_tape=special_tape, ) control_seq += self._cmd_margin(margin) control_seq += self._cmd_set_compression(tiff_compression=self.use_compression) return control_seq def _build_raster_data(self, raster: bytes, num_lines: int, high_resolution: bool) -> bytes: """Build raster data bytes from raw raster. Parameters ---------- raster : bytes Raw raster data from _generate_raster. num_lines : int Number of raster lines. high_resolution : bool Whether to use high resolution mode. Returns ------- bytes Formatted raster data for the printer. """ repeat_count = 2 if high_resolution else 1 raster_data = b"" for i in range(num_lines): line_data = raster[i * self.BYTES_PER_LINE : (i + 1) * self.BYTES_PER_LINE] for _ in range(repeat_count): if self.use_compression: # TIFF/packbits compression if line_data == b"\x00" * self.BYTES_PER_LINE: raster_data += b"\x5a" # Z - Zero raster graphics else: compressed_line = packbits.encode(line_data) raster_data += b"\x47" # G - Raster graphics transfer raster_data += struct.pack("<H", len(compressed_line)) raster_data += compressed_line else: # No compression - send all lines including empty ones raster_data += b"\x47" # G - Raster graphics transfer raster_data += struct.pack("<H", self.BYTES_PER_LINE) raster_data += line_data return raster_data
[docs] def precut(self, tape: Tape) -> None: """Trigger a feed-and-cut without printing any content. Sends the standard page-control sequence with zero raster lines and auto-cut enabled, then 0x1A (print and feed). The printer feeds the ~24 mm of tape currently between the print head and the cutter and cuts, ejecting it as a small leader scrap. The next print then starts from a fresh cut edge, with no blank leader on the real label. This is what Brother's official driver does at the start of a print operation to give clean leader-free output. Parameters ---------- tape : Tape Tape currently loaded — used only to populate the page-info command with the correct media type and width. """ control_seq = self._build_page_control_sequence( num_lines=0, margin=self._mm_to_dots(self.DEFAULT_MARGIN_MM), tape=tape, high_resolution=False, is_first_page=True, auto_cut=True, half_cut=False, chain_printing=False, ) self.connection.write(control_seq) self.connection.write(b"\x1a") # print and feed → triggers the cut logger.info("Precut: leader ejected.")
def _resolve_feature( self, name: str, requested: bool | None, *, default: bool, supported: bool, ) -> bool: """Resolve an optional feature flag against its capability and default. Parameters ---------- name : str Human-readable feature name, used in the error message. requested : bool or None The caller's explicit request. None means "not specified". default : bool The class ``DEFAULT_*`` value, used when ``requested`` is None. supported : bool The class ``SUPPORTS_*`` value for this printer model. Returns ------- bool The resolved value. Always False when the feature is unsupported. Raises ------ ValueError If the feature is explicitly requested (``requested=True``) but the printer model does not support it. """ if requested is None: # Fall back to the class default, but never enable an unsupported # feature just because the default says so. return default and supported if requested and not supported: raise ValueError(f"{type(self).__name__} does not support {name}") return requested
[docs] def print( self, label: Label, margin_mm: float | None = None, high_resolution: bool | None = None, feed: bool = True, auto_cut: bool | None = None, half_cut: bool | None = None, mirror: bool | None = None, chain: bool | None = None, special_tape: bool | None = None, ) -> None: """Print a label using column-by-column raster format. Parameters ---------- label : Label Label to print (contains image and tape information). margin_mm : float or None, optional Margin in millimeters. Valid range: MIN_MARGIN_MM to MAX_MARGIN_MM. If None, uses DEFAULT_MARGIN_MM. high_resolution : bool or None, optional Whether to use high resolution mode. If None, uses printer's setting. feed : bool, default True If True, sends 0x1A (print and feed). If False, sends 0x0C (print without feed) - used for multi-label printing. auto_cut : bool or None, optional Override auto-cut setting. If None, uses DEFAULT_AUTO_CUT. Used by print_multi() for half-cut mode. half_cut : bool or None, optional Override half-cut setting. If None, uses DEFAULT_HALF_CUT. Used by print_multi() for half-cut mode. mirror : bool or None, optional Enable mirror printing. If None, uses DEFAULT_MIRROR_PRINT. Useful for transparent / iron-on tape where the image must be reversed. chain : bool or None, optional Enable chain printing. If None, uses DEFAULT_CHAIN_PRINTING. When True, the printer skips the final feed + full-cut after this page so the next print job continues without a leader feed. Each job in the chain must opt in explicitly. special_tape : bool or None, optional Enable special-tape no-cut mode. If None, uses DEFAULT_SPECIAL_TAPE. Effect is conditional on the loaded cassette being non-laminated decorative tape. Raises ------ ValueError If the label's tape type is not supported by this printer, or if a feature (``auto_cut``, ``half_cut``, ``mirror``, ``chain``, ``special_tape``) is explicitly requested but the printer model does not support it (see the ``SUPPORTS_*`` class attributes). """ # Resolve high_resolution setting high_res = self.high_resolution if high_resolution is None else high_resolution tape_config = self.get_tape_config(label.tape) label.prepare(tape_config.print_pins, self.RESOLUTION_DPI) image = label.image img_1bit = self._prepare_image(image, tape_config) raster = self._generate_raster(img_1bit, tape_config) num_lines = image.width # Resolve margin and validate bounds margin_mm = margin_mm if margin_mm is not None else self.DEFAULT_MARGIN_MM if not self.MIN_MARGIN_MM <= margin_mm <= self.MAX_MARGIN_MM: raise ValueError( f"Margin must be between {self.MIN_MARGIN_MM} and {self.MAX_MARGIN_MM} mm, " f"got {margin_mm}" ) margin_dots = self._mm_to_dots(margin_mm) logger.info(f"Image: {image.size}") logger.info( f"{self.__class__.__name__}: {len(raster)} bytes, {num_lines} columns, " f"{self.BYTES_PER_LINE} bytes/column" ) logger.info(f"Tape: {label.tape.width_mm}mm") logger.info(f"Margin: {margin_mm}mm ({margin_dots} dots)") logger.info(f"Compression: {'ON (TIFF)' if self.use_compression else 'OFF'}") if high_res: logger.info(f"Resolution: High ({self.RESOLUTION_DPI}x{self.RESOLUTION_DPI_HIGH} dpi)") else: logger.info(f"Resolution: Standard ({self.RESOLUTION_DPI}x{self.RESOLUTION_DPI} dpi)") # Resolve each optional feature against its SUPPORTS_*/DEFAULT_* flags. # Explicitly requesting an unsupported feature raises ValueError. auto_cut = self._resolve_feature( "auto-cut", auto_cut, default=self.DEFAULT_AUTO_CUT, supported=self.SUPPORTS_AUTO_CUT ) half_cut = self._resolve_feature( "half-cut", half_cut, default=self.DEFAULT_HALF_CUT, supported=self.SUPPORTS_HALF_CUT ) chain = self._resolve_feature( "chain printing", chain, default=self.DEFAULT_CHAIN_PRINTING, supported=self.SUPPORTS_CHAIN_PRINTING, ) mirror = self._resolve_feature( "mirror printing", mirror, default=self.DEFAULT_MIRROR_PRINT, supported=self.SUPPORTS_MIRROR_PRINT, ) special_tape = self._resolve_feature( "special-tape mode", special_tape, default=self.DEFAULT_SPECIAL_TAPE, supported=self.SUPPORTS_SPECIAL_TAPE, ) control_seq = self._build_page_control_sequence( num_lines=num_lines, margin=margin_dots, tape=label.tape, high_resolution=high_res, is_first_page=False, auto_cut=auto_cut, half_cut=half_cut, chain_printing=chain, mirror_print=mirror, special_tape=special_tape, ) raster_data = self._build_raster_data(raster, num_lines, high_res) # Choose print command: 0x0C (print) or 0x1A (print and feed) print_cmd = b"\x1a" if feed else b"\x0c" all_data = control_seq + raster_data + print_cmd self.connection.write(all_data) logger.info("Sent all data to printer.")
[docs] def print_multi( self, labels: list[Label], margin_mm: float | None = None, high_resolution: bool | None = None, half_cut: bool = True, precut: bool = False, mirror: bool | None = None, chain: bool | None = None, special_tape: bool | None = None, ) -> None: """Print multiple labels with cuts between and after last. This method prints multiple labels in sequence by calling print() for each label with appropriate parameters. Parameters ---------- labels : list[Label] List of labels to print. All labels must use the same tape type. margin_mm : float or None, optional Margin in millimeters. Valid range: MIN_MARGIN_MM to MAX_MARGIN_MM. If None, uses DEFAULT_MARGIN_MM. high_resolution : bool or None, optional Whether to use high resolution mode. If None, uses printer's setting. half_cut : bool, default True If True, use half-cuts between labels (saves tape). If False, request full cuts between all labels. Note: on many Brother PT-series printers the full-cutter blade is downstream of the print head, so cuts between pages of a multi-page job (sent with ``feed=False`` / ``0x0C``) cannot physically reach the cutter and degrade to half-cuts at the print-head position. In practice this means ``half_cut=False`` and ``half_cut=True`` often produce indistinguishable output (half-cuts between, full-cut at end). To get true separate full-cut labels, issue N independent ``print()`` calls — each pays a leader-feed but yields a fully-cut label. mirror : bool or None, optional Enable mirror printing on every label. Forwarded to ``print()``. chain : bool or None, optional Enable chain printing on every label. Forwarded to ``print()``. When True, the final label of the job will NOT feed/full-cut, leaving the chain open for the next ``print_multi()`` / ``print()`` call. special_tape : bool or None, optional Enable special-tape no-cut mode on every label. Forwarded to ``print()``. Raises ------ ValueError If labels list is empty, tape types don't match, or tape is unsupported. """ if not labels: raise ValueError("At least one label is required") # Verify all labels use the same tape type tape_type = type(labels[0].tape) for i, label in enumerate(labels[1:], start=2): if not isinstance(label.tape, tape_type): raise ValueError( f"All labels must use the same tape type. " f"Label 1 uses {tape_type.__name__}, label {i} uses {type(label.tape).__name__}" ) # half_cut here is a plain bool default rather than an explicit # request, so clamp it to the printer's capability: models without # half-cut support (e.g. PT-P710BT) fall back to full cuts instead # of raising on the common default path. half_cut = half_cut and self.SUPPORTS_HALF_CUT cut_type = "half-cut" if half_cut else "full-cut" logger.info(f"Printing {len(labels)} labels with {cut_type} between") if precut: self.precut(labels[0].tape) for idx, label in enumerate(labels): is_last = idx == len(labels) - 1 logger.info(f"Printing label {idx + 1}/{len(labels)}") self.print( label, margin_mm=margin_mm, high_resolution=high_resolution, feed=is_last, auto_cut=not half_cut, half_cut=half_cut, mirror=mirror, chain=chain, special_tape=special_tape, ) logger.info(f"Finished printing {len(labels)} labels.")