""" Sopel plugin: jeeves art Converts an image URL to mIRC colour art using half-block characters. !draw [] - render image (lines: 4-12, default from !set art_height) Default height is configurable via !set art_height . Requires Pillow (pip install Pillow). Gracefully disabled if not installed. """ import os as _os, sys as _sys _d = _os.path.dirname(_os.path.abspath(__file__)) if _d not in _sys.path: _sys.path.insert(0, _d) import jv_core as jv import time import urllib.request from sopel import plugin try: from io import BytesIO from PIL import Image, ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = True _PILLOW = True except ImportError: _PILLOW = False MAX_WIDTH = 60 MIN_HEIGHT = 4 MAX_HEIGHT = 12 FLOOD_DELAY = 0.4 MAX_BYTES = 5 * 1024 * 1024 # 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 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 setup(bot): jv.ensure_setup(bot) def _nearest(r, g, b): 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": "jeeves/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 if aspect >= max_w / max_h: 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): 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): if not _PILLOW: bot.notice("!draw requires Pillow (pip install Pillow), not installed", trigger.nick) return args = (trigger.group(2) or "").split(None, 1) default_height = jv.cfg_val(bot, "art_height") if not args: bot.notice(f"usage: !draw [] (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {default_height})", 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.notice(f"lines must be between {MIN_HEIGHT} and {MAX_HEIGHT}", trigger.nick) return height = n url = args[1] else: url = args[0] url = url.rstrip(".,)>") if not url.startswith(("http://", "https://")): bot.notice(f"usage: !draw [] (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {default_height})", trigger.nick) return try: lines = _img_to_irc(url, height=height) except Exception as e: bot.say(str(e), trigger.sender) return for line in lines: bot.say(line, trigger.sender) time.sleep(FLOOD_DELAY)