wp9529-printer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wp9529/__init__.py +24 -0
- wp9529/__main__.py +4 -0
- wp9529/assets/MDI-LICENSE +20 -0
- wp9529/assets/materialdesignicons-webfont.ttf +0 -0
- wp9529/assets/mdi-codepoints.json +1 -0
- wp9529/cli.py +109 -0
- wp9529/driver.py +241 -0
- wp9529/label.py +213 -0
- wp9529_printer-0.1.0.dist-info/METADATA +217 -0
- wp9529_printer-0.1.0.dist-info/RECORD +15 -0
- wp9529_printer-0.1.0.dist-info/WHEEL +5 -0
- wp9529_printer-0.1.0.dist-info/entry_points.txt +2 -0
- wp9529_printer-0.1.0.dist-info/licenses/LICENSE +25 -0
- wp9529_printer-0.1.0.dist-info/licenses/src/wp9529/assets/MDI-LICENSE +20 -0
- wp9529_printer-0.1.0.dist-info/top_level.txt +1 -0
wp9529/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Print labels on a VSON WP9529 from the command line.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
wp9529-label --info # printer status
|
|
5
|
+
wp9529-label --power-off 30 # switch off after 30 idle minutes
|
|
6
|
+
wp9529-label --title Router --text "admin / see vault" --icon mdi:router-wireless
|
|
7
|
+
wp9529-label --qr "WIFI:T:WPA;S:Home;P:secret;;" --text "Guest Wi-Fi"
|
|
8
|
+
wp9529-label --qr https://example.com --icon mdi:link --copies 3
|
|
9
|
+
wp9529-label --title Spices --preview previews # render a PNG only
|
|
10
|
+
wp9529-label --image my_label.png # an image 96 px wide
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from PIL import Image
|
|
18
|
+
|
|
19
|
+
from .driver import DENSITY, POWER_OFF_MAX, POWER_OFF_MIN, WP9529, PrinterError
|
|
20
|
+
from .label import render_label
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def power_off_minutes(value: str) -> int:
|
|
24
|
+
minutes = int(value)
|
|
25
|
+
if not POWER_OFF_MIN <= minutes <= POWER_OFF_MAX:
|
|
26
|
+
raise argparse.ArgumentTypeError(f"must be {POWER_OFF_MIN}-{POWER_OFF_MAX} minutes")
|
|
27
|
+
return minutes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def add_print_options(p: argparse.ArgumentParser) -> None:
|
|
31
|
+
"""Options about the roll and the printer, shared with other label tools."""
|
|
32
|
+
p.add_argument("--length", type=float, default=30, help="label length in mm (default 30)")
|
|
33
|
+
p.add_argument("--gap", type=float, default=8.5, help="gap between labels in mm (default 8.5)")
|
|
34
|
+
p.add_argument("--continuous", action="store_true", help="continuous tape instead of die-cut labels")
|
|
35
|
+
p.add_argument("--rotate", type=int, default=90, choices=[90, 270], help="flip if text prints upside down")
|
|
36
|
+
p.add_argument("--density", default="normal", choices=list(DENSITY))
|
|
37
|
+
p.add_argument("--feed", type=int, default=0, help="extra blank lines after the label")
|
|
38
|
+
p.add_argument("--copies", type=int, default=1, help="copies of each label")
|
|
39
|
+
p.add_argument("--preview", metavar="DIR", help="save rendered labels as PNGs in DIR instead of printing")
|
|
40
|
+
p.add_argument("--address", help="printer BLE address (default: scan for it)")
|
|
41
|
+
p.add_argument("-v", "--verbose", action="store_true", help="log every Bluetooth packet")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save_previews(jobs: list[tuple[str, Image.Image]], directory: str) -> None:
|
|
45
|
+
out = Path(directory)
|
|
46
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
for name, img in jobs:
|
|
48
|
+
img.save(out / f"{name}.png")
|
|
49
|
+
print(f"saved {len(jobs)} preview(s) to {out}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def print_jobs(jobs: list[tuple[str, Image.Image]], args, info: bool = False,
|
|
53
|
+
power_off: int | None = None) -> None:
|
|
54
|
+
"""Connect once and print each (name, image) job `args.copies` times."""
|
|
55
|
+
async with WP9529(args.address) as printer:
|
|
56
|
+
print(f"printer {printer.address}: firmware {await printer.firmware_version()}")
|
|
57
|
+
if power_off is not None:
|
|
58
|
+
stored = await printer.set_power_off_minutes(power_off)
|
|
59
|
+
print(f"auto power-off set to {stored} minutes")
|
|
60
|
+
elif info:
|
|
61
|
+
print(f"auto power-off after {await printer.power_off_minutes()} minutes")
|
|
62
|
+
for name, img in jobs:
|
|
63
|
+
for _ in range(args.copies):
|
|
64
|
+
await printer.print_image(img, density=args.density, feed_lines=args.feed,
|
|
65
|
+
label_mm=None if args.continuous else args.length,
|
|
66
|
+
gap_mm=args.gap)
|
|
67
|
+
print(f"printed {name}" + (f" x{args.copies}" if args.copies > 1 else ""))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def run(args) -> None:
|
|
71
|
+
jobs: list[tuple[str, Image.Image]] = []
|
|
72
|
+
if args.image:
|
|
73
|
+
jobs.append((Path(args.image).stem, Image.open(args.image)))
|
|
74
|
+
elif args.title or args.text or args.qr or args.icon:
|
|
75
|
+
img = render_label(title=args.title, text=args.text, qr=args.qr, icon=args.icon,
|
|
76
|
+
length_mm=args.length, rotate=args.rotate)
|
|
77
|
+
jobs.append(("label", img))
|
|
78
|
+
if args.preview:
|
|
79
|
+
if not jobs:
|
|
80
|
+
raise ValueError("nothing to preview: give --title, --text, --qr, --icon or --image")
|
|
81
|
+
save_previews(jobs, args.preview)
|
|
82
|
+
return
|
|
83
|
+
if not jobs and not args.info and args.power_off is None:
|
|
84
|
+
raise ValueError("nothing to do: give --title, --text, --qr, --icon, --image, --info or --power-off")
|
|
85
|
+
await print_jobs(jobs, args, info=args.info, power_off=args.power_off)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv: list[str] | None = None) -> None:
|
|
89
|
+
p = argparse.ArgumentParser(prog="wp9529-label", description=__doc__,
|
|
90
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
91
|
+
p.add_argument("--title", default="", help="large text")
|
|
92
|
+
p.add_argument("--text", default="", help="smaller text (or the only text)")
|
|
93
|
+
p.add_argument("--qr", help="any text to encode as a QR code")
|
|
94
|
+
p.add_argument("--icon", help="MDI icon, e.g. mdi:tools (https://pictogrammers.com/library/mdi/)")
|
|
95
|
+
p.add_argument("--image", help="print an existing image (must be 96 px wide)")
|
|
96
|
+
p.add_argument("--info", action="store_true", help="show the printer's firmware and auto power-off time")
|
|
97
|
+
p.add_argument("--power-off", type=power_off_minutes, metavar="MIN",
|
|
98
|
+
help=f"set the printer's auto power-off time ({POWER_OFF_MIN}-{POWER_OFF_MAX} minutes)")
|
|
99
|
+
add_print_options(p)
|
|
100
|
+
args = p.parse_args(argv)
|
|
101
|
+
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING, format="%(message)s")
|
|
102
|
+
try:
|
|
103
|
+
asyncio.run(run(args))
|
|
104
|
+
except (PrinterError, ValueError, OSError) as e:
|
|
105
|
+
raise SystemExit(f"error: {e}") from None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
wp9529/driver.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Driver for the VSON WP9529 Bluetooth LE label printer.
|
|
2
|
+
|
|
3
|
+
Protocol reverse engineered from the iLabel Android app (com.vson.ilabel):
|
|
4
|
+
|
|
5
|
+
* GATT service 0xFFF0. Commands are written to 0xFFF1 (write without
|
|
6
|
+
response); replies arrive as notifications on 0xFFF4.
|
|
7
|
+
* Every command and reply starts with a one-byte opcode.
|
|
8
|
+
|
|
9
|
+
01 device info / status
|
|
10
|
+
<- 01 width_mm(LE16) width_dots(LE16) state err_a err_b err_c err_d ...
|
|
11
|
+
04 reset / cancel the current job
|
|
12
|
+
02 lines(LE16) offset(LE16) 00 01 density
|
|
13
|
+
job header, continuous paper (density 24/32/39)
|
|
14
|
+
02 lines(LE16) offset(LE16) 00 02 density label_mm gap_mm*10 copies
|
|
15
|
+
job header, die-cut labels with gaps
|
|
16
|
+
03 line(LE16) n data[n*width/8] raster packet, n lines from `line`
|
|
17
|
+
<- a4 / a5 printer accepted the header
|
|
18
|
+
<- a7 / a8 pause / resume the data stream
|
|
19
|
+
b1 MAC address
|
|
20
|
+
b2 firmware version (the app shows it as "V" + hex)
|
|
21
|
+
b4 auto power-off time
|
|
22
|
+
<- b4 minutes ..
|
|
23
|
+
b5 minutes 00 set the auto power-off time (10-180 minutes)
|
|
24
|
+
<- b5 minutes ..
|
|
25
|
+
|
|
26
|
+
Raster data is 1 bit per pixel, MSB first, 1 = black, one print-head
|
|
27
|
+
width (96 px = 12 mm at 203 dpi) per line.
|
|
28
|
+
"""
|
|
29
|
+
import asyncio
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
from bleak import BleakClient, BleakScanner
|
|
33
|
+
from PIL import Image, ImageOps
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("wp9529")
|
|
36
|
+
|
|
37
|
+
WRITE_UUID = "0000fff1-0000-1000-8000-00805f9b34fb"
|
|
38
|
+
NOTIFY_UUID = "0000fff4-0000-1000-8000-00805f9b34fb"
|
|
39
|
+
NAME_PREFIX = "9529"
|
|
40
|
+
|
|
41
|
+
DOTS_PER_MM = 8
|
|
42
|
+
DENSITY = {"light": 24, "normal": 32, "dark": 39}
|
|
43
|
+
POWER_OFF_MIN, POWER_OFF_MAX = 10, 180 # minutes, the range the iLabel app allows
|
|
44
|
+
LINES_PER_PACKET = 4
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PrinterError(Exception):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Info:
|
|
52
|
+
def __init__(self, raw: bytes):
|
|
53
|
+
self.raw = raw
|
|
54
|
+
self.width_mm = int.from_bytes(raw[1:3], "little")
|
|
55
|
+
self.width = int.from_bytes(raw[3:5], "little")
|
|
56
|
+
self.state = raw[5]
|
|
57
|
+
self.errors = tuple(raw[6:10])
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def busy(self):
|
|
61
|
+
return self.state != 0
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def has_error(self):
|
|
65
|
+
return any(self.errors)
|
|
66
|
+
|
|
67
|
+
def __repr__(self):
|
|
68
|
+
return (f"Info(width={self.width_mm} mm/{self.width} dots, state={self.state}, "
|
|
69
|
+
f"errors={self.errors}, raw={self.raw.hex(' ')})")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def find_printer(timeout=8.0):
|
|
73
|
+
"""Return the address of the first WP9529 found nearby."""
|
|
74
|
+
dev = await BleakScanner.find_device_by_filter(
|
|
75
|
+
lambda d, adv: (adv.local_name or d.name or "").startswith(NAME_PREFIX),
|
|
76
|
+
timeout=timeout,
|
|
77
|
+
)
|
|
78
|
+
if dev is None:
|
|
79
|
+
raise PrinterError("No WP9529 found. Is it on and disconnected from the phone app?")
|
|
80
|
+
return dev.address
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def rasterize(img: Image.Image, width: int) -> list[bytes]:
|
|
84
|
+
"""Turn an image `width` px wide into rows of packed 1-bit data (1 = black)."""
|
|
85
|
+
if img.width != width:
|
|
86
|
+
raise ValueError(f"image must be {width} px wide, got {img.width}")
|
|
87
|
+
bw = ImageOps.invert(img.convert("L")).point(lambda p: 255 if p >= 128 else 0, "1")
|
|
88
|
+
data = bw.tobytes()
|
|
89
|
+
stride = width // 8
|
|
90
|
+
return [data[i:i + stride] for i in range(0, len(data), stride)]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_packets(rows: list[bytes], lines_per_packet=LINES_PER_PACKET) -> list[bytes]:
|
|
94
|
+
packets = []
|
|
95
|
+
for start in range(0, len(rows), lines_per_packet):
|
|
96
|
+
chunk = rows[start:start + lines_per_packet]
|
|
97
|
+
packets.append(bytes([0x03, start & 0xFF, start >> 8, len(chunk)]) + b"".join(chunk))
|
|
98
|
+
return packets
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class WP9529:
|
|
102
|
+
def __init__(self, address: str | None = None):
|
|
103
|
+
self.address = address
|
|
104
|
+
self.client: BleakClient | None = None
|
|
105
|
+
self.info: Info | None = None
|
|
106
|
+
self._replies: asyncio.Queue[bytes] = asyncio.Queue()
|
|
107
|
+
self._resume = asyncio.Event()
|
|
108
|
+
self._resume.set()
|
|
109
|
+
|
|
110
|
+
async def __aenter__(self):
|
|
111
|
+
await self.connect()
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
async def __aexit__(self, *exc):
|
|
115
|
+
await self.disconnect()
|
|
116
|
+
|
|
117
|
+
async def connect(self):
|
|
118
|
+
if self.address is None:
|
|
119
|
+
self.address = await find_printer()
|
|
120
|
+
self.client = BleakClient(self.address, timeout=20.0)
|
|
121
|
+
await self.client.connect()
|
|
122
|
+
await self.client.start_notify(NOTIFY_UUID, self._on_notify)
|
|
123
|
+
self.info = await self.get_info()
|
|
124
|
+
log.info("connected to %s: %r", self.address, self.info)
|
|
125
|
+
|
|
126
|
+
async def disconnect(self):
|
|
127
|
+
if self.client and self.client.is_connected:
|
|
128
|
+
await self.client.disconnect()
|
|
129
|
+
|
|
130
|
+
def _on_notify(self, _, data: bytearray):
|
|
131
|
+
data = bytes(data)
|
|
132
|
+
log.debug("<- %s", data.hex(" "))
|
|
133
|
+
if data[:1] == b"\xa7":
|
|
134
|
+
self._resume.clear()
|
|
135
|
+
elif data[:1] == b"\xa8":
|
|
136
|
+
self._resume.set()
|
|
137
|
+
self._replies.put_nowait(data)
|
|
138
|
+
|
|
139
|
+
async def _send(self, data: bytes):
|
|
140
|
+
log.debug("-> %s", data.hex(" ") if len(data) <= 16 else f"{data[:16].hex(' ')} ... ({len(data)} B)")
|
|
141
|
+
await self.client.write_gatt_char(WRITE_UUID, data, response=False)
|
|
142
|
+
|
|
143
|
+
async def _request(self, data: bytes, opcodes: bytes, timeout=3.0) -> bytes:
|
|
144
|
+
"""Send a command and wait for a reply starting with one of `opcodes`."""
|
|
145
|
+
while not self._replies.empty():
|
|
146
|
+
self._replies.get_nowait()
|
|
147
|
+
await self._send(data)
|
|
148
|
+
loop = asyncio.get_running_loop()
|
|
149
|
+
deadline = loop.time() + timeout
|
|
150
|
+
while True:
|
|
151
|
+
remaining = deadline - loop.time()
|
|
152
|
+
if remaining <= 0:
|
|
153
|
+
raise PrinterError(f"no reply to {data[:1].hex()} (wanted {opcodes.hex()})")
|
|
154
|
+
try:
|
|
155
|
+
reply = await asyncio.wait_for(self._replies.get(), remaining)
|
|
156
|
+
except TimeoutError:
|
|
157
|
+
raise PrinterError(f"no reply to {data[:1].hex()} (wanted {opcodes.hex()})") from None
|
|
158
|
+
if reply[0] in opcodes:
|
|
159
|
+
return reply
|
|
160
|
+
|
|
161
|
+
async def get_info(self) -> Info:
|
|
162
|
+
return Info(await self._request(b"\x01", b"\x01"))
|
|
163
|
+
|
|
164
|
+
async def firmware_version(self) -> str:
|
|
165
|
+
return f"V{(await self._request(b'\xb2', b'\xb2'))[1]:x}"
|
|
166
|
+
|
|
167
|
+
async def power_off_minutes(self) -> int:
|
|
168
|
+
"""Minutes of inactivity before the printer switches itself off."""
|
|
169
|
+
return (await self._request(b"\xb4", b"\xb4"))[1]
|
|
170
|
+
|
|
171
|
+
async def set_power_off_minutes(self, minutes: int) -> int:
|
|
172
|
+
"""Set the auto power-off time (10-180 minutes); returns what the printer stored."""
|
|
173
|
+
minutes = max(POWER_OFF_MIN, min(POWER_OFF_MAX, int(minutes)))
|
|
174
|
+
return (await self._request(bytes([0xB5, minutes, 0]), b"\xb5"))[1]
|
|
175
|
+
|
|
176
|
+
async def print_image(self, img: Image.Image, density="normal", feed_lines=0,
|
|
177
|
+
label_mm: float | None = 30, gap_mm: float = 8.5):
|
|
178
|
+
"""Print one label. `img` must be exactly print-head wide (see .width).
|
|
179
|
+
|
|
180
|
+
With `label_mm` set the job is sent as gap paper so the printer lines up
|
|
181
|
+
with the die-cut labels; pass label_mm=None for continuous tape.
|
|
182
|
+
"""
|
|
183
|
+
level = DENSITY.get(density, density)
|
|
184
|
+
rows = rasterize(img, self.width)
|
|
185
|
+
rows += [bytes(self.width // 8)] * feed_lines
|
|
186
|
+
packets = build_packets(rows)
|
|
187
|
+
|
|
188
|
+
await self._send(b"\x04")
|
|
189
|
+
await asyncio.sleep(0.3)
|
|
190
|
+
info = await self.get_info()
|
|
191
|
+
if info.has_error:
|
|
192
|
+
raise PrinterError(f"printer reports an error (out of paper / cover open?): {info!r}")
|
|
193
|
+
if info.busy:
|
|
194
|
+
raise PrinterError(f"printer is busy: {info!r}")
|
|
195
|
+
|
|
196
|
+
total = len(rows)
|
|
197
|
+
if label_mm:
|
|
198
|
+
header = bytes([0x02, total & 0xFF, total >> 8, 0, 0, 0, 0x02, level,
|
|
199
|
+
round(label_mm), round(gap_mm * 10), 1])
|
|
200
|
+
else:
|
|
201
|
+
header = bytes([0x02, total & 0xFF, total >> 8, 0, 0, 0, 0x01, level])
|
|
202
|
+
reply = await self._request(header, b"\xa4\xa5", timeout=5.0)
|
|
203
|
+
log.info("header accepted: %s", reply.hex(" "))
|
|
204
|
+
|
|
205
|
+
self._resume.set()
|
|
206
|
+
await asyncio.sleep(2.0) # the app waits this long before the first packet
|
|
207
|
+
for i, pkt in enumerate(packets):
|
|
208
|
+
await self._wait_resume()
|
|
209
|
+
await self._send(pkt)
|
|
210
|
+
if i and (i * LINES_PER_PACKET) % 500 == 0:
|
|
211
|
+
await asyncio.sleep(2.0)
|
|
212
|
+
log.info("sent %d lines in %d packets", total, len(packets))
|
|
213
|
+
|
|
214
|
+
await self._wait_done()
|
|
215
|
+
|
|
216
|
+
async def _wait_resume(self, timeout=30.0):
|
|
217
|
+
try:
|
|
218
|
+
await asyncio.wait_for(self._resume.wait(), timeout)
|
|
219
|
+
except TimeoutError:
|
|
220
|
+
raise PrinterError("printer paused the data stream and never resumed") from None
|
|
221
|
+
|
|
222
|
+
async def _wait_done(self, timeout=60.0):
|
|
223
|
+
await asyncio.sleep(2.0)
|
|
224
|
+
loop = asyncio.get_running_loop()
|
|
225
|
+
deadline = loop.time() + timeout
|
|
226
|
+
while loop.time() < deadline:
|
|
227
|
+
try:
|
|
228
|
+
info = await self.get_info()
|
|
229
|
+
except PrinterError:
|
|
230
|
+
log.debug("status poll got no reply while printing, retrying")
|
|
231
|
+
continue
|
|
232
|
+
if info.has_error:
|
|
233
|
+
raise PrinterError(f"printer error during print: {info!r}")
|
|
234
|
+
if not info.busy:
|
|
235
|
+
return
|
|
236
|
+
await asyncio.sleep(1.0)
|
|
237
|
+
raise PrinterError("timed out waiting for the print to finish")
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def width(self) -> int:
|
|
241
|
+
return self.info.width if self.info else 96
|
wp9529/label.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Render labels as print-ready images for a 12 mm (96-dot) print head.
|
|
2
|
+
|
|
3
|
+
Part of the wp9529 package, and copied unchanged into the Home Assistant
|
|
4
|
+
integration (HA-WP9529), so it must not import anything from Home Assistant.
|
|
5
|
+
|
|
6
|
+
Layout, left to right along the label: [QR code] [MDI icon] [title / text].
|
|
7
|
+
Every part is optional; with no text, the QR code and icon are centred.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import qrcode
|
|
14
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
15
|
+
|
|
16
|
+
DOTS_PER_MM = 8
|
|
17
|
+
ASSETS = Path(__file__).with_name("assets")
|
|
18
|
+
MDI_FONT = ASSETS / "materialdesignicons-webfont.ttf"
|
|
19
|
+
MDI_CODEPOINTS = ASSETS / "mdi-codepoints.json"
|
|
20
|
+
FONT_CANDIDATES = ["arialbd.ttf", "DejaVuSans-Bold.ttf", "LiberationSans-Bold.ttf"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@lru_cache(maxsize=64)
|
|
24
|
+
def _font(size: int) -> ImageFont.FreeTypeFont:
|
|
25
|
+
for name in FONT_CANDIDATES:
|
|
26
|
+
try:
|
|
27
|
+
return ImageFont.truetype(name, size)
|
|
28
|
+
except OSError:
|
|
29
|
+
continue
|
|
30
|
+
return ImageFont.load_default(size)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@lru_cache(maxsize=1)
|
|
34
|
+
def _mdi_codepoints() -> dict[str, int]:
|
|
35
|
+
return json.loads(MDI_CODEPOINTS.read_text(encoding="utf-8"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@lru_cache(maxsize=8)
|
|
39
|
+
def _mdi_font(size: int) -> ImageFont.FreeTypeFont:
|
|
40
|
+
return ImageFont.truetype(str(MDI_FONT), size)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def mdi_glyph(icon: str) -> str:
|
|
44
|
+
"""Character for an MDI icon given as "mdi:tools" or "tools"."""
|
|
45
|
+
name = icon.removeprefix("mdi:").strip()
|
|
46
|
+
try:
|
|
47
|
+
return chr(_mdi_codepoints()[name])
|
|
48
|
+
except KeyError:
|
|
49
|
+
raise ValueError(f"unknown MDI icon: {icon!r}") from None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fit_font(draw, text, max_w, max_h, start=48):
|
|
53
|
+
size = start
|
|
54
|
+
while size > 8:
|
|
55
|
+
font = _font(size)
|
|
56
|
+
l, t, r, b = draw.textbbox((0, 0), text, font=font)
|
|
57
|
+
if r - l <= max_w and b - t <= max_h:
|
|
58
|
+
return font
|
|
59
|
+
size -= 1
|
|
60
|
+
return _font(8)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _line_height(font) -> int:
|
|
64
|
+
l, t, r, b = font.getbbox("Ag")
|
|
65
|
+
return b - t + 2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _split_long_word(draw, word, font, max_w) -> list[str]:
|
|
69
|
+
"""Break a word wider than max_w into pieces that fit ("Korttisuoj", "at")."""
|
|
70
|
+
pieces, piece = [], ""
|
|
71
|
+
for ch in word:
|
|
72
|
+
if piece and draw.textlength(piece + ch, font=font) > max_w:
|
|
73
|
+
pieces.append(piece)
|
|
74
|
+
piece = ch
|
|
75
|
+
else:
|
|
76
|
+
piece += ch
|
|
77
|
+
return pieces + [piece] if piece else pieces
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _wrap(draw, text, font, max_w, break_words=False) -> list[str]:
|
|
81
|
+
words = []
|
|
82
|
+
for word in text.split():
|
|
83
|
+
too_wide = break_words and draw.textlength(word, font=font) > max_w
|
|
84
|
+
words += _split_long_word(draw, word, font, max_w) if too_wide else [word]
|
|
85
|
+
lines, line = [], ""
|
|
86
|
+
for word in words:
|
|
87
|
+
trial = f"{line} {word}".strip()
|
|
88
|
+
if draw.textlength(trial, font=font) <= max_w or not line:
|
|
89
|
+
line = trial
|
|
90
|
+
else:
|
|
91
|
+
lines.append(line)
|
|
92
|
+
line = word
|
|
93
|
+
return lines + [line] if line else lines
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ellipsize(draw, text, font, max_w) -> str:
|
|
97
|
+
if draw.textlength(text, font=font) <= max_w:
|
|
98
|
+
return text
|
|
99
|
+
while text and draw.textlength(text + "…", font=font) > max_w:
|
|
100
|
+
text = text[:-1]
|
|
101
|
+
return text.rstrip() + "…"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _fit_lines(draw, text, max_w, max_h, largest=22, smallest=12, max_lines=2):
|
|
105
|
+
"""Largest font (not below `smallest`) that fits `text` in at most `max_lines`
|
|
106
|
+
lines; at the smallest size, overflow is cut with an ellipsis.
|
|
107
|
+
|
|
108
|
+
Words are only broken when whole words cannot fit at any size, and then into
|
|
109
|
+
as few lines as possible ("Korttisuo / jat", not "Kort / tisu / ojat").
|
|
110
|
+
"""
|
|
111
|
+
attempts = [(False, max_lines)] + [(True, n) for n in range(1, max_lines + 1)]
|
|
112
|
+
for break_words, n_lines in attempts:
|
|
113
|
+
for size in range(largest, smallest - 1, -1):
|
|
114
|
+
font = _font(size)
|
|
115
|
+
lines = _wrap(draw, text, font, max_w, break_words)
|
|
116
|
+
if (len(lines) <= n_lines and _line_height(font) * len(lines) <= max_h
|
|
117
|
+
and all(draw.textlength(ln, font=font) <= max_w for ln in lines)):
|
|
118
|
+
return lines, font
|
|
119
|
+
font = _font(smallest)
|
|
120
|
+
n = max(1, min(max_lines, max_h // _line_height(font)))
|
|
121
|
+
lines = _wrap(draw, text, font, max_w, break_words=True)
|
|
122
|
+
head, rest = lines[:n - 1], " ".join(lines[n - 1:])
|
|
123
|
+
return [_ellipsize(draw, ln, font, max_w) for ln in head] + [_ellipsize(draw, rest, font, max_w)], font
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def qr_image(data: str, size: int) -> Image.Image:
|
|
127
|
+
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=0, box_size=1)
|
|
128
|
+
qr.add_data(data)
|
|
129
|
+
qr.make(fit=True)
|
|
130
|
+
img = qr.make_image(fill_color="black", back_color="white").convert("L")
|
|
131
|
+
if img.width > size:
|
|
132
|
+
raise ValueError(f"QR data too long for a 12 mm label ({len(data)} characters)")
|
|
133
|
+
scale = max(1, size // img.width) # integer scaling keeps modules crisp
|
|
134
|
+
return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def icon_image(icon: str, size: int) -> Image.Image:
|
|
138
|
+
glyph = mdi_glyph(icon)
|
|
139
|
+
img = Image.new("L", (size, size), 255)
|
|
140
|
+
ImageDraw.Draw(img).text((size / 2, size / 2), glyph, font=_mdi_font(size), fill=0, anchor="mm")
|
|
141
|
+
return img
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def render_label(title: str = "", text: str = "", qr: str | None = None, icon: str | None = None,
|
|
145
|
+
length_mm: float = 30, head_px: int = 96, margin_px: int = 4,
|
|
146
|
+
end_margin_mm: float = 1.5, rotate: int = 90) -> Image.Image:
|
|
147
|
+
"""Build a label image `head_px` wide and `length_mm` long, ready for print_image().
|
|
148
|
+
|
|
149
|
+
`title` is drawn large, `text` below it (or on its own, up to 3 lines),
|
|
150
|
+
`qr` is any string to encode, `icon` an MDI name like "mdi:tools".
|
|
151
|
+
`end_margin_mm` keeps content off the label's rounded ends.
|
|
152
|
+
"""
|
|
153
|
+
if not (title or text or qr or icon):
|
|
154
|
+
raise ValueError("nothing to print: give a title, text, QR data or an icon")
|
|
155
|
+
length_px = int(length_mm * DOTS_PER_MM)
|
|
156
|
+
end_px = int(end_margin_mm * DOTS_PER_MM)
|
|
157
|
+
box = head_px - 2 * margin_px
|
|
158
|
+
canvas = Image.new("L", (length_px, head_px), 255)
|
|
159
|
+
draw = ImageDraw.Draw(canvas)
|
|
160
|
+
|
|
161
|
+
has_text = bool(title or text)
|
|
162
|
+
blocks = []
|
|
163
|
+
if qr:
|
|
164
|
+
blocks.append(qr_image(qr, box))
|
|
165
|
+
if icon:
|
|
166
|
+
# Sharing the label with a QR code and text, a full-height icon would
|
|
167
|
+
# leave almost no room for the text, so it shrinks to about 6 mm.
|
|
168
|
+
icon_px = 6 * DOTS_PER_MM if (qr and has_text) else box
|
|
169
|
+
blocks.append(icon_image(icon, icon_px))
|
|
170
|
+
blocks_w = sum(b.width for b in blocks) + 2 * margin_px * max(0, len(blocks) - 1)
|
|
171
|
+
|
|
172
|
+
x = end_px if has_text else max(end_px, (length_px - blocks_w) // 2)
|
|
173
|
+
for b in blocks:
|
|
174
|
+
canvas.paste(b, (x, (head_px - b.height) // 2))
|
|
175
|
+
x += b.width + margin_px * 2
|
|
176
|
+
|
|
177
|
+
text_w = length_px - x - end_px
|
|
178
|
+
if has_text and text_w < 3 * DOTS_PER_MM:
|
|
179
|
+
raise ValueError(f"no room for text on a {length_mm:g} mm label; drop the QR or the icon")
|
|
180
|
+
|
|
181
|
+
if title and text:
|
|
182
|
+
gap = 5
|
|
183
|
+
title_font = _fit_font(draw, title, text_w, head_px * 0.42)
|
|
184
|
+
tb = draw.textbbox((0, 0), title, font=title_font)
|
|
185
|
+
title_h = tb[3] - tb[1]
|
|
186
|
+
lines, text_font = _fit_lines(draw, text, text_w, box - title_h - gap)
|
|
187
|
+
line_h = _line_height(text_font)
|
|
188
|
+
y = (head_px - title_h - gap - line_h * len(lines)) // 2
|
|
189
|
+
draw.text((x - tb[0], y - tb[1]), title, font=title_font, fill=0)
|
|
190
|
+
y += title_h + gap
|
|
191
|
+
for line in lines:
|
|
192
|
+
draw.text((x, y), line, font=text_font, fill=0, anchor="lt")
|
|
193
|
+
y += line_h
|
|
194
|
+
elif title:
|
|
195
|
+
font = _fit_font(draw, title, text_w, box, start=64)
|
|
196
|
+
tb = draw.textbbox((0, 0), title, font=font)
|
|
197
|
+
draw.text((x - tb[0], (head_px - (tb[3] - tb[1])) // 2 - tb[1]), title, font=font, fill=0)
|
|
198
|
+
elif text:
|
|
199
|
+
lines, font = _fit_lines(draw, text, text_w, box, largest=40, max_lines=3)
|
|
200
|
+
line_h = _line_height(font)
|
|
201
|
+
y = (head_px - line_h * len(lines)) // 2
|
|
202
|
+
for line in lines:
|
|
203
|
+
draw.text((x, y), line, font=font, fill=0, anchor="lt")
|
|
204
|
+
y += line_h
|
|
205
|
+
|
|
206
|
+
return canvas.rotate(rotate, expand=True)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def asset_tag(asset_id: str, name: str = "", url: str | None = None, icon: str | None = None,
|
|
210
|
+
length_mm: float = 30, rotate: int = 90) -> Image.Image:
|
|
211
|
+
"""Homebox tag: QR link, optional icon, asset ID large with the item name below."""
|
|
212
|
+
return render_label(title=asset_id, text=name, qr=url, icon=icon,
|
|
213
|
+
length_mm=length_mm, rotate=rotate)
|