ASSESSMENT.md §7 assumed the RFID encode was locked inside the Windows-only
GTSPL_SDK.dll and would need a USBPcap sniff or the GTSPL Java jar to recover.
It didn't. The DLL is a managed .NET assembly, and its IL shows every RFID call
is a one-line String.Concat -> ASCII -> WritePrinter. Recovered from ldstr order
plus argument order, and confirmed by a second independent decompile:
UHF WRITE H,2,12,E,"<24 hex>" UHF READ/QUERY ...
UHF GEN2 EPC|TID|USER|ACCESS|KILL <action>,"<pw>"
SET RFID <tagType>,<rw_pos>,<void>,<try>,<err>,<speed>,<retry> (dots, not mm)
&DEFAULT,i &CALIBRATE,A,R PRINT <set>, <copy>
So the whole recipe is plain ASCII over the printer's bulk endpoint and ports to
macOS with no vendor binaries at all.
- mac/urovo.py USB transport + full GTSPL command set, status decoding, the
ribbon-latch recovery, SKU<->EPC packing ported from daemon.cs.
- mac/label.py PIL bitmap renderer -> TSPL BITMAP. Media-profile driven, so both
shop stocks work (small 51x19 and large 55x24 on the 24mm web),
with a safe-area inset so the design can't clip at the edges.
- mac/daemon.py :7790 HTTP service, same routes/JSON/CORS as daemon.cs, plus a
/preview route that renders a label as PNG without printing one.
Verified on the bench from macOS: printer identifies (MODEL:UROVO-D812R), the
latched 0x08 "out of ribbon" clears with SET RIBBON OFF + FORMFEED, and labels
print correctly oriented and dark on the 24x55 stock.
NOT yet verified: the RFID encode itself. It is wired and the printer accepts the
command, but the loaded thermal stock has no inlays so nothing has been written to
a chip. Needs a PET RFID label at the antenna. The SKU<->EPC byte layout also
remains unconfirmed against existing Chafon-written tags.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
217 lines
7.9 KiB
Python
217 lines
7.9 KiB
Python
#!/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 # 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))
|