yourovo/mac/label.py
type-two d1b2d7ec64 feat(label): rfid65 profile for the 65x35 UHF direct-thermal roll
web 65 / pitch 35 / gap 0 / art 64x34. 65 is the roll width and 35 the label
length: sent the other way round (SIZE 34,65.35) the printer advanced nearly two
labels per feed, which presented as an inlay that never stopped at the antenna.
gap=0 because the inlay fills most of the label and blinds the optical gap
sensor. Proven with a real print-test on this roll -- printer returned Ready, no
VOID -- so callers stop passing w/h/gap overrides.

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

238 lines
9.3 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),
}
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):
"""Trim with an ellipsis until it fits maxw dots."""
s = (s or '').strip()
if not s or _w(d, s, f) <= maxw:
return s
while s and _w(d, s + '', f) > maxw:
s = s[:-1]
return (s + '') if s else ''
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)
d.text((lx, y), fit(d, val, f, avail), font=f, fill=0)
y += size + 2
if info:
f = font('regular', S(0.095))
d.text((lx, H - pad - S(0.095)), fit(d, info, f, avail), font=f, fill=0)
return img
def render(fields, prof=None, rotate=True):
"""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)
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)
else:
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))