Files
NIIMBOT-D101/label_app.py
T
Gracious dfe26919ad Rewrite label designer as an item-based canvas editor
- Items (text blocks, emoji) are placed freely: click to select, drag to
  move, arrow-key nudge, rotate to any angle, resize, duplicate, fit/center
- Emoji picker now covers the full emoji set via emoji-data-python
  (~1,900, 9 categories, name search, hover shows the name); thumbnails
  are rendered by Pillow with Noto Color Emoji so ZWJ sequences and flags
  display exactly as they print
- Designs save/open as JSON; last design restored on launch
2026-09-21 08:15:27 +01:00

906 lines
33 KiB
Python

"""Label designer for Niimbot printers.
Items (text blocks and emoji) are placed freely on the label canvas: click to
select, drag to move, arrow keys to nudge, rotate/resize from the side panel.
"""
import json
import re
import threading
import tkinter as tk
from dataclasses import asdict, dataclass, field
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
import emoji_data_python
from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageTk
from niimprint import (
MODELS,
BluetoothTransport,
PrinterClient,
PrinterError,
SerialTransport,
prepare_print,
)
PX_PER_MM = 8
SETTINGS_PATH = Path.home() / ".config" / "niimprint-label" / "settings.json"
DEFAULT_ADDR = "94:11:02:66:16:4D" # your D101 — change in the app, it is remembered
TEXT_FONTS = {
"bold": "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"regular": "/usr/share/fonts/TTF/DejaVuSans.ttf",
}
EMOJI_FONT = "/usr/share/fonts/noto/NotoColorEmoji.ttf"
EMOJI_FONT_SIZE = 109 # Noto Color Emoji is a bitmap font; only this size loads
EMOJI_GAMMA = 3.0 # >1 darkens emoji so they survive 1-bit dithering
EMOJI_SCALE = 1.15 # emoji height relative to text size
PICKER_PX = 26 # thumbnail size in the emoji picker
# (name, width mm, length mm) — width is the short side (tape width)
LABEL_PRESETS = [
("12 x 40 mm", 12, 40),
("12 x 30 mm", 12, 30),
("12 x 22 mm", 12, 22),
("15 x 30 mm", 15, 30),
("Custom", None, None),
]
EMOJI_CATEGORIES = [
("Smileys", "Smileys & Emotion"),
("People", "People & Body"),
("Animals", "Animals & Nature"),
("Food", "Food & Drink"),
("Travel", "Travel & Places"),
("Activities", "Activities"),
("Objects", "Objects"),
("Symbols", "Symbols"),
("Flags", "Flags"),
]
def _rng(a: int, b: int) -> str:
return f"{chr(a)}-{chr(b)}"
# Matches one emoji cluster: base + optional VS16 / skin tone + ZWJ continuations
_EMOJI_BASE = (
"["
+ _rng(0x1F000, 0x1FAFF)
+ _rng(0x2600, 0x27BF)
+ _rng(0x2B00, 0x2BFF)
+ _rng(0x2300, 0x23FF)
+ _rng(0x2190, 0x21FF)
+ _rng(0x25A0, 0x25FF)
+ _rng(0x2900, 0x297F)
+ "".join(chr(c) for c in (0x3030, 0x303D, 0x3297, 0x3299, 0xA9, 0xAE, 0x2122))
+ "]"
)
_VS = f"[{chr(0xFE0F)}{chr(0xFE0E)}]?"
_SKIN = f"[{_rng(0x1F3FB, 0x1F3FF)}]?"
_ZWJ = chr(0x200D)
EMOJI_RE = re.compile(f"(?:{_EMOJI_BASE}{_VS}{_SKIN}(?:{_ZWJ}{_EMOJI_BASE}{_VS})*)")
# --------------------------------------------------------------------------- emoji db
def load_emoji_db():
"""Return {category label: [(char, name), ...]} ordered like the emoji keyboard."""
by_cat = {label: [] for label, _ in EMOJI_CATEGORIES}
cat_label = {full: label for label, full in EMOJI_CATEGORIES}
for e in sorted(emoji_data_python.emoji_data, key=lambda e: e.sort_order):
label = cat_label.get(e.category)
if label and not e.obsoleted_by:
by_cat[label].append((e.char, e.name.title()))
return by_cat
# --------------------------------------------------------------------------- render
_emoji_font = None
_emoji_cache: dict[tuple[str, int], Image.Image] = {}
_text_font_cache: dict[tuple[bool, int], ImageFont.FreeTypeFont] = {}
def text_font(bold: bool, size: int) -> ImageFont.FreeTypeFont:
key = (bold, size)
if key not in _text_font_cache:
path = TEXT_FONTS["bold" if bold else "regular"]
_text_font_cache[key] = ImageFont.truetype(path, size)
return _text_font_cache[key]
def _emoji_rgba(cluster: str) -> Image.Image:
"""Render one emoji cluster with Noto Color Emoji, tightly cropped, RGBA."""
global _emoji_font
if _emoji_font is None:
_emoji_font = ImageFont.truetype(EMOJI_FONT, EMOJI_FONT_SIZE)
left, top, right, bottom = _emoji_font.getbbox(cluster)
tile = Image.new("RGBA", (max(right, 1), max(bottom, 1)), (255, 255, 255, 0))
ImageDraw.Draw(tile).text((0, 0), cluster, font=_emoji_font, embedded_color=True)
return tile.crop((left, top, right, bottom))
def render_emoji(cluster: str, height: int) -> Image.Image:
"""Render one emoji cluster as a grayscale tile of the given height."""
key = (cluster, height)
if key in _emoji_cache:
return _emoji_cache[key]
tile = _emoji_rgba(cluster)
# Flatten alpha onto white, then scale to line height
bg = Image.new("RGB", tile.size, "white")
bg.paste(tile, mask=tile.split()[3])
scale = height / tile.height
tile = bg.resize((max(1, round(tile.width * scale)), height), Image.LANCZOS)
# Thermal print is 1-bit: darken mid-tones so pale colours don't dither away
gray = tile.convert("L").point(lambda v: round(255 * (v / 255) ** EMOJI_GAMMA))
_emoji_cache[key] = gray
return gray
def emoji_thumbnail(cluster: str, px: int = PICKER_PX) -> Image.Image:
"""Colour thumbnail for the picker, rendered by the same font that prints."""
tile = _emoji_rgba(cluster)
tile.thumbnail((px, px), Image.LANCZOS)
return tile
def _tokenize(line: str):
"""Split a line into ("text", str) and ("emoji", str) runs."""
pos = 0
for m in EMOJI_RE.finditer(line):
if m.start() > pos:
yield ("text", line[pos : m.start()])
yield ("emoji", m.group())
pos = m.end()
if pos < len(line):
yield ("text", line[pos:])
def render_text_block(text: str, size: int, bold: bool, align: str) -> Image.Image:
"""Render multi-line text (with inline emoji) as a tight grayscale block."""
font = text_font(bold, size)
ascent, descent = font.getmetrics()
emoji_h = round(size * EMOJI_SCALE)
line_h = max(ascent + descent, emoji_h)
rows = []
for line in text.splitlines() or [""]:
runs, width = [], 0
for kind, s in _tokenize(line):
if kind == "text":
w = round(font.getlength(s))
runs.append(("text", s, w))
else:
tile = render_emoji(s, emoji_h)
w = tile.width + 2
runs.append(("emoji", tile, w))
width += w
rows.append((width, runs))
block_w = max(1, max(w for w, _ in rows))
block_h = line_h * len(rows)
img = Image.new("L", (block_w, block_h), 255)
draw = ImageDraw.Draw(img)
y = 0
for row_w, runs in rows:
if align == "left":
x = 0
elif align == "right":
x = block_w - row_w
else:
x = (block_w - row_w) // 2
for kind, obj, w in runs:
if kind == "text":
draw.text((x, y + (line_h - ascent) // 2), obj, font=font, fill=0)
else:
img.paste(obj, (x + 1, y + (line_h - obj.height) // 2))
x += w
y += line_h
return img
@dataclass
class Item:
kind: str # "text" | "emoji"
text: str
x: int # centre, label px
y: int
size: int = 40
angle: int = 0 # degrees clockwise
bold: bool = True
align: str = "center"
def sprite(self) -> Image.Image:
if self.kind == "emoji":
img = render_emoji(self.text, max(8, round(self.size * EMOJI_SCALE)))
else:
img = render_text_block(self.text, max(8, self.size), self.bold, self.align)
if self.angle % 360:
img = img.rotate(
-self.angle, expand=True, fillcolor=255, resample=Image.BICUBIC
)
return img
@dataclass
class Design:
width_mm: int = 12
length_mm: int = 40
preset: str = "12 x 40 mm"
items: list[Item] = field(default_factory=list)
@property
def size_px(self) -> tuple[int, int]:
"""Landscape (width, height): length along x, tape width along y."""
return self.length_mm * PX_PER_MM, self.width_mm * PX_PER_MM
def render(self):
"""Return (grayscale image, [bbox per item])."""
w, h = self.size_px
img = Image.new("L", (w, h), 255)
boxes = []
for item in self.items:
sprite = item.sprite()
x0 = item.x - sprite.width // 2
y0 = item.y - sprite.height // 2
img.paste(sprite, (x0, y0), mask=ImageOps.invert(sprite))
boxes.append((x0, y0, x0 + sprite.width, y0 + sprite.height))
return img, boxes
def to_dict(self) -> dict:
return {
"width_mm": self.width_mm,
"length_mm": self.length_mm,
"preset": self.preset,
"items": [asdict(i) for i in self.items],
}
@classmethod
def from_dict(cls, d: dict) -> "Design":
return cls(
width_mm=int(d.get("width_mm", 12)),
length_mm=int(d.get("length_mm", 40)),
preset=d.get("preset", "12 x 40 mm" if not d else "Custom"),
items=[Item(**i) for i in d.get("items", [])],
)
# --------------------------------------------------------------------------- app
class LabelApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("Niimbot Label Designer")
self.minsize(1100, 640)
self.settings = self._load_settings()
self.design = Design.from_dict(self.settings.get("design", {}))
if not self.design.items and "text" in self.settings: # migrate old settings
w, h = self.design.size_px
self.design.items.append(
Item("text", self.settings["text"], w // 2, h // 2)
)
self.emoji_db = load_emoji_db()
self.selected: Item | None = None
self._boxes: list[tuple[int, int, int, int]] = []
self._render_job = None
self._printer = None
self._photo = None
self._syncing = False
self._drag = None
self.zoom = 2
self.preset = tk.StringVar(value=self.design.preset)
self.width_mm = tk.IntVar(value=self.design.width_mm)
self.length_mm = tk.IntVar(value=self.design.length_mm)
self.model = tk.StringVar(value=self.settings.get("model", "d101"))
self.conn = tk.StringVar(value=self.settings.get("conn", "bluetooth"))
self.addr = tk.StringVar(value=self.settings.get("addr", DEFAULT_ADDR))
self.density = tk.IntVar(value=self.settings.get("density", 3))
self.copies = tk.IntVar(value=1)
self.status = tk.StringVar(value="Ready")
self.search = tk.StringVar()
# selected-item properties
self.p_size = tk.IntVar(value=40)
self.p_angle = tk.IntVar(value=0)
self.p_bold = tk.BooleanVar(value=True)
self.p_align = tk.StringVar(value="center")
self.p_x = tk.IntVar(value=0)
self.p_y = tk.IntVar(value=0)
self._build_ui()
self.preset.trace_add("write", lambda *_: self._apply_preset())
for var in (self.width_mm, self.length_mm):
var.trace_add("write", lambda *_: self._size_changed())
for var in (
self.p_size, self.p_angle, self.p_bold, self.p_align, self.p_x, self.p_y
): # fmt: skip
var.trace_add("write", lambda *_: self._props_changed())
self.search.trace_add("write", lambda *_: self._search_changed())
for var in (self.model, self.conn, self.addr):
var.trace_add("write", lambda *_: self._disconnect())
self._apply_preset()
if not self.design.items:
self.add_text("Hello 👋")
self._select(self.design.items[-1])
self.protocol("WM_DELETE_WINDOW", self._on_close)
# ---- UI construction
def _build_ui(self):
root = ttk.Frame(self, padding=8)
root.pack(fill="both", expand=True)
root.columnconfigure(1, weight=1)
root.rowconfigure(0, weight=1)
left = ttk.Frame(root)
left.grid(row=0, column=0, sticky="nsw", padx=(0, 8))
right = ttk.Frame(root)
right.grid(row=0, column=1, sticky="nsew")
right.columnconfigure(0, weight=1)
right.rowconfigure(2, weight=1)
self._build_size_box(left)
self._build_emoji_picker(left)
self._build_printer_box(left)
self._build_toolbar(right)
self._build_canvas(right)
self._build_props(right)
self._build_actions(right)
def _build_size_box(self, parent):
box = ttk.LabelFrame(parent, text="Label", padding=6)
box.pack(fill="x")
ttk.Combobox(
box,
textvariable=self.preset,
values=[p[0] for p in LABEL_PRESETS],
state="readonly",
width=12,
).grid(row=0, column=0, columnspan=2, sticky="w")
ttk.Label(box, text="W mm").grid(row=0, column=2, sticky="e", padx=(8, 2))
self.width_spin = ttk.Spinbox(
box, from_=6, to=60, textvariable=self.width_mm, width=4
)
self.width_spin.grid(row=0, column=3)
ttk.Label(box, text="L mm").grid(row=0, column=4, sticky="e", padx=(6, 2))
self.length_spin = ttk.Spinbox(
box, from_=10, to=200, textvariable=self.length_mm, width=4
)
self.length_spin.grid(row=0, column=5)
def _build_emoji_picker(self, parent):
box = ttk.LabelFrame(parent, text="Emoji — click to add to label", padding=4)
box.pack(fill="both", expand=True, pady=(8, 0))
top = ttk.Frame(box)
top.pack(fill="x")
ttk.Label(top, text="Search").pack(side="left")
ttk.Entry(top, textvariable=self.search, width=24).pack(
side="left", fill="x", expand=True, padx=(4, 0)
)
self.hover_name = ttk.Label(box, text=" ", foreground="#555")
self.hover_name.pack(fill="x")
self.nb = ttk.Notebook(box, width=420, height=260)
self.nb.pack(fill="both", expand=True)
self.emoji_index: dict[tk.Text, list[tuple[str, str]]] = {}
for label, _ in EMOJI_CATEGORIES:
self._make_emoji_tab(label, self.emoji_db[label])
self.search_tab = self._make_emoji_tab("Search", [])
self.nb.hide(self.search_tab.master)
def _make_emoji_tab(self, label, entries) -> tk.Text:
frame = ttk.Frame(self.nb)
self.nb.add(frame, text=label)
txt = tk.Text(
frame,
wrap="char",
cursor="hand2",
spacing1=2,
spacing3=2,
padx=4,
relief="flat",
highlightthickness=0,
)
sb = ttk.Scrollbar(frame, command=txt.yview)
txt.configure(yscrollcommand=sb.set)
sb.pack(side="right", fill="y")
txt.pack(side="left", fill="both", expand=True)
txt.bind("<Button-1>", self._picker_click)
txt.bind("<Motion>", self._picker_hover)
txt.bind("<Leave>", lambda e: self.hover_name.configure(text=" "))
self._fill_emoji_tab(txt, entries)
return txt
def _fill_emoji_tab(self, txt: tk.Text, entries):
txt.configure(state="normal")
txt.delete("1.0", "end")
photos = []
for i, (char, _name) in enumerate(entries):
photo = ImageTk.PhotoImage(emoji_thumbnail(char))
photos.append(photo) # keep references alive
txt.image_create("end", image=photo, padx=3, pady=1)
txt.tag_add(f"e{i}", "end-2c", "end-1c")
txt.configure(state="disabled")
txt.photos = photos
self.emoji_index[txt] = list(entries)
def _picker_entry(self, event):
txt = event.widget
for tag in txt.tag_names(f"@{event.x},{event.y}"):
if tag.startswith("e") and tag[1:].isdigit():
return self.emoji_index[txt][int(tag[1:])]
return None
def _picker_hover(self, event):
entry = self._picker_entry(event)
self.hover_name.configure(text=entry[1] if entry else " ")
def _picker_click(self, event):
entry = self._picker_entry(event)
if entry:
self.add_emoji(entry[0])
return "break"
def _search_changed(self):
q = self.search.get().strip().lower()
if not q:
self.nb.hide(self.search_tab.master)
return
hits = [
(c, n)
for entries in self.emoji_db.values()
for c, n in entries
if q in n.lower()
][:300]
self._fill_emoji_tab(self.search_tab, hits)
self.nb.add(self.search_tab.master, text=f"Search ({len(hits)})")
self.nb.select(self.search_tab.master)
def _build_printer_box(self, parent):
box = ttk.LabelFrame(parent, text="Printer", padding=6)
box.pack(fill="x", pady=(8, 0))
ttk.Label(box, text="Model").grid(row=0, column=0, sticky="w")
ttk.Combobox(
box, textvariable=self.model, values=list(MODELS), state="readonly", width=6
).grid(row=0, column=1, sticky="w")
ttk.Label(box, text="Via").grid(row=0, column=2, sticky="w", padx=(8, 0))
ttk.Combobox(
box,
textvariable=self.conn,
values=["bluetooth", "usb"],
state="readonly",
width=9,
).grid(row=0, column=3, sticky="w")
ttk.Label(box, text="Address").grid(row=1, column=0, sticky="w")
ttk.Entry(box, textvariable=self.addr, width=22).grid(
row=1, column=1, columnspan=3, sticky="we", pady=(4, 0)
)
ttk.Label(box, text="Density").grid(row=2, column=0, sticky="w")
ttk.Spinbox(box, from_=1, to=5, textvariable=self.density, width=4).grid(
row=2, column=1, sticky="w", pady=(4, 0)
)
ttk.Label(box, text="Copies").grid(row=2, column=2, sticky="w", padx=(8, 0))
ttk.Spinbox(box, from_=1, to=20, textvariable=self.copies, width=4).grid(
row=2, column=3, sticky="w", pady=(4, 0)
)
def _build_toolbar(self, parent):
bar = ttk.Frame(parent)
bar.grid(row=0, column=0, sticky="we")
def btn(text, cmd, **kw):
ttk.Button(bar, text=text, command=cmd, **kw).pack(side="left", padx=(0, 4))
def sep():
ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=6)
btn("+ Text", lambda: self.add_text("Text"))
btn("Duplicate", self.duplicate_selected)
btn("Delete", self.delete_selected)
sep()
btn("⟲ 90°", lambda: self.rotate(-90), width=6)
btn("⟳ 90°", lambda: self.rotate(90), width=6)
sep()
btn("Center H", lambda: self.center("h"))
btn("Center V", lambda: self.center("v"))
btn("Fit", self.fit_selected)
sep()
btn("Open…", self.open_design)
btn("Save…", self.save_design)
btn("New", self.new_design)
def _build_canvas(self, parent):
ttk.Label(
parent,
text="Drag items to move · arrow keys nudge (Shift = 10px) · Delete removes",
foreground="#555",
).grid(row=1, column=0, sticky="w", pady=(6, 2))
holder = tk.Frame(parent, bg="#777")
holder.grid(row=2, column=0, sticky="nsew")
self.canvas = tk.Canvas(holder, bg="#777", highlightthickness=0)
self.canvas.place(relx=0.5, rely=0.5, anchor="center")
self.canvas.bind("<ButtonPress-1>", self._canvas_press)
self.canvas.bind("<B1-Motion>", self._canvas_drag)
self.canvas.bind("<ButtonRelease-1>", lambda e: setattr(self, "_drag", None))
for key, dx, dy in (
("Left", -1, 0),
("Right", 1, 0),
("Up", 0, -1),
("Down", 0, 1),
):
self.canvas.bind(f"<{key}>", lambda e, dx=dx, dy=dy: self.nudge(dx, dy))
self.canvas.bind(
f"<Shift-{key}>", lambda e, dx=dx, dy=dy: self.nudge(dx * 10, dy * 10)
)
self.canvas.bind("<Delete>", lambda e: self.delete_selected())
self.canvas.bind("<BackSpace>", lambda e: self.delete_selected())
self.size_label = ttk.Label(parent, text="")
self.size_label.grid(row=3, column=0, sticky="w")
def _build_props(self, parent):
box = ttk.LabelFrame(parent, text="Selected item", padding=6)
box.grid(row=4, column=0, sticky="we", pady=(6, 0))
box.columnconfigure(0, weight=1)
self.prop_text = tk.Text(box, height=3, width=40, font=("DejaVu Sans", 12))
self.prop_text.grid(row=0, column=0, sticky="we")
self.prop_text.bind("<<Modified>>", self._prop_text_modified)
grid = ttk.Frame(box)
grid.grid(row=0, column=1, sticky="n", padx=(10, 0))
ttk.Label(grid, text="Size").grid(row=0, column=0, sticky="w")
ttk.Spinbox(grid, from_=8, to=300, textvariable=self.p_size, width=5).grid(
row=0, column=1, sticky="w"
)
ttk.Label(grid, text="Angle").grid(row=0, column=2, sticky="w", padx=(10, 0))
ttk.Spinbox(
grid, from_=-180, to=359, increment=15, textvariable=self.p_angle, width=5
).grid(row=0, column=3, sticky="w")
ttk.Label(grid, text="X").grid(row=1, column=0, sticky="w", pady=(4, 0))
ttk.Spinbox(grid, from_=-500, to=2000, textvariable=self.p_x, width=5).grid(
row=1, column=1, sticky="w", pady=(4, 0)
)
ttk.Label(grid, text="Y").grid(row=1, column=2, sticky="w", padx=(10, 0))
ttk.Spinbox(grid, from_=-500, to=2000, textvariable=self.p_y, width=5).grid(
row=1, column=3, sticky="w", pady=(4, 0)
)
style = ttk.Frame(grid)
style.grid(row=2, column=0, columnspan=4, sticky="w", pady=(4, 0))
self.bold_check = ttk.Checkbutton(style, text="Bold", variable=self.p_bold)
self.bold_check.pack(side="left")
self.align_btns = []
for a in ("left", "center", "right"):
b = ttk.Radiobutton(style, text=a.title(), value=a, variable=self.p_align)
b.pack(side="left", padx=(6, 0))
self.align_btns.append(b)
def _build_actions(self, parent):
bar = ttk.Frame(parent)
bar.grid(row=5, column=0, sticky="we", pady=(8, 0))
self.print_btn = ttk.Button(bar, text="Print", command=self.print_label)
self.print_btn.pack(side="left")
ttk.Button(bar, text="Save PNG…", command=self.save_png).pack(
side="left", padx=(6, 0)
)
ttk.Label(bar, textvariable=self.status).pack(side="right")
# ---- label size
def _apply_preset(self):
for name, w, length in LABEL_PRESETS:
if name == self.preset.get() and w is not None:
self.width_mm.set(w)
self.length_mm.set(length)
state = "disabled"
break
else:
state = "normal"
self.width_spin.configure(state=state)
self.length_spin.configure(state=state)
self.design.preset = self.preset.get()
self._size_changed()
def _size_changed(self):
try:
self.design.width_mm = max(1, self.width_mm.get())
self.design.length_mm = max(1, self.length_mm.get())
except tk.TclError:
return
w, h = self.design.size_px
self.zoom = max(1, min(4, 720 // w))
self.canvas.configure(width=w * self.zoom, height=h * self.zoom)
self.schedule_render()
# ---- items
def add_text(self, text: str):
w, h = self.design.size_px
item = Item("text", text, w // 2, h // 2, size=min(40, h // 2))
self.design.items.append(item)
self._select(item)
def add_emoji(self, char: str):
w, h = self.design.size_px
n = sum(1 for i in self.design.items if i.kind == "emoji")
x = min(w - 20, 30 + n * 20) if n else w // 2
size = min(40, round(h / EMOJI_SCALE) - 8)
item = Item("emoji", char, x, h // 2, size=size)
self.design.items.append(item)
self._select(item)
def duplicate_selected(self):
if self.selected:
copy = Item(**asdict(self.selected))
copy.x += 10
copy.y += 10
self.design.items.append(copy)
self._select(copy)
def delete_selected(self):
if self.selected in self.design.items:
self.design.items.remove(self.selected)
self._select(self.design.items[-1] if self.design.items else None)
def rotate(self, delta: int):
if self.selected:
self.p_angle.set((self.selected.angle + delta) % 360)
def center(self, axis: str):
if self.selected:
w, h = self.design.size_px
if axis == "h":
self.p_x.set(w // 2)
else:
self.p_y.set(h // 2)
def fit_selected(self):
"""Resize the selected item to fill the label, and centre it."""
item = self.selected
if not item:
return
w, h = self.design.size_px
size = 300
while size > 8:
item.size = size
sp = item.sprite()
if sp.width <= w - 8 and sp.height <= h - 8:
break
size -= max(1, size // 20)
self.p_size.set(item.size)
self.p_x.set(w // 2)
self.p_y.set(h // 2)
def nudge(self, dx: int, dy: int):
if self.selected:
self.p_x.set(self.selected.x + dx)
self.p_y.set(self.selected.y + dy)
def _select(self, item: Item | None):
self.selected = item
self._syncing = True
try:
self.prop_text.configure(state="normal")
self.prop_text.delete("1.0", "end")
if item:
self.prop_text.insert("1.0", item.text)
self.p_size.set(item.size)
self.p_angle.set(item.angle)
self.p_bold.set(item.bold)
self.p_align.set(item.align)
self.p_x.set(item.x)
self.p_y.set(item.y)
self.prop_text.edit_modified(False)
is_text = bool(item and item.kind == "text")
self.prop_text.configure(state="normal" if is_text else "disabled")
self.bold_check.configure(state="normal" if is_text else "disabled")
for b in self.align_btns:
b.configure(state="normal" if is_text else "disabled")
finally:
self._syncing = False
self.schedule_render()
def _props_changed(self):
if self._syncing or not self.selected:
return
try:
self.selected.size = self.p_size.get()
self.selected.angle = self.p_angle.get()
self.selected.bold = self.p_bold.get()
self.selected.align = self.p_align.get()
self.selected.x = self.p_x.get()
self.selected.y = self.p_y.get()
except tk.TclError:
return # spinbox mid-edit
self.schedule_render()
def _prop_text_modified(self, _event=None):
if self.prop_text.edit_modified():
self.prop_text.edit_modified(False)
if not self._syncing and self.selected and self.selected.kind == "text":
self.selected.text = self.prop_text.get("1.0", "end-1c")
self.schedule_render()
# ---- canvas
def _canvas_press(self, event):
self.canvas.focus_set()
x, y = event.x / self.zoom, event.y / self.zoom
hit = None
for item, box in reversed(list(zip(self.design.items, self._boxes))):
if box[0] - 3 <= x <= box[2] + 3 and box[1] - 3 <= y <= box[3] + 3:
hit = item
break
if hit is not self.selected:
self._select(hit)
if hit:
self._drag = (event.x, event.y, hit.x, hit.y)
def _canvas_drag(self, event):
if not self._drag or not self.selected:
return
sx, sy, ox, oy = self._drag
self.p_x.set(ox + round((event.x - sx) / self.zoom))
self.p_y.set(oy + round((event.y - sy) / self.zoom))
def schedule_render(self):
if self._render_job:
self.after_cancel(self._render_job)
self._render_job = self.after(30, self.render_canvas)
def render_canvas(self):
self._render_job = None
try:
img, self._boxes = self.design.render()
except (ValueError, OSError) as exc:
self.status.set(f"Render error: {exc}")
return
preview = img.convert("1").convert("L") # dithered, as the printer sees it
z = self.zoom
self._photo = ImageTk.PhotoImage(
preview.resize((img.width * z, img.height * z), Image.NEAREST)
)
self.canvas.delete("all")
self.canvas.create_image(0, 0, image=self._photo, anchor="nw")
if self.selected in self.design.items:
x0, y0, x1, y1 = self._boxes[self.design.items.index(self.selected)]
self.canvas.create_rectangle(
x0 * z - 2, y0 * z - 2, x1 * z + 2, y1 * z + 2,
outline="#1e88e5", width=2, dash=(4, 3),
) # fmt: skip
w, h = img.size
model = MODELS[self.model.get()]
warn = ""
if h > model.max_width_px:
warn = f" ⚠ too wide for {model.name.upper()} (max {model.max_width_px}px)"
self.size_label.configure(
text=f"{w} x {h} px ({self.design.length_mm} x {self.design.width_mm} mm)"
f" · {len(self.design.items)} items{warn}"
)
# ---- designs
def new_design(self):
if self.design.items and not messagebox.askyesno(
"New label", "Clear all items?"
):
return
self.design.items.clear()
self._select(None)
def save_design(self):
path = filedialog.asksaveasfilename(
defaultextension=".json", filetypes=[("Label design", "*.json")]
)
if path:
Path(path).write_text(json.dumps(self.design.to_dict(), indent=2))
self.status.set(f"Saved {Path(path).name}")
def open_design(self):
path = filedialog.askopenfilename(filetypes=[("Label design", "*.json")])
if not path:
return
try:
self.design = Design.from_dict(json.loads(Path(path).read_text()))
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Cannot open", str(exc))
return
self.preset.set(self.design.preset)
self.width_mm.set(self.design.width_mm)
self.length_mm.set(self.design.length_mm)
self._select(self.design.items[-1] if self.design.items else None)
def save_png(self):
path = filedialog.asksaveasfilename(
defaultextension=".png", filetypes=[("PNG image", "*.png")]
)
if path:
self.design.render()[0].convert("1").save(path)
self.status.set(f"Saved {Path(path).name}")
# ---- printing
def _connect(self) -> PrinterClient:
if self._printer is None:
addr = self.addr.get().strip()
if self.conn.get() == "bluetooth":
if not re.fullmatch(r"([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}", addr):
raise ValueError("Enter the printer's Bluetooth MAC address")
transport = BluetoothTransport(addr.upper())
else:
transport = SerialTransport(port=addr or "auto")
self._printer = PrinterClient(transport)
return self._printer
def _disconnect(self):
self._printer = None
def print_label(self):
try:
image = self.design.render()[0].convert("1")
image, density = prepare_print(
self.model.get(), image, self.density.get(), rotate=90
)
except (ValueError, tk.TclError) as exc:
messagebox.showerror("Cannot print", str(exc))
return
copies = max(1, self.copies.get())
self.print_btn.configure(state="disabled")
self.status.set("Connecting…")
self._save_settings()
def work():
try:
printer = self._connect()
for i in range(copies):
self.after(0, self.status.set, f"Printing {i + 1}/{copies}…")
printer.print_image(image, density=density, model=self.model.get())
self.after(0, self._print_done, None)
except Exception as exc: # noqa: BLE001 — surface everything to the UI
self._disconnect()
self.after(0, self._print_done, exc)
threading.Thread(target=work, daemon=True).start()
def _print_done(self, exc):
self.print_btn.configure(state="normal")
if exc is None:
self.status.set("Printed")
else:
self.status.set("Print failed")
is_prn = isinstance(exc, PrinterError)
kind = "Printer error" if is_prn else type(exc).__name__
messagebox.showerror(kind, str(exc) or repr(exc))
# ---- settings
def _load_settings(self) -> dict:
try:
return json.loads(SETTINGS_PATH.read_text())
except (OSError, ValueError):
return {}
def _save_settings(self):
data = {
"model": self.model.get(),
"conn": self.conn.get(),
"addr": self.addr.get(),
"density": self.density.get(),
"design": self.design.to_dict(),
}
try:
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_PATH.write_text(json.dumps(data, indent=2))
except (OSError, tk.TclError):
pass
def _on_close(self):
self._save_settings()
self.destroy()
if __name__ == "__main__":
LabelApp().mainloop()