Files
label_printer/ptouch_label.py
T
ske087 93afa3c417 Add P-touch Labels for Asset: 63x20 mm label format for PT-E560BT
New three-column label format for Brother P-touch EDGE PT-E560BT asset tags:
logo | three static lines + variable field | QR code.

Layout and rendering
- ptouch_label.py: geometry engine with PDF and preview renderers. Both
  consume the same geometry, so the preview matches the print exactly.
- Code symbols are snapped to whole printer dots per module. Fractional
  modules are the usual reason a small QR code will not scan.
- QR is maximised to the printable height by default (16.4 mm, 4 dots per
  module) instead of a fixed 16 mm box (12.3 mm, 3 dots per module).
- Code payload is composed as prefix + inventory + suffix. A legacy
  {inventory} template is migrated on first save.

Hardware handling
- print_area.py reads the driver's real printable area via GetDeviceCaps.
  The PT-E560BT head is 128 dots at 180 dpi, so the maximum print height is
  18.06 mm even on 24 mm tape; the driver reports media as 65x24 mm but only
  about 61x18 mm is reachable. Content is fitted to the reachable area,
  otherwise the driver silently crops it.
- set_printer_resolution.py reads/switches the driver's resolution mode. The
  driver offers 180x180 (symmetric) and 180x360 (anisotropic, 2x across the
  tape); the anisotropic mode scaled output incorrectly.

GUI
- label_printer_ptouch_gui.py: 600x300 two-column panel - controls on the
  left, live preview on the right. Advanced settings for logo, static lines,
  code prefix/suffix and error correction.
- native_file_dialog.py: the standard Windows file picker, run in a
  subprocess because Tk and Kivy both want the main event loop.

Printing
- Print failures are reported honestly. print_to_printer previously returned
  True unconditionally, so a failed print claimed success and was logged as
  SUCCESS.
- SumatraPDF exit codes are now checked (4 = no printer, 5 = driver error).
- The PDF is sent with noscale so driver-side scaling cannot distort the QR
  module pitch.

Tests
- validate_ptouch_layout.py decodes the QR back out of the rendered preview
  module by module, proving the preview and PDF are the same layout.
- test_ptouch_pipeline.py writes to a temp config so it cannot overwrite
  operator settings.
- test_gui_layout.py exercises the real app class headlessly.
- test_print_reporting.py covers the failure paths.

Diagnostics: check_print_area.py, check_print_geometry.py, check_devmode.py,
print_calibration.py, print_measure_scale.py, capture_print_job.py.
2026-09-25 17:30:11 +03:00

813 lines
30 KiB
Python

"""
P-touch EDGE PT-E560BT label layout engine.
Produces an exact 63 x 20 mm label split into three columns:
0 20 mm 45 mm 63 mm
+---------+------------+--------+ 0
| | static 1 | Q R |
| LOGO | static 2 | C O | 1 mm non-printable gutter
| 16x16 | static 3 | D E |
| | VARIABLE | |
+---------+------------+--------+ 20 mm
20 mm 25 mm 18 mm
Hardware constraints (Brother spec sheet for PT-E310BT / PT-E510 / PT-E560BT):
* print resolution ....... 180 dpi (128 dot print head)
* max print height ....... 18.0 mm, and only when using 24 mm tape
* max tape width ......... 24 mm
Because the print head can only reach 18 mm, a 20 mm tall label keeps 1 mm of
non-printable gutter at the top and bottom. All content is laid out inside the
central 18 mm band; `gutter_mm` controls this and is applied automatically.
The same geometry is used for both the PDF and the on-screen preview, so what
you see in the GUI is what the printer receives.
"""
from __future__ import annotations
import datetime
import os
import tempfile
from dataclasses import dataclass, field, asdict
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from PIL import Image
MM_PER_PT = 25.4 / 72.0
# --------------------------------------------------------------------------
# Configuration
# --------------------------------------------------------------------------
#: Font size that a QR / Code128 symbol must be at least, in dots, to stay
#: scannable on a 180 dpi thermal print head.
MIN_MODULE_DOTS = 2.5
@dataclass
class LabelConfig:
"""All user-editable layout settings for the P-touch label."""
# Media
page_width_mm: float = 63.0
page_height_mm: float = 20.0
# Vertical non-printable gutter (print head reaches 18 mm of 20 mm)
gutter_mm: float = 1.0
# Emit the page at the driver's own media size, with the 63 x 20 mm content
# centred inside it.
#
# Why this defaults to True: the installed PT-E560BT driver reports its media
# as 24 x 65 mm (landscape). Sending a 63 x 20 mm page means the driver has to
# rescale and reposition it, and the measured result was a 25 x 17 mm label
# clipped against the left edge. Matching the page to the media size removes
# scaling and rotation from the equation, so the printed geometry is exactly
# what was drawn.
match_driver_media: bool = True
media_width_mm: float = 65.0 # physical page, along the tape
media_height_mm: float = 24.0 # physical page, across the tape
# Where the printable area starts inside the physical page. The PT-E560BT
# print head cannot reach the page edges: its reachable window measured
# 61.10 x 18.06 mm starting at (1.98, 2.96) mm on 65 x 24 mm media.
# Content drawn outside that window is cropped by the driver.
media_offset_x_mm: float = 1.98
media_offset_y_mm: float = 2.96
center_in_media: bool = True # when False, use media_offset_* instead
# Draw a hairline box at the content bounds. Used only for calibration.
show_alignment_box: bool = False
# Column split
col_logo_mm: float = 20.0
col_text_mm: float = 25.0 # remainder goes to the code column
# Column 1 - logo
logo_path: str = ""
logo_box_mm: float = 16.0
logo_dither: bool = True # thermal transfer is 1-bit; dither greyscale
# Column 2 - text
static_lines: list[str] = field(
default_factory=lambda: ["LINE 1", "LINE 2", "LINE 3"]
)
static_font_pt: float = 7.0
variable_font_pt: float = 9.5
text_padding_mm: float = 0.8
# Column 3 - code
code_type: str = "qr" # "qr" or "code128"
# Code payload is composed as: prefix + inventory + suffix.
# Example: prefix "INV-" and suffix "-A" with inventory "1234" -> "INV-1234-A".
qr_prefix: str = ""
qr_suffix: str = ""
# Legacy single-template form, kept only so older config files still load.
# New settings should use qr_prefix / qr_suffix.
qr_template: str = ""
# Size the code column so the symbol fills the printable height, giving the
# largest possible scannable code. When False, col_text_mm decides the split.
qr_maximize: bool = True
qr_box_mm: float = 16.0 # used only when qr_maximize is False
qr_border_modules: int = 4
qr_error_correction: str = "M"
#: Smallest text column worth keeping; stops a maximised code from starving
#: the text so badly that the three static lines stop fitting.
MIN_TEXT_COL_MM: float = 18.0
# Printing
printer_scale: str = "noscale" # never let the driver rescale the page
# SumatraPDF -print-settings.
#
# "paperkind" forces the driver's media explicitly (261 = 24 mm tape). Without
# it the driver was observed squeezing content into roughly the first 25 mm.
# Set to 0 to omit it and let the driver use its default.
#
# "disable-auto-rotation" is deliberately NOT used: SumatraPDF's default
# auto-rotation is what orients our 65 x 24 mm page onto the 24 x 65 mm media.
# Disabling it makes the geometry worse, not better.
#
# "center" centres the page on the paper once noscale is in effect.
paperkind: int = 261
# -- helpers ----------------------------------------------------------
@property
def sumatra_settings(self) -> str:
"""SumatraPDF -print-settings string for this label.
Pins the scale and the media kind so the QR module pitch reaches the tape
unmangled. Auto-rotation is intentionally left enabled: SumatraPDF uses it
to place our 65 x 24 mm page onto the driver's 24 x 65 mm media.
"""
parts = ["noscale"]
if self.paperkind:
parts.append(f"paperkind={self.paperkind}")
parts.append("center")
return ",".join(parts)
@property
def col_code_mm(self) -> float:
"""Width of the code column.
When `qr_maximize` is set, the code column takes everything left after the
logo column and a minimum usable text column, so the symbol is as large as
the printable height allows. The code is square, so its width is bounded by
the height; any excess here is simply not used.
"""
remaining = self.page_width_mm - self.col_logo_mm - self.col_text_mm
if not self.qr_maximize:
return remaining
return max(remaining, self.printable_height_mm)
@property
def effective_col_text_mm(self) -> float:
"""Text column width actually used, after the code column is given its space."""
if not self.qr_maximize:
return self.col_text_mm
# Code needs a square region as wide as the printable height.
code_need = self.printable_height_mm
available_for_text = self.page_width_mm - self.col_logo_mm - code_need
return max(self.MIN_TEXT_COL_MM, available_for_text)
@property
def printable_height_mm(self) -> float:
return self.page_height_mm - 2 * self.gutter_mm
@property
def page_size_mm(self) -> tuple[float, float]:
"""Actual PDF page size, which may be larger than the label content."""
if self.match_driver_media:
return (self.media_width_mm, self.media_height_mm)
return (self.page_width_mm, self.page_height_mm)
@property
def origin_mm(self) -> tuple[float, float]:
"""Bottom-left corner of the label content within the PDF page.
When `center_in_media` is False the content is placed at the driver's
printable-area offset instead of being centred. That matters when the
printable window is not centred on the page.
"""
page_w, page_h = self.page_size_mm
if not self.match_driver_media:
return (0.0, 0.0)
if self.center_in_media:
return (
(page_w - self.page_width_mm) / 2,
(page_h - self.page_height_mm) / 2,
)
return (self.media_offset_x_mm, self.media_offset_y_mm)
@property
def line_count(self) -> int:
return max(1, len(self.static_lines) + 1) # static lines + variable
def build_code_payload(self, inventory: str) -> str:
"""Compose the code payload from prefix + inventory + suffix.
The composed form is used for both QR and Code128, so the operator can
build e.g. `INV-1234-A` out of a fixed prefix, the variable field, and a
fixed suffix.
A legacy `qr_template` (e.g. "INV:{inventory}") is still honoured when no
prefix/suffix are set, so existing config files keep working.
"""
inventory = (inventory or "").strip()
# Legacy template support: only when the new fields are untouched.
template = (self.qr_template or "").strip()
if template and not self.qr_prefix and not self.qr_suffix:
if "{inventory}" in template:
return template.replace("{inventory}", inventory)
if "{INVENTORY}" in template:
return template.replace("{INVENTORY}", inventory)
return f"{template}{inventory}" if inventory else template
return f"{self.qr_prefix}{inventory}{self.qr_suffix}"
def describe_code_payload(self) -> str:
"""Human-readable form of the payload composition, for settings UIs."""
prefix = self.qr_prefix or "(none)"
suffix = self.qr_suffix or "(none)"
return f"{prefix} + <inventory> + {suffix}"
def to_dict(self) -> dict:
return asdict(self)
def copy(self) -> "LabelConfig":
"""An independent copy, so fitting for one printer cannot mutate another."""
clone = LabelConfig.from_dict(self.to_dict())
clone.static_lines = list(self.static_lines)
return clone
@classmethod
def from_dict(cls, data: dict) -> "LabelConfig":
"""Build a config, ignoring unknown keys so old files keep loading."""
valid = {f for f in cls.__dataclass_fields__}
clean = {k: v for k, v in (data or {}).items() if k in valid}
cfg = cls(**clean)
if not isinstance(cfg.static_lines, list):
cfg.static_lines = list(cls().static_lines)
return cfg
# --------------------------------------------------------------------------
# Geometry
# --------------------------------------------------------------------------
@dataclass
class Box:
"""A rectangle in millimetres, measured from the bottom-left corner."""
x: float
y: float
w: float
h: float
@property
def cx(self) -> float:
return self.x + self.w / 2
@property
def cy(self) -> float:
return self.y + self.h / 2
def compute_geometry(cfg: LabelConfig) -> dict:
"""Return every box and text baseline in millimetres.
Coordinates are relative to the label content (origin at the label's
bottom-left). `origin_mm` gives where that content sits on the PDF page,
which matters when `match_driver_media` enlarges the page.
Both renderers (PDF and preview) consume this dict, which is what keeps
the preview honest.
"""
g = cfg.gutter_mm
inner_h = cfg.printable_height_mm
# Column 1 - logo, centred in its column and vertically centred
logo = Box(
x=(cfg.col_logo_mm - cfg.logo_box_mm) / 2,
y=(cfg.page_height_mm - cfg.logo_box_mm) / 2,
w=cfg.logo_box_mm,
h=cfg.logo_box_mm,
)
# Column 2 - text block, vertically filling the printable band
text_w = cfg.effective_col_text_mm
text = Box(
x=cfg.col_logo_mm + cfg.text_padding_mm,
y=g,
w=text_w - 2 * cfg.text_padding_mm,
h=inner_h,
)
# Column 3 - code. A QR symbol is square, so when maximising we fill the
# printable height and let the width follow; otherwise use the configured box.
if cfg.qr_maximize:
code_size = inner_h
else:
code_size = cfg.qr_box_mm
code = Box(
x=(
cfg.col_logo_mm
+ text_w
+ (cfg.page_width_mm - cfg.col_logo_mm - text_w - code_size) / 2
),
y=(cfg.page_height_mm - code_size) / 2,
w=code_size,
h=code_size,
)
# Text lines, distributed evenly through the printable band. Line 0 is the
# topmost static line; the last line is the variable inventory value.
leading = inner_h / cfg.line_count
lines = []
for i in range(cfg.line_count):
line_top = g + inner_h - i * leading
line_bottom = line_top - leading
is_variable = i == cfg.line_count - 1
font_pt = cfg.variable_font_pt if is_variable else cfg.static_font_pt
cap_mm = 0.72 * font_pt * MM_PER_PT
# Centre the cap height of the glyphs inside the line slot.
baseline = line_bottom + leading / 2 - cap_mm / 2
lines.append(
{
"index": i,
"box": Box(text.x, line_bottom, text.w, leading),
"baseline_mm": baseline,
"font_pt": font_pt,
"is_variable": is_variable,
}
)
return {
"page": (cfg.page_width_mm, cfg.page_height_mm),
"printable": Box(0, g, cfg.page_width_mm, inner_h),
"logo": logo,
"text": text,
"code": code,
"lines": lines,
}
# --------------------------------------------------------------------------
# Fonts (preview only - the PDF uses built-in Helvetica)
# --------------------------------------------------------------------------
_FONT_CANDIDATES = [
(r"C:\Windows\Fonts\arialbd.ttf", r"C:\Windows\Fonts\arial.ttf"),
(r"C:\Windows\Fonts\segoeuib.ttf", r"C:\Windows\Fonts\segoeui.ttf"),
("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
]
def _find_fonts() -> tuple[str, str]:
"""Locate a bold/regular TrueType pair, falling back to DejaVu."""
for bold, regular in _FONT_CANDIDATES:
if os.path.exists(bold) and os.path.exists(regular):
return bold, regular
return "", ""
def _preview_font(size_pt: float, bold: bool, dpi: int):
"""Return a PIL font at the given point size for the requested DPI."""
from PIL import ImageFont
px = max(6, int(round(size_pt * dpi / 72.0)))
bold_path, regular_path = _find_fonts()
path = bold_path if bold else regular_path
if path:
try:
return ImageFont.truetype(path, px)
except OSError:
pass
return ImageFont.load_default()
# --------------------------------------------------------------------------
# Code (QR / barcode) generation
# --------------------------------------------------------------------------
#: Native resolution of the PT-E560BT print head.
PRINTER_DPI = 180
@dataclass
class CodeBitmap:
"""A generated code symbol plus the exact size it should occupy."""
image: object # PIL.Image - 1-bit-friendly 'L' mode
width_mm: float
height_mm: float
modules: int # module count across the symbol (QR) or bars (Code128)
dots_per_module: float
def build_code_bitmap(cfg: LabelConfig, payload: str, target_dots: int) -> CodeBitmap | None:
"""Render the code so its modules land on whole printer dots.
Thermal print heads cannot render a fraction of a dot, so a symbol scaled to
an arbitrary size gets uneven modules - some 3 dots wide, some 4 - which is
the main reason small QR codes fail to scan. This snaps the symbol to an
integer number of dots per module and returns the resulting exact size.
"""
if not payload:
return None
try:
if cfg.code_type == "code128":
return _build_code128(cfg, payload, target_dots)
return _build_qr(cfg, payload, target_dots)
except Exception as exc: # noqa: BLE001 - never crash a print job
print(f"Code generation error for '{payload}': {exc}")
return None
def _build_qr(cfg: LabelConfig, payload: str, target_dots: int) -> CodeBitmap:
"""Build a dot-aligned QR symbol."""
import qrcode
from qrcode.constants import (
ERROR_CORRECT_H,
ERROR_CORRECT_L,
ERROR_CORRECT_M,
ERROR_CORRECT_Q,
)
levels = {
"L": ERROR_CORRECT_L,
"M": ERROR_CORRECT_M,
"Q": ERROR_CORRECT_Q,
"H": ERROR_CORRECT_H,
}
qr = qrcode.QRCode(
version=None,
error_correction=levels.get(cfg.qr_error_correction.upper(), ERROR_CORRECT_M),
box_size=1,
border=cfg.qr_border_modules,
)
qr.add_data(payload)
qr.make(fit=True)
modules = qr.modules_count + 2 * cfg.qr_border_modules
dots_per_module = max(1, target_dots // modules)
total_dots = modules * dots_per_module
# Render at one pixel per module, then scale by whole pixels with NEAREST so
# every module stays perfectly square and edge-aligned.
base = qr.make_image(fill_color="black", back_color="white").convert("L")
img = base.resize((total_dots, total_dots), Image.NEAREST)
size_mm = total_dots / PRINTER_DPI * 25.4
return CodeBitmap(
image=img,
width_mm=size_mm,
height_mm=size_mm,
modules=modules,
dots_per_module=float(dots_per_module),
)
def _build_code128(cfg: LabelConfig, payload: str, target_dots: int) -> CodeBitmap:
"""Build a Code128 barcode sized to whole dots per narrow bar."""
from barcode import Code128
from barcode.writer import ImageWriter
instance = Code128(payload, writer=ImageWriter())
# Total symbol width in narrow modules (encoding bars + quiet zone).
modules = len(instance.build()[0])
# Render one pixel per module at the printer's native resolution.
temp_base = os.path.join(
tempfile.gettempdir(),
f"ptouch_code_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}",
)
instance.save(temp_base, options={"write_text": False, "module_height": 8, "font_size": 0})
path = temp_base + ".png"
try:
raw = Image.open(path)
raw.load()
raw = raw.convert("L")
finally:
try:
os.remove(path)
except OSError:
pass
dots_per_module = max(1, target_dots // modules)
total_dots = modules * dots_per_module
img = raw.resize((total_dots, raw.height * dots_per_module), Image.NEAREST)
return CodeBitmap(
image=img,
width_mm=total_dots / PRINTER_DPI * 25.4,
height_mm=img.height / PRINTER_DPI * 25.4,
modules=modules,
dots_per_module=float(dots_per_module),
)
# --------------------------------------------------------------------------
# Renderers
# --------------------------------------------------------------------------
class PtouchLabelRenderer:
"""Renders the three-column P-touch label to PDF and to a preview image."""
def __init__(self, config: LabelConfig | None = None):
self.config = config or LabelConfig()
# -- shared -----------------------------------------------------------
def _load_logo(self):
"""Open the logo, flatten transparency and mode for thermal printing."""
from PIL import Image
path = self.config.logo_path
if not path or not os.path.exists(path):
return None
try:
img = Image.open(path)
img.load()
except Exception as exc: # noqa: BLE001
print(f"Logo load error '{path}': {exc}")
return None
if img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGBA")
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
img = Image.alpha_composite(background, img)
img = img.convert("L")
if self.config.logo_dither:
# Thermal transfer is 1-bit; dither so greyscale logos survive.
img = img.convert("1").convert("L")
return img
def _fit_into(self, img, box: Box, dpi: int):
"""Scale an image to fit inside a box, preserving aspect ratio."""
target_w = max(1, int(round(box.w / 25.4 * dpi)))
target_h = max(1, int(round(box.h / 25.4 * dpi)))
ratio = min(target_w / img.width, target_h / img.height)
new_size = (
max(1, int(round(img.width * ratio))),
max(1, int(round(img.height * ratio))),
)
return img.resize(new_size, Image.LANCZOS)
def _code_box(self, cfg: LabelConfig, bitmap: CodeBitmap | None) -> Box:
"""Centre the code symbol in its column, at its dot-aligned size.
The symbol has a fixed physical size (whole dots per module), so it is
placed centred rather than stretched to fill the column. If it is larger
than the column it is clamped, which shrinks the module pitch slightly -
`code_density_warning` reports that case.
"""
geo = compute_geometry(cfg)["code"]
if bitmap is None:
return geo
w = min(bitmap.width_mm, geo.w)
h = min(bitmap.height_mm, geo.h)
return Box(x=geo.cx - w / 2, y=geo.cy - h / 2, w=w, h=h)
def code_bitmap(self, inventory: str) -> CodeBitmap | None:
"""The dot-aligned code symbol for this label, ready to embed."""
cfg = self.config
return build_code_bitmap(cfg, cfg.build_code_payload(inventory), self._code_target_dots(cfg))
def _line_text(self, index: int, inventory: str) -> str:
"""Text for a given line index; the last line is the inventory value."""
lines = self.config.static_lines
if index >= len(lines):
return (inventory or "").strip()
return lines[index] if index < len(lines) else ""
# -- PDF --------------------------------------------------------------
def render_pdf(self, inventory: str, filename: str | None = None) -> str:
"""Write the label to a PDF at exactly the configured page size.
Returns the filename. Passing None writes to a temp file.
"""
cfg = self.config
geo = compute_geometry(cfg)
payload = cfg.build_code_payload(inventory)
if not filename:
filename = os.path.join(
tempfile.gettempdir(),
f"ptouch_label_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.pdf",
)
page_w, page_h = cfg.page_size_mm
ox, oy = cfg.origin_mm
c = canvas.Canvas(filename, pagesize=(page_w * mm, page_h * mm))
c.setTitle(f"P-touch label {inventory}".strip())
# Optional hairline at the label content bounds, for calibration prints.
if cfg.show_alignment_box:
c.setStrokeColorRGB(0, 0, 0)
c.setLineWidth(0.25)
c.rect(ox * mm, oy * mm, cfg.page_width_mm * mm, cfg.page_height_mm * mm)
# Column 1 - logo
logo = self._load_logo()
if logo is not None:
box = geo["logo"]
temp_logo = os.path.join(
tempfile.gettempdir(),
f"ptouch_logo_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.png",
)
logo.save(temp_logo, "PNG")
try:
c.drawImage(
temp_logo,
(ox + box.x) * mm,
(oy + box.y) * mm,
width=box.w * mm,
height=box.h * mm,
preserveAspectRatio=True,
anchor="c",
mask=None,
)
finally:
try:
os.remove(temp_logo)
except OSError:
pass
# Column 2 - three static lines plus the variable line
for line in geo["lines"]:
text = self._line_text(line["index"], inventory)
if not text:
continue
c.setFont("Helvetica-Bold", line["font_pt"])
baseline_y = (oy + line["baseline_mm"]) * mm
# Shrink to fit rather than letting text run into the code column.
max_w = line["box"].w * mm
size = line["font_pt"]
while size > 4 and c.stringWidth(text, "Helvetica-Bold", size) > max_w:
size -= 0.25
c.setFont("Helvetica-Bold", size)
c.drawString((ox + line["box"].x) * mm, baseline_y, text)
# Column 3 - QR / barcode
bitmap = build_code_bitmap(cfg, payload, self._code_target_dots(cfg))
if bitmap is not None:
box = self._code_box(cfg, bitmap)
temp_code = os.path.join(
tempfile.gettempdir(),
f"ptouch_code_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.png",
)
bitmap.image.save(temp_code, "PNG")
try:
c.drawImage(
temp_code,
(ox + box.x) * mm,
(oy + box.y) * mm,
width=box.w * mm,
height=box.h * mm,
mask=None,
)
finally:
try:
os.remove(temp_code)
except OSError:
pass
c.showPage()
c.save()
return filename
# -- preview ----------------------------------------------------------
def render_preview(self, inventory: str, dpi: int = 300):
"""Render the label to a PIL image using the identical geometry."""
from PIL import Image, ImageDraw
cfg = self.config
geo = compute_geometry(cfg)
payload = cfg.build_code_payload(inventory)
page_w_mm, page_h_mm = cfg.page_size_mm
ox, oy = cfg.origin_mm
def px(mm_value: float) -> float:
return mm_value / 25.4 * dpi
def py(mm_from_bottom: float) -> float:
# Layout is measured from the bottom-left; PIL's origin is top-left.
return (page_h_mm - mm_from_bottom) / 25.4 * dpi
width = int(round(px(page_w_mm)))
height = int(round(px(page_h_mm)))
img = Image.new("L", (width, height), "white")
draw = ImageDraw.Draw(img)
# When the page is larger than the label content (driver media match),
# shade the slack so the operator can see where the label sits.
if cfg.match_driver_media:
draw.rectangle(
[
int(round(px(ox))),
int(round(py(oy + cfg.page_height_mm))),
int(round(px(ox + cfg.page_width_mm))),
int(round(py(oy))),
],
outline=0,
width=1,
)
# Column 1 - logo, centred in its box
logo = self._load_logo()
box = geo["logo"]
if logo is not None:
fitted = self._fit_into(logo, box, dpi)
img.paste(
fitted,
(
int(round(px(ox + box.x + (box.w - fitted.width / dpi * 25.4) / 2))),
int(round(py(oy + box.y + (box.h + fitted.height / dpi * 25.4) / 2))),
),
)
# Column 2 - text lines, drawn on their baselines
for line in geo["lines"]:
text = self._line_text(line["index"], inventory)
if not text:
continue
font = _preview_font(line["font_pt"], bold=True, dpi=dpi)
draw.text(
(px(ox + line["box"].x), py(oy + line["baseline_mm"])),
text,
fill=0,
font=font,
anchor="ls",
)
# Column 3 - code
bitmap = build_code_bitmap(cfg, payload, self._code_target_dots(cfg))
if bitmap is not None:
box = self._code_box(cfg, bitmap)
# The bitmap is authored at the printer's native 180 dpi; rescale it
# to the preview resolution with NEAREST so modules stay crisp and
# the on-screen size matches the printed size exactly.
factor = dpi / PRINTER_DPI
scaled = bitmap.image.resize(
(
max(1, int(round(bitmap.image.width * factor))),
max(1, int(round(bitmap.image.height * factor))),
),
Image.NEAREST,
)
img.paste(
scaled,
(int(round(px(ox + box.x))), int(round(py(oy + box.y + box.h)))),
)
return img.convert("RGB")
# -- diagnostics ------------------------------------------------------
def _code_target_dots(self, cfg: LabelConfig) -> int:
"""Dots available for the code symbol along its smaller axis."""
geo = compute_geometry(cfg)["code"]
return max(1, int(round(min(geo.w, geo.h) / 25.4 * PRINTER_DPI)))
def print_resolution_dpi(self) -> int:
"""Native resolution of the PT-E560BT print head."""
return PRINTER_DPI
def code_density_warning(config: LabelConfig, inventory: str) -> str | None:
"""Return a warning string when the code would print too densely to scan."""
payload = config.build_code_payload(inventory)
if not payload:
return None
geo = compute_geometry(config)["code"]
target_dots = max(1, int(round(min(geo.w, geo.h) / 25.4 * PRINTER_DPI)))
bitmap = build_code_bitmap(config, payload, target_dots)
if bitmap is None:
return None
if bitmap.width_mm > geo.w + 0.01 or bitmap.height_mm > geo.h + 0.01:
return (
f"Code does not fit: needs {bitmap.width_mm:.1f} x {bitmap.height_mm:.1f} mm "
f"but the column is {geo.w:.1f} x {geo.h:.1f} mm even at one dot per "
f"module ({bitmap.modules} modules). Shorten the QR string."
)
if bitmap.dots_per_module < MIN_MODULE_DOTS:
return (
f"Code is dense: {bitmap.dots_per_module:.1f} printer dots per module at "
f"{PRINTER_DPI} dpi ({bitmap.modules} modules). Scanning may be "
f"unreliable - shorten the QR string or lower error correction."
)
return None