diff options
| author | Ahmed <git@gumx.cc> | 2026-06-14 01:46:29 +0300 |
|---|---|---|
| committer | Ahmed <git@gumx.cc> | 2026-06-14 01:46:29 +0300 |
| commit | 5a8d568931d9b23ce0df1265d05259a7012081c9 (patch) | |
| tree | 4ea19b8bb1763a38246f342f172c285f6579e09b /salvador/salvador.py | |
init: mostly vibed
Diffstat (limited to 'salvador/salvador.py')
| -rw-r--r-- | salvador/salvador.py | 172 |
1 files changed, 172 insertions, 0 deletions
diff --git a/salvador/salvador.py b/salvador/salvador.py new file mode 100644 index 0000000..f346e2a --- /dev/null +++ b/salvador/salvador.py @@ -0,0 +1,172 @@ +""" +Sopel plugin: salvador + +Converts an image URL to mIRC colour art using half-block characters. + + !draw <url> - render at default height (6 lines) + !draw <lines> <url> - render at custom height (4-12 lines) +""" + +import time +import urllib.request +from io import BytesIO + +from PIL import Image, ImageFile + +ImageFile.LOAD_TRUNCATED_IMAGES = True +from sopel import plugin + + +MAX_WIDTH = 60 +DEFAULT_HEIGHT = 6 +MIN_HEIGHT = 4 +MAX_HEIGHT = 12 +FLOOD_DELAY = 0.4 +MAX_BYTES = 5 * 1024 * 1024 # 5 MB + +# IRC monospace character cells are approximately twice as tall as they are wide. +# _fit multiplies the image aspect ratio by this factor so the rendered output +# uses enough character columns to compensate for the tall cells. +CHAR_RATIO = 2.0 + +USAGE = f'usage: salvador [<lines>] <url> (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {DEFAULT_HEIGHT})' + +# Full mIRC 99-colour palette (indices 0-98) +MIRC = [ + (0xff,0xff,0xff),(0x00,0x00,0x00),(0x00,0x00,0x7f),(0x00,0x93,0x00), + (0xff,0x00,0x00),(0x7f,0x00,0x00),(0x9c,0x00,0x9c),(0xfc,0x7f,0x00), + (0xff,0xff,0x00),(0x00,0xfc,0x00),(0x00,0x93,0x93),(0x00,0xff,0xff), + (0x00,0x00,0xfc),(0xff,0x00,0xff),(0x55,0x55,0x55),(0xaa,0xaa,0xaa), + (0x47,0x00,0x00),(0x47,0x21,0x00),(0x47,0x47,0x00),(0x32,0x47,0x00), + (0x00,0x47,0x00),(0x00,0x47,0x2c),(0x00,0x47,0x47),(0x00,0x27,0x47), + (0x00,0x00,0x47),(0x2e,0x00,0x47),(0x47,0x00,0x47),(0x47,0x00,0x2a), + (0x74,0x00,0x00),(0x74,0x3a,0x00),(0x74,0x74,0x00),(0x51,0x74,0x00), + (0x00,0x74,0x00),(0x00,0x74,0x49),(0x00,0x74,0x74),(0x00,0x40,0x74), + (0x00,0x00,0x74),(0x4b,0x00,0x74),(0x74,0x00,0x74),(0x74,0x00,0x45), + (0xb5,0x00,0x00),(0xb5,0x63,0x00),(0xb5,0xb5,0x00),(0x7d,0xb5,0x00), + (0x00,0xb5,0x00),(0x00,0xb5,0x71),(0x00,0xb5,0xb5),(0x00,0x63,0xb5), + (0x00,0x00,0xb5),(0x75,0x00,0xb5),(0xb5,0x00,0xb5),(0xb5,0x00,0x6b), + (0xff,0x00,0x00),(0xff,0x8c,0x00),(0xff,0xff,0x00),(0xb2,0xff,0x00), + (0x00,0xff,0x00),(0x00,0xff,0xa0),(0x00,0xff,0xff),(0x00,0x8c,0xff), + (0x00,0x00,0xff),(0xa5,0x00,0xff),(0xff,0x00,0xff),(0xff,0x00,0x98), + (0xff,0x59,0x59),(0xff,0xb4,0x59),(0xff,0xff,0x71),(0xcf,0xff,0x60), + (0x6f,0xff,0x6f),(0x65,0xff,0xc9),(0x6d,0xff,0xff),(0x59,0xb4,0xff), + (0x59,0x59,0xff),(0xc4,0x59,0xff),(0xff,0x66,0xff),(0xff,0x59,0xbc), + (0xff,0x9c,0x9c),(0xff,0xd3,0x9c),(0xff,0xff,0x9c),(0xe2,0xff,0x9c), + (0x9c,0xff,0x9c),(0x9c,0xff,0xdb),(0x9c,0xff,0xff),(0x9c,0xd3,0xff), + (0x9c,0x9c,0xff),(0xdc,0x9c,0xff),(0xff,0x9c,0xff),(0xff,0x94,0xd3), + (0x00,0x00,0x00),(0x13,0x13,0x13),(0x28,0x28,0x28),(0x36,0x36,0x36), + (0x4d,0x4d,0x4d),(0x65,0x65,0x65),(0x81,0x81,0x81),(0x9f,0x9f,0x9f), + (0xbc,0xbc,0xbc),(0xe2,0xe2,0xe2),(0xff,0xff,0xff), +] + + +def _nearest(r, g, b): + # Perceptually-weighted RGB distance + rmean = (r + 128) / 2 + best, best_dist = 0, float('inf') + for i, (cr, cg, cb) in enumerate(MIRC): + dr, dg, db = r - cr, g - cg, b - cb + dist = (2 + rmean / 256) * dr*dr + 4*dg*dg + (2 + (255 - rmean) / 256) * db*db + if dist < best_dist: + best_dist = dist + best = i + return best + + +def _fetch(url): + req = urllib.request.Request(url, headers={'User-Agent': 'salvador/1.0'}) + with urllib.request.urlopen(req, timeout=10) as resp: + content_type = resp.headers.get('Content-Type', '') + if 'image' not in content_type: + raise ValueError(f'not an image ({content_type})') + return resp.read(MAX_BYTES) + + +def _fit(img_w, img_h, max_w, max_h): + aspect = (img_w / img_h) * CHAR_RATIO + char_aspect = max_w / max_h + if aspect >= char_aspect: + w = max_w + h = max(1, round(max_w / aspect)) + else: + h = max_h + w = max(1, round(max_h * aspect)) + return w, h + + +def _dither(img, w, h): + buf = [[list(map(float, img.getpixel((x, y)))) for x in range(w)] + for y in range(h)] + idx = [[0] * w for _ in range(h)] + for y in range(h): + for x in range(w): + r, g, b = (max(0, min(255, int(v))) for v in buf[y][x]) + c = _nearest(r, g, b) + idx[y][x] = c + cr, cg, cb = MIRC[c] + er, eg, eb = r - cr, g - cg, b - cb + for dx, dy, f in ((1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)): + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < h: + buf[ny][nx][0] += er * f / 16 + buf[ny][nx][1] += eg * f / 16 + buf[ny][nx][2] += eb * f / 16 + return idx + + +def _img_to_irc(url, height=DEFAULT_HEIGHT): + data = _fetch(url) + img = Image.open(BytesIO(data)).convert('RGB') + char_w, char_h = _fit(img.width, img.height, MAX_WIDTH, height) + img = img.resize((char_w, char_h * 2), Image.LANCZOS) + idx = _dither(img, char_w, char_h * 2) + + lines = [] + for row in range(char_h): + line = '' + last_fg = last_bg = None + for col in range(char_w): + bg = idx[row * 2][col] + fg = idx[row * 2 + 1][col] + if fg != last_fg or bg != last_bg: + line += f'\x03{fg:02d},{bg:02d}' + last_fg, last_bg = fg, bg + line += '▄' + line += '\x0f' + lines.append(line) + return lines + + +@plugin.command("draw") +@plugin.require_chanmsg +def cmd_draw(bot, trigger): + args = (trigger.group(2) or "").split(None, 1) + if not args: + bot.say(USAGE, trigger.nick) + return + + height = DEFAULT_HEIGHT + if len(args) == 2 and args[0].isdigit(): + n = int(args[0]) + if not (MIN_HEIGHT <= n <= MAX_HEIGHT): + bot.say(USAGE, trigger.nick) + return + height = n + url = args[1] + else: + url = args[0] + + url = url.rstrip('.,)>') + if not url.startswith(('http://', 'https://')): + bot.say(USAGE, trigger.nick) + return + + try: + lines = _img_to_irc(url, height=height) + except Exception as e: + bot.say(str(e)) + return + for line in lines: + bot.say(line) + time.sleep(FLOOD_DELAY) |
