#!/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 # 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))