yourovo/mac/label.py
type-two c1801c8c34 fix(label): shrink text to fit before ellipsising it
fit() cut words the moment a line overran, so the style row printed as
"Electro, Synth-…" -- the one line a customer scans for -- while the extension's
own label engine printed it whole. Step the font down to ~70% of its authored
size first (still legible at 203 dpi) and only ellipsise if it still will not
fit. Returns (text, font) so callers draw with whatever size actually fitted.
Verified on the rfid65 render: the full "Electro, Synth-pop" at a slightly
smaller bold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:37:08 +10:00

265 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Render a record label to a 1-bit bitmap for the Urovo D812R+.
WHY A BITMAP INSTEAD OF TSPL TEXT/QRCODE COMMANDS
-------------------------------------------------
The media runs 24 mm across the print head with the long edge along the feed, so a
landscape label design has to be rotated 90 degrees onto it. The printer's built-in
bitmap fonts are small, blocky, and their anchor under `TEXT ...,90,...` is awkward to
lay out against. Rendering with PIL gives real fonts, exact rotation and pixel-level
control -- which is also what it takes to match the extension's labels.js design.
Output goes out via TSPL `BITMAP x,y,<width_bytes>,<height_dots>,<mode>,<data>`.
Bit convention: in TSPL BITMAP data a set bit is WHITE and a clear bit is BLACK, which
is exactly what PIL mode-'1' `tobytes()` produces -- no inversion needed.
MEDIA PROFILES
--------------
Both of the shop's stocks sit on a 24 mm web and feed long-edge-first; they differ in
the label pitch and how much of the web is actually printable:
small 51 x 19 mm design on a 24 mm web (older stock -- the label is 19 mm tall and
the rest of the web is blank filler, so the design is centred across the web)
large 55 x 24 mm design, full width of the web (current stock)
Names match the extension's size dropdown. Anything can be overridden per request.
"""
from PIL import Image, ImageDraw, ImageFont
import segno
DPMM = 8 # Urovo: 203 dpi ~= 8 dots/mm
DPMM_DYMO = 300 / 25.4 # Dymo LabelWriter 450: 300 dpi
def mm(v, dpmm=DPMM):
return int(round(v * dpmm))
# web_mm : media width across the print head (what SIZE's first arg must be)
# pitch_mm : label length along the feed (SIZE's second arg)
# art_mm : (long, short) of the printable label face, centred on the web
# margin_mm: safe-area inset held back from every edge of that face. Without it the
# design runs edge-to-edge and the smallest registration drift clips it.
PROFILES = {
'small': dict(web_mm=24, pitch_mm=51, art_mm=(51, 19), gap_mm=2, margin_mm=1.5),
'large': dict(web_mm=24, pitch_mm=55, art_mm=(55, 24), gap_mm=2, margin_mm=1.5),
'xlarge': dict(web_mm=34, pitch_mm=64, art_mm=(64, 34), gap_mm=2, margin_mm=1.5),
# The 65 x 35 mm direct-thermal UHF stock (U9 inlay, US band). 65 is the roll WIDTH and 35
# the label length along the feed -- sending it the other way round (SIZE 34,65.35) advanced
# nearly two labels per feed and looked exactly like a chip that would not stop at the
# antenna. gap=0 on purpose: the inlay fills most of the label and blinds the optical gap
# sensor, so the printer positions by pitch. Proven with a real print-test on this roll.
'rfid65': dict(web_mm=65, pitch_mm=35, art_mm=(64, 34), gap_mm=0, margin_mm=1.5, rotate=False),
}
DEFAULT_PROFILE = 'large'
def profile(name=None, dpmm=DPMM, **over):
"""Media profile. `dpmm` picks the target device's resolution -- the same design
renders for the Urovo (203 dpi) or the Dymo (300 dpi) just by changing it."""
p = dict(PROFILES.get(name or DEFAULT_PROFILE, PROFILES[DEFAULT_PROFILE]))
p.update({k: v for k, v in over.items() if v is not None})
p['name'] = name or DEFAULT_PROFILE
p['dpmm'] = dpmm or DPMM
return p
_FONTS = {
'bold': ['/System/Library/Fonts/Supplemental/Arial Bold.ttf',
'/System/Library/Fonts/Supplemental/Arial Narrow Bold.ttf',
'/System/Library/Fonts/HelveticaNeue.ttc'],
'regular': ['/System/Library/Fonts/Supplemental/Arial.ttf',
'/System/Library/Fonts/Supplemental/Arial Narrow.ttf',
'/System/Library/Fonts/HelveticaNeue.ttc'],
}
_cache = {}
def font(kind, size):
size = max(7, int(size))
key = (kind, size)
if key not in _cache:
for path in _FONTS[kind]:
try:
_cache[key] = ImageFont.truetype(path, size)
break
except Exception:
continue
else:
_cache[key] = ImageFont.load_default()
return _cache[key]
def _w(d, s, f):
return d.textbbox((0, 0), s, font=f)[2]
def fit(d, s, f, maxw):
"""Make `s` fit `maxw` dots. Returns (text, font).
SHRINK before trimming. This used to ellipsise straight away, so a label read
"Electro, Synth-…" -- the one line a customer scans for, cut off while the extension's
own label engine printed it whole. Step the font down to ~70% of its authored size
(still legible at 203 dpi) and only ellipsise if it still will not fit."""
s = (s or '').strip()
if not s or _w(d, s, f) <= maxw:
return s, f
size, path = getattr(f, 'size', None), getattr(f, 'path', None)
if size and path:
floor = max(7, int(size * 0.7))
for sz in range(size - 1, floor - 1, -1):
g = ImageFont.truetype(path, sz)
if _w(d, s, g) <= maxw:
return s, g
f = ImageFont.truetype(path, floor)
while s and _w(d, s + '', f) > maxw:
s = s[:-1]
return ((s.rstrip() + '') if s else ''), f
def _qr(data, maxpx):
"""segno has no PIL export without a plugin, so build it from the module matrix."""
rows = [list(r) for r in segno.make(data, error='m').matrix]
n = len(rows)
scale = max(1, maxpx // n)
img = Image.new('1', (n * scale, n * scale), 1)
px = img.load()
for ry, row in enumerate(rows):
for rx, on in enumerate(row):
if on:
for dy in range(scale):
for dx in range(scale):
px[rx * scale + dx, ry * scale + dy] = 0
return img
def render_art(fields, prof):
"""Draw the landscape design (long axis horizontal). Returns a 1-bit image."""
inset = prof.get('margin_mm', 1.5)
dpmm = prof.get('dpmm', DPMM)
W = mm(prof['art_mm'][0] - 2 * inset, dpmm)
H = mm(prof['art_mm'][1] - 2 * inset, dpmm)
img = Image.new('1', (W, H), 1)
d = ImageDraw.Draw(img)
g = lambda k: str(fields.get(k, '') or '').strip()
pad = max(3, H // 24)
# Type scale is a fraction of the design height so 19 mm and 24 mm both work.
S = lambda frac: max(7, int(H * frac))
# ---- right column: price (top), condition, QR (bottom) ----
price, cond, sku = g('price'), g('condition'), g('sku')
f_price = font('bold', S(0.24))
f_cond = font('regular', S(0.105))
qr_img = _qr(sku, int(H * 0.55)) if sku else None
right = 0
if price:
right = max(right, _w(d, price, f_price))
if cond:
right = max(right, _w(d, cond, f_cond))
if qr_img:
right = max(right, qr_img.width)
if right:
right += pad * 2
rx_end = W - pad
y = pad
if price:
d.text((rx_end - _w(d, price, f_price), y), price, font=f_price, fill=0)
y += S(0.24) + 2
if cond:
d.text((rx_end - _w(d, cond, f_cond), y), cond, font=f_cond, fill=0)
if qr_img:
img.paste(qr_img, (rx_end - qr_img.width, H - pad - qr_img.height))
# ---- left column, mirroring the extension's hierarchy ----
lx, avail = pad, W - pad - right
info = g('info')
info_h = S(0.095) + 2 if info else 0
y = pad
for key, kind, frac in (('artist', 'regular', 0.155),
('title', 'regular', 0.085),
('genre', 'regular', 0.125),
('style', 'bold', 0.155)):
val = g(key)
if not val:
continue
size = S(frac)
if y + size > H - pad - info_h:
break
f = font(kind, size)
tv, fv = fit(d, val, f, avail)
d.text((lx, y), tv, font=fv, fill=0)
y += size + 2
if info:
f = font('regular', S(0.095))
ti, fi = fit(d, info, f, avail)
d.text((lx, H - pad - S(0.095)), ti, font=fi, fill=0)
return img
def render(fields, prof=None, rotate=None):
"""Full media-sized bitmap, design centred and oriented for the print head.
rotate=True (Urovo) the 24 mm web feeds narrow-edge-first, so the landscape
design is turned 90 degrees onto it.
rotate=False (Dymo) the 57 mm head is wider than the label, so the same design
feeds landscape already and must NOT be rotated.
"""
prof = prof or profile()
dpmm = prof.get('dpmm', DPMM)
art = render_art(fields, prof)
# A profile may say it feeds landscape already. The 65 x 35 UHF roll does: 65 is the WEB,
# so the 64 x 34 design sits across it unrotated. Rotating it (the default, right for the
# 24 mm narrow stock) ran the design ALONG a 35 mm label -- printed sideways and clipped
# after ~35 mm. Seen on the bench. Only an explicit rotate=... argument overrides it.
if rotate is None:
rotate = prof.get('rotate', True)
if rotate:
# ROTATE_270 == 90 degrees clockwise, matching TSPL's rotation sense and the
# orientation confirmed on the bench.
art = art.transpose(Image.ROTATE_270)
cw, ch = mm(prof['web_mm'], dpmm), mm(prof['pitch_mm'], dpmm)
elif 'rotate' in prof:
# Unrotated ON A LABEL PRINTER: the canvas is still the media, web x pitch, so the
# printer gets a full-label bitmap with the design centred -- not an art-sized one.
cw, ch = mm(prof['web_mm'], dpmm), mm(prof['pitch_mm'], dpmm)
else:
# Unrotated for the Dymo path (rotate=False passed explicitly): the head is wider than
# the label, so the canvas is the design itself. Unchanged.
cw, ch = mm(prof['art_mm'][0], dpmm), mm(prof['art_mm'][1], dpmm)
canvas = Image.new('1', (cw + (-cw % 8), ch), 1)
canvas.paste(art, (max(0, (canvas.width - art.width) // 2),
max(0, (ch - art.height) // 2)))
return canvas
def to_tspl(img, x=0, y=0, mode=0):
"""Build the TSPL BITMAP command (ASCII header + raw 1-bpp payload)."""
if img.mode != '1':
img = img.convert('1')
if img.width % 8:
pad = Image.new('1', (img.width + 8 - img.width % 8, img.height), 1)
pad.paste(img, (0, 0))
img = pad
header = 'BITMAP %d,%d,%d,%d,%d,' % (x, y, img.width // 8, img.height, mode)
return header.encode('ascii') + img.tobytes() + b'\r\n'
DEMO = {'artist': 'Lushlife',
'title': '& The Age Of Imagination Quartet Order Of Operations Instrumentals EP',
'genre': 'Hip Hop', 'style': 'Instrumental',
'info': '2007 | Japan | Miclife Recordings MLR-2016',
'price': '$25', 'condition': 'VG+ / VG+',
'sku': '20260722153000', 'releaseId': '12345'}
if __name__ == '__main__':
import sys
for name in (sys.argv[1:] or ['small', 'large']):
p = profile(name)
art = render_art(DEMO, p)
art.save('/tmp/label-%s.png' % name)
print('%-7s art %dx%d dots (%.1fmm inset) web %dmm pitch %dmm -> /tmp/label-%s.png'
% (name, art.width, art.height, p['margin_mm'], p['web_mm'], p['pitch_mm'], name))