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.
This commit is contained in:
+812
@@ -0,0 +1,812 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user