diff --git a/docs/app-dark.png b/docs/app-dark.png new file mode 100644 index 0000000..a6e7fb4 Binary files /dev/null and b/docs/app-dark.png differ diff --git a/docs/app-light.png b/docs/app-light.png new file mode 100644 index 0000000..5c37ec0 Binary files /dev/null and b/docs/app-light.png differ diff --git a/docs/sample-label.png b/docs/sample-label.png new file mode 100644 index 0000000..70cec5b Binary files /dev/null and b/docs/sample-label.png differ diff --git a/label_app.py b/label_app.py index b7b5ee2..0460b63 100644 --- a/label_app.py +++ b/label_app.py @@ -51,16 +51,17 @@ LABEL_PRESETS = [ ("Custom", None, None), ] +# (tab label, emoji-data category, tab icon) 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"), + ("Smileys", "Smileys & Emotion", "😀"), + ("People", "People & Body", "👍"), + ("Animals", "Animals & Nature", "🐶"), + ("Food", "Food & Drink", "🍎"), + ("Travel", "Travel & Places", "🚗"), + ("Activities", "Activities", "⚽"), + ("Objects", "Objects", "💡"), + ("Symbols", "Symbols", "❤️"), + ("Flags", "Flags", "🏁"), ] @@ -92,8 +93,8 @@ EMOJI_RE = re.compile(f"(?:{_EMOJI_BASE}{_VS}{_SKIN}(?:{_ZWJ}{_EMOJI_BASE}{_VS}) 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} + 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: @@ -282,7 +283,7 @@ class LabelApp(ttk.Window): super().__init__( title="Niimbot Label Designer", themename=THEMES.get(self.theme_mode, "flatly"), - minsize=(1100, 680), + minsize=(1200, 720), ) self.option_add("*Font", UI_FONT) self.design = Design.from_dict(self.settings.get("design", {})) @@ -393,19 +394,29 @@ class LabelApp(ttk.Window): self.hover_name = ttk.Label(box, text=" ", bootstyle="secondary") self.hover_name.pack(fill="x", pady=(4, 2)) - self.nb = ttk.Notebook(box, width=420, height=260) + self.nb = ttk.Notebook(box, width=400, height=300) 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._tab_icons = [] + for label, _, icon in EMOJI_CATEGORIES: + photo = ImageTk.PhotoImage(emoji_thumbnail(icon, 22)) + self._tab_icons.append(photo) + self._make_emoji_tab(label, self.emoji_db[label], image=photo) + self.search_tab = self._make_emoji_tab("🔍", []) self.nb.hide(self.search_tab.master) + self.nb.bind("<>", self._tab_changed) - def _make_emoji_tab(self, label, entries) -> tk.Text: + def _make_emoji_tab(self, label, entries, image=None) -> tk.Text: frame = ttk.Frame(self.nb) - self.nb.add(frame, text=label) + frame.category = label + if image is not None: + self.nb.add(frame, image=image, padding=(6, 2)) + else: + self.nb.add(frame, text=label) txt = tk.Text( frame, + width=1, # let the notebook decide the width + height=1, wrap="char", cursor="hand2", spacing1=2, @@ -444,6 +455,10 @@ class LabelApp(ttk.Window): return self.emoji_index[txt][int(tag[1:])] return None + def _tab_changed(self, _event=None): + frame = self.nametowidget(self.nb.select()) + self.hover_name.configure(text=getattr(frame, "category", " ")) + def _picker_hover(self, event): entry = self._picker_entry(event) self.hover_name.configure(text=entry[1] if entry else " ") @@ -466,7 +481,7 @@ class LabelApp(ttk.Window): 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.add(self.search_tab.master, text=f"🔍 {len(hits)}") self.nb.select(self.search_tab.master) def _build_printer_box(self, parent): @@ -519,18 +534,6 @@ class LabelApp(ttk.Window): btn("Center H", lambda: self.center("h")) btn("Center V", lambda: self.center("v")) btn("Fit", self.fit_selected) - sep() - btn("New", self.new_design, "link") - btn("Open…", self.open_design, "link") - btn("Save…", self.save_design, "link") - self.theme_btn = ttk.Button( - bar, - text="☾", - width=3, - bootstyle="secondary-link", - command=self.toggle_theme, - ) - self.theme_btn.pack(side="right") def _build_canvas(self, parent): ttk.Label( @@ -567,7 +570,7 @@ class LabelApp(ttk.Window): self.prop_text = tk.Text( box, height=3, - width=40, + width=20, font=("DejaVu Sans", 12), relief="flat", padx=6, @@ -618,8 +621,22 @@ class LabelApp(ttk.Window): ttk.Button( bar, text="Save PNG…", command=self.save_png, bootstyle="secondary-outline" ).pack(side="left", padx=(8, 0), ipady=4) + self.theme_btn = ttk.Button( + bar, + text="☾", + width=3, + bootstyle="secondary-link", + command=self.toggle_theme, + ) + self.theme_btn.pack(side="right") + for text, cmd in ( + ("Save…", self.save_design), + ("Open…", self.open_design), + ("New", self.new_design), + ): + ttk.Button(bar, text=text, command=cmd, bootstyle="link").pack(side="right") ttk.Label(bar, textvariable=self.status, bootstyle="secondary").pack( - side="right" + side="right", padx=(0, 12) ) # ---- theme diff --git a/readme.md b/readme.md index 702ab2e..ac52a75 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,18 @@ -# `niimprint` — Niimbot Printer Client +# `niimprint` — Niimbot Printer Client + Label Designer -**Fork changelog & differences from original version:** +Print to Niimbot label printers (B1, B18, B21, D11, D101, D110) over Bluetooth or USB, from a CLI or a desktop label designer with full emoji support. + +![Label designer, light theme](docs/app-light.png) + +Fork of [AndBondStyle/niimprint](https://github.com/AndBondStyle/niimprint). Changes in this fork: + +- **Label designer app** (`label_app.py`): drag-and-drop text and emoji, rotation, live 1-bit preview, light/dark themes +- Printer model table (`niimprint/models.py`) with per-model width / density / protocol quirks; adds **D101** +- Ported upstream PRs [#28](https://github.com/AndBondStyle/niimprint/pull/28) and [#12](https://github.com/AndBondStyle/niimprint/pull/12): D-series `ALLOW_PRINT_CLEAR`/`SET_QUANTITY` (fixes tall labels), print-status polling instead of a fixed sleep +- `PrinterError` raised on timeouts / rejected commands instead of returning `None` +- Tested on a D101 (firmware 20.51) over Bluetooth + +Upstream's own changelog vs. the original project: - Tested on Niimbot B1, B18, B21, D11, D110 and Python 3.11 - Added transport abstraction: switch between bluetooth and USB (serial) @@ -8,11 +20,24 @@ - Switched to [click](https://click.palletsprojects.com/) CLI library instead of argparse - Integrated [pyproject.toml](https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/) and [poetry](https://python-poetry.org) - Integrated [pre-commit](https://pre-commit.com/) and [ruff](https://docs.astral.sh/ruff/), re-formatted all files -- Miscellaneous refactoring / file renaming / etc. ## Installation -Recommended method is to use [poetry](https://python-poetry.org) and install with `poetry install`. However `requirements.txt` is also provided for convenience. Project is tested on Python 3.11, but should work on other versions. +``` +git clone https://git.gracious.one/gracious_admin/NIIMBOT-D101.git +cd NIIMBOT-D101 +python -m venv --system-site-packages .venv # system Python gives you tkinter +.venv/bin/pip install -r requirements.txt +``` + +Or with [poetry](https://python-poetry.org): `poetry install`. Python 3.10+ (uses `match`); tested on 3.13. + +The label designer needs two fonts, both standard on most distros: + +- DejaVu Sans — `/usr/share/fonts/TTF/DejaVuSans*.ttf` (Arch) or `/usr/share/fonts/truetype/dejavu/` (Debian) +- Noto Color Emoji — `/usr/share/fonts/noto/NotoColorEmoji.ttf` + +Edit `TEXT_FONTS` / `EMOJI_FONT` at the top of `label_app.py` if yours live elsewhere. ## Usage @@ -58,6 +83,8 @@ It seems like B21 and B1 (and maybe other models?) have two bluetooth adresses. To identify which address is the correct one, run `bluetoothctl info` on the address you want to check. The incorrect one might list `UUID: Generic Access Profile` and `UUID: Generic Attribute Profile`, while the correct one will list `UUID: Serial Port`. +The D101 does the same: it advertises as `D101-` on two addresses (e.g. `94:11:02:…` and `11:02:94:…`); use the one whose `bluetoothctl info` lists `UUID: Serial Port`, then `pair` and `trust` it once. + ## Examples **B21, USB connection, 30x15 mm (240x120 px) label** @@ -76,25 +103,52 @@ python niimprint -c bluetooth -a "E2:E1:08:03:09:87" -r 90 -i examples/B21_80x50 [![](examples/B21_80x50_result.png)]() +## Label designer app + +``` +.venv/bin/python label_app.py +``` + +| Light | Dark | +|---|---| +| ![light](docs/app-light.png) | ![dark](docs/app-dark.png) | + +The 40 × 12 mm label above, exactly as sent to the printer (320 × 96 px, 1-bit): + +![sample label](docs/sample-label.png) + +### Designing + +- **Label size** — pick a preset (12 × 40, 12 × 30, 12 × 22, 15 × 30 mm) or *Custom* and type width / length in mm. 8 px per mm. +- **+ Text** adds a text block. Type in the *Selected item* box; multi-line is fine, and emoji typed inline are rendered too. +- **Emoji** — click any emoji in the picker to add it as its own item. The tabs are 😀 Smileys, 👍 People, 🐶 Animals, 🍎 Food, 🚗 Travel, ⚽ Activities, 💡 Objects, ❤️ Symbols, 🏁 Flags (~1,900 in total); the *Search* box filters by name; hovering shows the name. +- **Move** — drag on the canvas, arrow keys nudge 1 px (Shift = 10 px), or type X / Y. **Center H / V** snap to the middle. +- **Rotate** — ⟲ / ⟳ 90° buttons, or any angle in the *Angle* spinbox (15° steps). +- **Size / Bold / Align** — per item. **Fit** sizes the selected item to fill the label. +- **Duplicate / Delete** (or the Delete key). +- The canvas shows the dithered black-and-white image the printer will actually produce, at 2–4× zoom depending on label size. Emoji are darkened (gamma 3) so pale colours don't dither away on thermal paper. + +### Printing & files + +- **Print** — connects lazily and prints in the background; *Copies* repeats the label. Errors show in a dialog. +- **Save PNG…** exports the 1-bit image; **Save… / Open…** store the design as JSON; **New** clears the label. +- Printer model, connection, address, density, theme, and the last design are remembered in `~/.config/niimprint-label/settings.json`. +- ☾ / ☀ toggles light and dark themes ([ttkbootstrap](https://ttkbootstrap.readthedocs.io) *flatly* / *darkly*). + +### Library use + +```python +from PIL import Image +from niimprint import BluetoothTransport, PrinterClient, prepare_print + +image = Image.open("label.png") # landscape, 8 px/mm +image, density = prepare_print("d101", image, 3, rotate=90) +printer = PrinterClient(BluetoothTransport("94:11:02:66:16:4D")) +printer.print_image(image, density=density, model="d101") +``` + +`prepare_print` clamps density to the model's maximum, rotates, and raises `ValueError` if the image is wider than the print head. `print_image` raises `PrinterError` if the printer stops responding or rejects a command. + ## Licence [MIT](https://choosealicense.com/licenses/mit/). Originally developed by [kjy00302](https://github.com/kjy00302), forked & enhanced by [AndBondStyle](https://github.com/AndBondStyle) - -## Label designer app - -`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) -- **+ 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 -- ☾/☀ in the toolbar switches between light and dark themes ([ttkbootstrap](https://ttkbootstrap.readthedocs.io)) - -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.