aboutsummaryrefslogtreecommitdiffstats
path: root/salvador/salvador.py
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
committerAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
commit5a8d568931d9b23ce0df1265d05259a7012081c9 (patch)
tree4ea19b8bb1763a38246f342f172c285f6579e09b /salvador/salvador.py
init: mostly vibed
Diffstat (limited to 'salvador/salvador.py')
-rw-r--r--salvador/salvador.py172
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)