#!/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,,,,`. 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 # 203 dpi ~= 8 dots/mm def mm(v): 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), } DEFAULT_PROFILE = 'large' def profile(name=None, **over): 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 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) W = mm(prof['art_mm'][0] - 2 * inset) H = mm(prof['art_mm'][1] - 2 * inset) 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): """Full media-sized bitmap, design centred on the web and rotated for the head.""" prof = prof or profile() art = render_art(fields, prof) # ROTATE_270 == 90 degrees clockwise, matching TSPL's rotation sense and the # orientation confirmed on the bench. rot = art.transpose(Image.ROTATE_270) # now (short x long) web, pitch = mm(prof['web_mm']), mm(prof['pitch_mm']) canvas = Image.new('1', (web + (-web % 8), pitch), 1) canvas.paste(rot, (max(0, (canvas.width - rot.width) // 2), max(0, (pitch - rot.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))