diff --git a/label_app.py b/label_app.py index 00ab181..ff89cf1 100644 --- a/label_app.py +++ b/label_app.py @@ -1,13 +1,19 @@ -"""Simple label designer for Niimbot printers: text + emoji, live preview, print.""" +"""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 -from PIL import Image, ImageDraw, ImageFont, ImageTk +import emoji_data_python +from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageTk from niimprint import ( MODELS, @@ -29,6 +35,8 @@ TEXT_FONTS = { 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 = [ @@ -39,26 +47,54 @@ LABEL_PRESETS = [ ("Custom", None, None), ] -EMOJI_CATEGORIES = { - "Smileys": "๐Ÿ˜€ ๐Ÿ˜ƒ ๐Ÿ˜„ ๐Ÿ˜ ๐Ÿ˜† ๐Ÿ˜… ๐Ÿ˜‚ ๐Ÿ™‚ ๐Ÿ˜‰ ๐Ÿ˜Š ๐Ÿ˜ ๐Ÿ˜˜ ๐Ÿ˜Ž ๐Ÿค” ๐Ÿ˜ ๐Ÿ˜ด ๐Ÿ˜ท ๐Ÿค’ ๐Ÿ˜ญ ๐Ÿ˜ก ๐Ÿคฏ ๐Ÿฅณ ๐Ÿค  ๐Ÿคก ๐Ÿ‘ป ๐Ÿ’€ ๐Ÿ‘ฝ ๐Ÿค– ๐Ÿ˜บ", - "People": "๐Ÿ‘ ๐Ÿ‘Ž ๐Ÿ‘‹ โœŒ๏ธ ๐Ÿคž ๐Ÿ‘Œ ๐Ÿ™ ๐Ÿ’ช ๐Ÿ‘€ ๐Ÿง  ๐Ÿ‘ถ ๐Ÿ‘ง ๐Ÿ‘ฆ ๐Ÿ‘ฉ ๐Ÿ‘จ ๐Ÿ‘ต ๐Ÿ‘ด ๐Ÿง‘โ€๐Ÿ’ป ๐Ÿ‘ฉโ€๐Ÿ”ฌ ๐Ÿ‘จโ€๐Ÿณ ๐Ÿง‘โ€๐Ÿ”ง ๐Ÿ‘ฎ ๐Ÿ•ต๏ธ ๐Ÿ’", - "Home": "๐Ÿ  ๐Ÿ›๏ธ ๐Ÿ›‹๏ธ ๐Ÿšช ๐ŸชŸ ๐Ÿงน ๐Ÿงบ ๐Ÿงผ ๐Ÿงฝ ๐Ÿชฅ ๐Ÿงป ๐Ÿชฃ ๐Ÿ”‘ ๐Ÿ”’ ๐Ÿ”“ ๐Ÿ’ก ๐Ÿ”Œ ๐Ÿ”‹ ๐Ÿช› ๐Ÿ”ง ๐Ÿ”จ ๐Ÿชš ๐Ÿงฐ ๐Ÿ“ฆ ๐Ÿ—‘๏ธ โ™ป๏ธ", - "Food": "๐ŸŽ ๐ŸŒ ๐Ÿ‡ ๐Ÿ“ ๐Ÿ’ ๐Ÿฅ• ๐ŸŒฝ ๐Ÿฅฆ ๐Ÿง„ ๐Ÿง… ๐Ÿž ๐Ÿง€ ๐Ÿฅš ๐Ÿฅ› ๐Ÿ— ๐Ÿฅฉ ๐Ÿ• ๐Ÿ” ๐ŸŒฎ ๐Ÿ ๐Ÿš ๐Ÿช ๐Ÿฐ โ˜• ๐Ÿต ๐Ÿงƒ ๐Ÿบ ๐Ÿท ๐Ÿง‚ ๐Ÿฏ", - "Objects": "๐Ÿ“ฑ ๐Ÿ’ป ๐Ÿ–ฅ๏ธ โŒจ๏ธ ๐Ÿ–ฑ๏ธ ๐Ÿ–จ๏ธ ๐Ÿ“ท ๐ŸŽง ๐Ÿ“š ๐Ÿ“– ๐Ÿ“ โœ๏ธ ๐Ÿ“Ž ๐Ÿ“Œ ๐Ÿ“ โœ‚๏ธ ๐Ÿ—‚๏ธ ๐Ÿ“ ๐Ÿ“… โฐ ๐Ÿ’Š ๐Ÿฉน ๐Ÿงช ๐Ÿ”ฌ ๐Ÿ”ญ ๐ŸŽ ๐ŸŽˆ ๐Ÿงธ ๐ŸŽฎ ๐ŸŽฒ", - "Nature": "๐ŸŒž ๐ŸŒ™ โญ โšก ๐Ÿ”ฅ ๐Ÿ’ง โ„๏ธ ๐ŸŒˆ ๐ŸŒธ ๐ŸŒป ๐ŸŒต ๐ŸŒฒ ๐Ÿ€ ๐Ÿถ ๐Ÿฑ ๐Ÿญ ๐Ÿฐ ๐ŸฆŠ ๐Ÿป ๐Ÿผ ๐Ÿธ ๐Ÿข ๐Ÿ ๐Ÿฆ‹ ๐ŸŸ", - "Symbols": "โค๏ธ ๐Ÿงก ๐Ÿ’› ๐Ÿ’š ๐Ÿ’™ ๐Ÿ’œ ๐Ÿ–ค โœ… โŒ โš ๏ธ โ›” ๐Ÿšซ โ— โ“ ๐Ÿ’ฏ ๐Ÿ”ด ๐ŸŸข ๐Ÿ”ต โฌ†๏ธ โฌ‡๏ธ โžก๏ธ โฌ…๏ธ โ™ฅ๏ธ โ˜… โ˜† โœ” โœ– โ˜ ", - "Transport": "๐Ÿš— ๐Ÿš• ๐ŸšŒ ๐Ÿšฒ ๐Ÿ›ต ๐Ÿ๏ธ ๐Ÿš‚ โœˆ๏ธ ๐Ÿš€ โ›ต ๐Ÿš ๐Ÿ›’ ๐Ÿงณ ๐Ÿ—บ๏ธ ๐Ÿงญ", -} +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"), +] -# Matches one emoji cluster: base + optional VS16 + any ZWJ-joined continuations + +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 = ( - r"[\U0001F000-\U0001FAFF\u2600-\u27BF\u2B00-\u2BFF\u2300-\u23FF\u2190-\u21FF" - r"\u25A0-\u25FF\u2900-\u297F\u3030\u303D\u3297\u3299\u00A9\u00AE\u2122]" -) -EMOJI_RE = re.compile( - rf"(?:{_EMOJI_BASE}[\uFE0F\uFE0E]?[\U0001F3FB-\U0001F3FF]?" - rf"(?:\u200D{_EMOJI_BASE}[\uFE0F\uFE0E]?)*)" + "[" + + _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 @@ -66,20 +102,34 @@ EMOJI_RE = re.compile( _emoji_font = None _emoji_cache: dict[tuple[str, int], Image.Image] = {} +_text_font_cache: dict[tuple[bool, int], ImageFont.FreeTypeFont] = {} -def render_emoji(cluster: str, height: int) -> Image.Image: - """Render one emoji cluster as a grayscale tile of the given height.""" +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 - key = (cluster, height) - if key in _emoji_cache: - return _emoji_cache[key] 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) - tile = tile.crop((left, top, right, bottom)) + 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]) @@ -91,6 +141,13 @@ def render_emoji(cluster: str, height: int) -> Image.Image: 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 @@ -103,13 +160,14 @@ def _tokenize(line: str): yield ("text", line[pos:]) -def _layout(lines, font, font_size): - """Return (rows, block_w, block_h). Each row is (width, [(kind, obj, w)]).""" - emoji_h = round(font_size * 1.15) +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 lines: + for line in text.splitlines() or [""]: runs, width = [], 0 for kind, s in _tokenize(line): if kind == "text": @@ -121,46 +179,21 @@ def _layout(lines, font, font_size): runs.append(("emoji", tile, w)) width += w rows.append((width, runs)) - block_w = max((w for w, _ in rows), default=0) - return rows, block_w, line_h * len(rows), line_h + block_w = max(1, max(w for w, _ in rows)) + block_h = line_h * len(rows) - -def render_label( - text: str, - width_px: int, - height_px: int, - font_size: int = 40, - bold: bool = True, - align: str = "center", - auto_fit: bool = True, - padding: int = 4, -) -> Image.Image: - """Render text (with emoji) into a landscape grayscale label image.""" - lines = text.splitlines() or [""] - font_path = TEXT_FONTS["bold" if bold else "regular"] - size = font_size - while True: - font = ImageFont.truetype(font_path, size) - rows, block_w, block_h, line_h = _layout(lines, font, size) - fits = block_w <= width_px - 2 * padding and block_h <= height_px - 2 * padding - if fits or not auto_fit or size <= 8: - break - size -= 1 - - img = Image.new("L", (width_px, height_px), 255) + img = Image.new("L", (block_w, block_h), 255) draw = ImageDraw.Draw(img) - ascent, _ = font.getmetrics() - y = (height_px - block_h) // 2 + y = 0 for row_w, runs in rows: if align == "left": - x = padding + x = 0 elif align == "right": - x = width_px - padding - row_w + x = block_w - row_w else: - x = (width_px - row_w) // 2 + x = (block_w - row_w) // 2 for kind, obj, w in runs: if kind == "text": - # Baseline-align text within the row 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)) @@ -169,6 +202,72 @@ def render_label( 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 @@ -176,176 +275,319 @@ class LabelApp(tk.Tk): def __init__(self): super().__init__() self.title("Niimbot Label Designer") - self.minsize(900, 560) + self.minsize(1100, 640) self.settings = self._load_settings() - self._preview_job = None + 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._preview_photo = None + self._photo = None + self._syncing = False + self._drag = None + self.zoom = 2 - self.preset = tk.StringVar(value=self.settings.get("preset", "12 x 40 mm")) - self.width_mm = tk.IntVar(value=self.settings.get("width_mm", 12)) - self.length_mm = tk.IntVar(value=self.settings.get("length_mm", 40)) - self.font_size = tk.IntVar(value=self.settings.get("font_size", 40)) - self.auto_fit = tk.BooleanVar(value=self.settings.get("auto_fit", True)) - self.bold = tk.BooleanVar(value=self.settings.get("bold", True)) - self.align = tk.StringVar(value=self.settings.get("align", "center")) + 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() - for var in ( - self.width_mm, self.length_mm, self.font_size, self.auto_fit, - self.bold, self.align, - ): # fmt: skip - var.trace_add("write", lambda *_: self.schedule_preview()) 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=10) + 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, 10)) + left.grid(row=0, column=0, sticky="nsw", padx=(0, 8)) right = ttk.Frame(root) right.grid(row=0, column=1, sticky="nsew") - right.rowconfigure(1, weight=1) right.columnconfigure(0, weight=1) + right.rowconfigure(2, weight=1) - # Label size - size_box = ttk.LabelFrame(left, text="Label", padding=6) - size_box.pack(fill="x") + 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( - size_box, + box, textvariable=self.preset, values=[p[0] for p in LABEL_PRESETS], state="readonly", - width=14, - ).grid(row=0, column=0, columnspan=4, sticky="w") - ttk.Label(size_box, text="Width mm").grid(row=1, column=0, sticky="w") + 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( - size_box, from_=6, to=60, textvariable=self.width_mm, width=5 + box, from_=6, to=60, textvariable=self.width_mm, width=4 ) - self.width_spin.grid(row=1, column=1) - ttk.Label(size_box, text="Length mm").grid(row=1, column=2, sticky="w") + 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( - size_box, from_=10, to=200, textvariable=self.length_mm, width=5 + box, from_=10, to=200, textvariable=self.length_mm, width=4 ) - self.length_spin.grid(row=1, column=3) + self.length_spin.grid(row=0, column=5) - # Text style - style_box = ttk.LabelFrame(left, text="Text", padding=6) - style_box.pack(fill="x", pady=(8, 0)) - ttk.Label(style_box, text="Size").grid(row=0, column=0, sticky="w") - ttk.Spinbox( - style_box, from_=8, to=200, textvariable=self.font_size, width=5 - ).grid(row=0, column=1, sticky="w") - ttk.Checkbutton(style_box, text="Auto-fit", variable=self.auto_fit).grid( - row=0, column=2, sticky="w", padx=(8, 0) + 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) ) - ttk.Checkbutton(style_box, text="Bold", variable=self.bold).grid( - row=0, column=3, sticky="w" + 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, ) - align_row = ttk.Frame(style_box) - align_row.grid(row=1, column=0, columnspan=4, sticky="w", pady=(4, 0)) - for a in ("left", "center", "right"): - ttk.Radiobutton( - align_row, text=a.title(), value=a, variable=self.align - ).pack(side="left") + 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("", self._picker_click) + txt.bind("", self._picker_hover) + txt.bind("", lambda e: self.hover_name.configure(text=" ")) + self._fill_emoji_tab(txt, entries) + return txt - # Emoji picker - emoji_box = ttk.LabelFrame(left, text="Emoji (click to insert)", padding=4) - emoji_box.pack(fill="both", expand=True, pady=(8, 0)) - nb = ttk.Notebook(emoji_box) - nb.pack(fill="both", expand=True) - for name, emojis in EMOJI_CATEGORIES.items(): - tab = ttk.Frame(nb) - nb.add(tab, text=name) - for i, e in enumerate(emojis.split()): - tk.Button( - tab, - text=e, - font=("Noto Color Emoji", 14), - width=2, - relief="flat", - command=lambda e=e: self.insert_emoji(e), - ).grid(row=i // 8, column=i % 8, padx=1, pady=1) + 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) - # Printer - prn_box = ttk.LabelFrame(left, text="Printer", padding=6) - prn_box.pack(fill="x", pady=(8, 0)) - ttk.Label(prn_box, text="Model").grid(row=0, column=0, sticky="w") + 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( - prn_box, - textvariable=self.model, - values=list(MODELS), - state="readonly", - width=6, + box, textvariable=self.model, values=list(MODELS), state="readonly", width=6 ).grid(row=0, column=1, sticky="w") - ttk.Label(prn_box, text="Via").grid(row=0, column=2, sticky="w", padx=(8, 0)) + ttk.Label(box, text="Via").grid(row=0, column=2, sticky="w", padx=(8, 0)) ttk.Combobox( - prn_box, + box, textvariable=self.conn, values=["bluetooth", "usb"], state="readonly", width=9, ).grid(row=0, column=3, sticky="w") - ttk.Label(prn_box, text="Address").grid(row=1, column=0, sticky="w") - ttk.Entry(prn_box, textvariable=self.addr, width=22).grid( + 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(prn_box, text="Density").grid(row=2, column=0, sticky="w") - ttk.Spinbox(prn_box, from_=1, to=5, textvariable=self.density, width=4).grid( + 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(prn_box, text="Copies").grid(row=2, column=2, sticky="w", padx=(8, 0)) - ttk.Spinbox(prn_box, from_=1, to=20, textvariable=self.copies, width=4).grid( + 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) ) - for var in (self.model, self.conn, self.addr): - var.trace_add("write", lambda *_: self._disconnect()) - # Right side: editor + preview + actions - ttk.Label(right, text="Label text (one line per row):").grid( - row=0, column=0, sticky="w" - ) - self.text = tk.Text( - right, height=5, font=("DejaVu Sans", 14), wrap="word", undo=True - ) - self.text.grid(row=1, column=0, sticky="nsew") - self.text.insert("1.0", self.settings.get("text", "Hello ๐Ÿ‘‹")) - self.text.bind("<>", self._on_text_modified) + def _build_toolbar(self, parent): + bar = ttk.Frame(parent) + bar.grid(row=0, column=0, sticky="we") - ttk.Label(right, text="Preview (actual print, 2x zoom):").grid( - row=2, column=0, sticky="w", pady=(8, 0) - ) - self.preview = tk.Label(right, bg="#888", anchor="center") - self.preview.grid(row=3, column=0, sticky="we", ipady=10) - self.size_label = ttk.Label(right, text="") - self.size_label.grid(row=4, column=0, sticky="w") + def btn(text, cmd, **kw): + ttk.Button(bar, text=text, command=cmd, **kw).pack(side="left", padx=(0, 4)) - actions = ttk.Frame(right) - actions.grid(row=5, column=0, sticky="we", pady=(10, 0)) - self.print_btn = ttk.Button(actions, text="Print", command=self.print_label) + 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("", self._canvas_press) + self.canvas.bind("", self._canvas_drag) + self.canvas.bind("", 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"", lambda e, dx=dx, dy=dy: self.nudge(dx * 10, dy * 10) + ) + self.canvas.bind("", lambda e: self.delete_selected()) + self.canvas.bind("", 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("<>", 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(actions, text="Save PNGโ€ฆ", command=self.save_png).pack( + ttk.Button(bar, text="Save PNGโ€ฆ", command=self.save_png).pack( side="left", padx=(6, 0) ) - ttk.Button(actions, text="Clear", command=self.clear_text).pack( - side="left", padx=(6, 0) - ) - ttk.Label(actions, textvariable=self.status).pack(side="right") + ttk.Label(bar, textvariable=self.status).pack(side="right") - # ---- helpers + # ---- label size def _apply_preset(self): for name, w, length in LABEL_PRESETS: @@ -358,63 +600,224 @@ class LabelApp(tk.Tk): state = "normal" self.width_spin.configure(state=state) self.length_spin.configure(state=state) - self.schedule_preview() + self.design.preset = self.preset.get() + self._size_changed() - def _px_size(self) -> tuple[int, int]: - """Landscape (width, height) in px: length along x, tape width along y.""" - return self.length_mm.get() * PX_PER_MM, self.width_mm.get() * PX_PER_MM - - def _on_text_modified(self, _event=None): - if self.text.edit_modified(): - self.text.edit_modified(False) - self.schedule_preview() - - def insert_emoji(self, emoji: str): - self.text.insert("insert", emoji) - self.text.focus_set() - - def clear_text(self): - self.text.delete("1.0", "end") - - def schedule_preview(self): - if self._preview_job: - self.after_cancel(self._preview_job) - self._preview_job = self.after(150, self.update_preview) - - def current_image(self) -> Image.Image: - w, h = self._px_size() - return render_label( - self.text.get("1.0", "end-1c"), - w, - h, - font_size=max(8, self.font_size.get()), - bold=self.bold.get(), - align=self.align.get(), - auto_fit=self.auto_fit.get(), - ) - - def update_preview(self): - self._preview_job = None + def _size_changed(self): try: - img = self.current_image().convert("1") # dithered, as the printer sees it - except (tk.TclError, ValueError): + 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 - zoom = img.resize((img.width * 2, img.height * 2), Image.NEAREST) - self._preview_photo = ImageTk.PhotoImage(zoom) - self.preview.configure(image=self._preview_photo) + 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{warn}") + 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.current_image().convert("1").save(path) + self.design.render()[0].convert("1").save(path) self.status.set(f"Saved {Path(path).name}") # ---- printing @@ -436,7 +839,7 @@ class LabelApp(tk.Tk): def print_label(self): try: - image = self.current_image().convert("1") + image = self.design.render()[0].convert("1") image, density = prepare_print( self.model.get(), image, self.density.get(), rotate=90 ) @@ -481,18 +884,11 @@ class LabelApp(tk.Tk): def _save_settings(self): data = { - "preset": self.preset.get(), - "width_mm": self.width_mm.get(), - "length_mm": self.length_mm.get(), - "font_size": self.font_size.get(), - "auto_fit": self.auto_fit.get(), - "bold": self.bold.get(), - "align": self.align.get(), "model": self.model.get(), "conn": self.conn.get(), "addr": self.addr.get(), "density": self.density.get(), - "text": self.text.get("1.0", "end-1c"), + "design": self.design.to_dict(), } try: SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) diff --git a/pyproject.toml b/pyproject.toml index 757b6c9..b668c90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ python = "~3.11" pyserial = "^3.5" pillow = "^10.1.0" click = "^8.1.7" +emoji-data-python = "^1.6" [build-system] requires = ["poetry-core"] diff --git a/readme.md b/readme.md index 548cc5a..360efb6 100644 --- a/readme.md +++ b/readme.md @@ -82,15 +82,18 @@ python niimprint -c bluetooth -a "E2:E1:08:03:09:87" -r 90 -i examples/B21_80x50 ## Label designer app -`label_app.py` is a small Tkinter app for designing and printing text + emoji labels: +`label_app.py` is a Tkinter app for designing and printing labels with text and emoji: ``` python label_app.py ``` -- Pick a label size preset (or custom mm), type text (one line per row), click emojis to insert them -- Live 1-bit preview shows exactly what the printer will produce -- Auto-fit shrinks the font until the text fits the label -- Printer model / connection / address / density are remembered in `~/.config/niimprint-label/settings.json` +- Pick a label size preset (or custom mm) +- **+ Text** adds a text block; click any emoji in the picker (all ~1,900, searchable by name) to add it +- Click an item to select it, drag to move, arrow keys nudge (Shift = 10 px), Delete removes +- Selected item panel: edit text, size, angle (any degrees, or the โŸฒ/โŸณ 90ยฐ buttons), X/Y, bold, alignment +- **Fit** resizes the item to fill the label; **Center H/V** aligns it +- The canvas shows the dithered 1-bit image exactly as the printer will produce it +- **Saveโ€ฆ/Openโ€ฆ** store designs as JSON; the last design and printer settings are restored on launch -Fonts: DejaVu Sans (`/usr/share/fonts/TTF/`) for text and Noto Color Emoji (`/usr/share/fonts/noto/`) for emoji; edit `TEXT_FONTS` / `EMOJI_FONT` at the top of the file for other distros. +Fonts: DejaVu Sans (`/usr/share/fonts/TTF/`) for text and Noto Color Emoji (`/usr/share/fonts/noto/`) for emoji; edit `TEXT_FONTS` / `EMOJI_FONT` at the top of the file for other distros. Emoji are rendered by Pillow with the same font that prints, so the picker is WYSIWYG. diff --git a/requirements.txt b/requirements.txt index 0964750..dc2844d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ click==8.1.7 pillow==10.1.0 pyserial==3.5 +emoji-data-python==1.6.0