Monorepo for Aesthetic.Computer aesthetic.computer
at main 451 lines 17 kB view raw
1"""ac — Aesthetic Computer notebook utilities. 2 3Usage (in any notebook): 4 import sys, os; sys.path.insert(0, os.path.join(os.getcwd(), "notebooks") if not os.getcwd().endswith("notebooks") else os.getcwd()) 5 from ac import kidlisp, show_card 6 7 # Render and display a KidLisp program inline 8 show_card("Starburst", "(wipe black) (ink white) (repeat 72 i (line 80 60 ...))") 9 10 # Just render to PIL image (no display) 11 img = kidlisp("(wipe navy) (ink cyan) (circle 80 60 40)") 12""" 13 14import math, io, base64, sys, os 15 16# Ensure notebooks/ is on sys.path for sibling imports 17_dir = os.path.dirname(os.path.abspath(__file__)) 18if _dir not in sys.path: 19 sys.path.insert(0, _dir) 20from PIL import Image, ImageDraw 21from IPython.display import display, HTML 22 23 24# --------------------------------------------------------------------------- 25# Colors 26# --------------------------------------------------------------------------- 27 28CSS_COLORS = { 29 "black": (0,0,0), "white": (255,255,255), "red": (255,0,0), 30 "green": (0,128,0), "blue": (0,0,255), "lime": (0,255,0), 31 "cyan": (0,255,255), "magenta": (255,0,255), "yellow": (255,255,0), 32 "orange": (255,165,0), "navy": (0,0,128), "purple": (128,0,128), 33 "brown": (139,69,19), "pink": (255,192,203), "gray": (128,128,128), 34 "grey": (128,128,128), "beige": (245,245,220), "coral": (255,127,80), 35 "gold": (255,215,0), "silver": (192,192,192), "teal": (0,128,128), 36 "maroon": (128,0,0), "olive": (128,128,0), "aqua": (0,255,255), 37 "salmon": (250,128,114), "khaki": (240,230,140), 38 "indigo": (75,0,130), "violet": (238,130,238), "crimson": (220,20,60), 39 "tomato": (255,99,71), "turquoise": (64,224,208), "plum": (221,160,221), 40 "tan": (210,180,140), "sienna": (160,82,45), "peru": (205,133,63), 41 "lavender": (230,230,250), "ivory": (255,255,240), "linen": (250,240,230), 42 "wheat": (245,222,179), "chocolate": (210,105,30), "firebrick": (178,34,34), 43} 44 45def _resolve_color(c): 46 if isinstance(c, tuple): return c 47 if isinstance(c, str): 48 c = c.strip().strip('"').strip("'").lower() 49 if c in CSS_COLORS: return CSS_COLORS[c] 50 if isinstance(c, (int, float)): return (int(c), int(c), int(c)) 51 return (255, 255, 255) 52 53 54# --------------------------------------------------------------------------- 55# Canvas 56# --------------------------------------------------------------------------- 57 58class _KLCanvas: 59 def __init__(self, w=160, h=120): 60 self.W, self.H = w, h 61 self.img = Image.new("RGBA", (w, h), (0, 0, 0, 255)) 62 self.draw = ImageDraw.Draw(self.img) 63 self.ink = (255, 255, 255, 255) 64 self.fill_mode = True 65 self.stroke_w = 1 66 self.env = {} 67 self.fns = {} 68 69 def set_ink(self, *args): 70 args = [a for a in args if a is not None] 71 if len(args) == 1: 72 c = _resolve_color(args[0]) 73 self.ink = (*c[:3], 255) if len(c) < 4 else c 74 elif len(args) == 2: 75 c = _resolve_color(args[0]) 76 self.ink = (*c[:3], int(args[1])) 77 elif len(args) >= 3: 78 vals = [int(float(a)) for a in args[:4]] 79 if len(vals) == 3: vals.append(255) 80 self.ink = tuple(vals) 81 82 def wipe(self, *args): 83 if args: 84 c = _resolve_color(args[0]) if len(args) == 1 else tuple(int(float(a)) for a in args[:3]) 85 self.img.paste(Image.new("RGBA", (self.W, self.H), (*c[:3], 255))) 86 else: 87 self.img.paste(Image.new("RGBA", (self.W, self.H), (0, 0, 0, 255))) 88 self.draw = ImageDraw.Draw(self.img) 89 90 def line(self, x1, y1, x2, y2): 91 self.draw.line([(x1, y1), (x2, y2)], fill=self.ink, width=max(1, self.stroke_w)) 92 93 def box(self, x, y, w, h): 94 if self.fill_mode: 95 self.draw.rectangle([(x, y), (x+w, y+h)], fill=self.ink) 96 else: 97 self.draw.rectangle([(x, y), (x+w, y+h)], outline=self.ink, width=self.stroke_w) 98 99 def circle(self, cx, cy, r): 100 r = abs(r) 101 if self.fill_mode: 102 self.draw.ellipse([(cx-r, cy-r), (cx+r, cy+r)], fill=self.ink) 103 else: 104 self.draw.ellipse([(cx-r, cy-r), (cx+r, cy+r)], outline=self.ink, width=self.stroke_w) 105 106 def tri(self, x1, y1, x2, y2, x3, y3): 107 pts = [(x1, y1), (x2, y2), (x3, y3)] 108 if self.fill_mode: 109 self.draw.polygon(pts, fill=self.ink) 110 else: 111 self.draw.polygon(pts, outline=self.ink) 112 113 def plot(self, x, y): 114 ix, iy = int(x), int(y) 115 if 0 <= ix < self.W and 0 <= iy < self.H: 116 self.img.putpixel((ix, iy), self.ink) 117 118 def shape(self, *coords): 119 pts = [(coords[i], coords[i+1]) for i in range(0, len(coords)-1, 2)] 120 if self.fill_mode: 121 self.draw.polygon(pts, fill=self.ink) 122 else: 123 self.draw.polygon(pts, outline=self.ink) 124 125 # --- turtle graphics --- 126 def _init_turtle(self): 127 if not hasattr(self, '_tx'): 128 self._tx, self._ty = self.W / 2, self.H / 2 129 self._tangle = 0 # degrees, 0 = right 130 self._tpen = True 131 132 def crawl(self, steps=1): 133 self._init_turtle() 134 rad = math.radians(self._tangle) 135 nx = self._tx + steps * math.cos(rad) 136 ny = self._ty + steps * math.sin(rad) 137 if self._tpen: 138 self.draw.line([(self._tx, self._ty), (nx, ny)], 139 fill=self.ink, width=max(1, self.stroke_w)) 140 self._tx, self._ty = nx, ny 141 142 def turtle_left(self, deg=1): 143 self._init_turtle(); self._tangle -= deg 144 145 def turtle_right(self, deg=1): 146 self._init_turtle(); self._tangle += deg 147 148 def turtle_up(self): 149 self._init_turtle(); self._tpen = False 150 151 def turtle_down(self): 152 self._init_turtle(); self._tpen = True 153 154 def turtle_goto(self, x=None, y=None): 155 self._init_turtle() 156 if x is None: x = self.W / 2 157 if y is None: y = self.H / 2 158 if self._tpen: 159 self.draw.line([(self._tx, self._ty), (x, y)], 160 fill=self.ink, width=max(1, self.stroke_w)) 161 self._tx, self._ty = x, y 162 163 def turtle_face(self, angle=0): 164 self._init_turtle(); self._tangle = angle 165 166 167# --------------------------------------------------------------------------- 168# Parser 169# --------------------------------------------------------------------------- 170 171def _tokenize(src): 172 tokens = [] 173 i = 0 174 while i < len(src): 175 c = src[i] 176 if c == ';': 177 while i < len(src) and src[i] != '\n': i += 1 178 continue 179 if c in '()': 180 tokens.append(c); i += 1; continue 181 if c in ' \t\n\r,': 182 i += 1; continue 183 if c == '"': 184 j = i + 1 185 while j < len(src) and src[j] != '"': j += 1 186 tokens.append(src[i:j+1]); i = j + 1; continue 187 j = i 188 while j < len(src) and src[j] not in ' \t\n\r,();"': j += 1 189 tokens.append(src[i:j]); i = j 190 return tokens 191 192def _parse_atom(t): 193 if t.startswith('"') and t.endswith('"'): return t[1:-1] 194 try: return int(t) 195 except ValueError: pass 196 try: return float(t) 197 except ValueError: pass 198 return t 199 200def _parse_one(tokens, pos): 201 if tokens[pos] == '(': 202 pos += 1; sub = [] 203 while pos < len(tokens) and tokens[pos] != ')': 204 val, pos = _parse_one(tokens, pos); sub.append(val) 205 return sub, pos + 1 206 return _parse_atom(tokens[pos]), pos + 1 207 208def _parse(tokens): 209 results = []; pos = 0 210 while pos < len(tokens): 211 t = tokens[pos] 212 if t == '(': 213 pos += 1; sub = [] 214 while pos < len(tokens) and tokens[pos] != ')': 215 val, pos = _parse_one(tokens, pos); sub.append(val) 216 pos += 1; results.append(sub) 217 elif t == ')': 218 break 219 else: 220 results.append(_parse_atom(t)); pos += 1 221 return results 222 223def _parse_program(src): 224 lines = src.strip().split('\n') 225 converted = [] 226 for line in lines: 227 s = line.strip() 228 if not s or s.startswith(';'): converted.append(s); continue 229 if s.startswith('('): 230 converted.append(s) 231 else: 232 for part in (p.strip() for p in s.split(',')): 233 if not part: continue 234 toks = part.split() 235 if len(toks) == 1 and toks[0] in CSS_COLORS: 236 converted.append(f'(wipe "{toks[0]}")') 237 elif len(toks) == 1: 238 converted.append(f'({toks[0]})') 239 else: 240 converted.append(f'({part})') 241 return _parse(_tokenize(' '.join(converted))) 242 243 244# --------------------------------------------------------------------------- 245# Evaluator 246# --------------------------------------------------------------------------- 247 248def _eval(c, expr): 249 if isinstance(expr, (int, float)): return expr 250 if isinstance(expr, str): 251 if expr in ('w', 'width'): return c.W 252 if expr in ('h', 'height'): return c.H 253 if expr == 'pi': return math.pi 254 if expr in c.env: return c.env[expr] 255 if expr in CSS_COLORS: return expr 256 return expr 257 if not isinstance(expr, list) or not expr: return expr 258 259 h = expr[0] 260 261 # --- arithmetic --- 262 if h == '+': return sum(v for v in (_eval(c, a) for a in expr[1:]) if isinstance(v, (int, float))) 263 if h == '-': 264 vs = [_eval(c, a) for a in expr[1:]] 265 return -vs[0] if len(vs) == 1 else vs[0] - sum(vs[1:]) 266 if h == '*': 267 r = 1 268 for v in (_eval(c, a) for a in expr[1:]): 269 if isinstance(v, (int, float)): r *= v 270 return r 271 if h == '/': 272 vs = [_eval(c, a) for a in expr[1:]] 273 return vs[0] / vs[1] if len(vs) >= 2 and vs[1] != 0 else 0 274 if h == '%': 275 vs = [_eval(c, a) for a in expr[1:]] 276 return vs[0] % vs[1] if len(vs) >= 2 and vs[1] != 0 else 0 277 if h == '&': 278 vs = [int(_eval(c, a)) for a in expr[1:]] 279 return vs[0] & vs[1] if len(vs) >= 2 else 0 280 if h == 'abs': return abs(_eval(c, expr[1])) 281 if h == 'sin': return math.sin(_eval(c, expr[1])) 282 if h == 'cos': return math.cos(_eval(c, expr[1])) 283 if h == 'tan': return math.tan(_eval(c, expr[1])) 284 if h == 'sqrt': return math.sqrt(max(0, _eval(c, expr[1]))) 285 if h == 'pow': return math.pow(_eval(c, expr[1]), _eval(c, expr[2])) 286 if h == 'floor': return math.floor(_eval(c, expr[1])) 287 if h == 'ceil': return math.ceil(_eval(c, expr[1])) 288 if h == 'round': return round(_eval(c, expr[1])) 289 if h == 'min': return min(_eval(c, a) for a in expr[1:]) 290 if h == 'max': return max(_eval(c, a) for a in expr[1:]) 291 if h == '=': 292 vs = [_eval(c, a) for a in expr[1:]] 293 return 1 if len(vs) >= 2 and vs[0] == vs[1] else 0 294 if h in ('<', '>', '<=', '>=', '!='): 295 a, b = _eval(c, expr[1]), _eval(c, expr[2]) 296 return 1 if (h == '<' and a < b) or (h == '>' and a > b) or \ 297 (h == '<=' and a <= b) or (h == '>=' and a >= b) or \ 298 (h == '!=' and a != b) else 0 299 300 # --- drawing --- 301 if h == 'wipe': c.wipe(*[_eval(c, a) for a in expr[1:]]); return 302 if h == 'ink': c.set_ink(*[_eval(c, a) for a in expr[1:]]); return 303 if h == 'line': 304 vs = [_eval(c, a) for a in expr[1:]] 305 if len(vs) >= 4: c.line(*[float(v) for v in vs[:4]]) 306 return 307 if h == 'box': 308 vs = [_eval(c, a) for a in expr[1:]] 309 if len(vs) >= 4: c.box(*[float(v) for v in vs[:4]]) 310 return 311 if h == 'circle': 312 vs = [_eval(c, a) for a in expr[1:]] 313 if len(vs) >= 3: c.circle(*[float(v) for v in vs[:3]]) 314 return 315 if h == 'tri': 316 vs = [_eval(c, a) for a in expr[1:]] 317 if len(vs) >= 6: c.tri(*[float(v) for v in vs[:6]]) 318 return 319 if h == 'plot': 320 vs = [_eval(c, a) for a in expr[1:]] 321 if len(vs) >= 2: c.plot(float(vs[0]), float(vs[1])) 322 return 323 if h == 'shape': 324 vs = [_eval(c, a) for a in expr[1:]] 325 c.shape(*[float(v) for v in vs if isinstance(v, (int, float))]) 326 return 327 if h == 'fill': c.fill_mode = True; return 328 if h == 'outline': c.fill_mode = False; return 329 if h == 'stroke': c.stroke_w = int(_eval(c, expr[1])); return 330 if h == 'resolution': 331 nw, nh = int(_eval(c, expr[1])), int(_eval(c, expr[2])) 332 c.W, c.H = nw, nh 333 c.img = Image.new("RGBA", (nw, nh), (0, 0, 0, 255)) 334 c.draw = ImageDraw.Draw(c.img) 335 return 336 if h == 'flood': 337 vs = [_eval(c, a) for a in expr[1:]] 338 if len(vs) >= 2: ImageDraw.floodfill(c.img, (int(vs[0]), int(vs[1])), c.ink) 339 return 340 341 # --- turtle --- 342 if h == 'crawl': 343 vs = [_eval(c, a) for a in expr[1:]] 344 c.crawl(float(vs[0]) if vs else 1); return 345 if h == 'left': 346 vs = [_eval(c, a) for a in expr[1:]] 347 c.turtle_left(float(vs[0]) if vs else 1); return 348 if h == 'right': 349 vs = [_eval(c, a) for a in expr[1:]] 350 c.turtle_right(float(vs[0]) if vs else 1); return 351 if h == 'up': c.turtle_up(); return 352 if h == 'down': c.turtle_down(); return 353 if h == 'goto': 354 vs = [_eval(c, a) for a in expr[1:]] 355 c.turtle_goto(float(vs[0]) if len(vs) >= 1 else None, 356 float(vs[1]) if len(vs) >= 2 else None); return 357 if h == 'face': 358 vs = [_eval(c, a) for a in expr[1:]] 359 c.turtle_face(float(vs[0]) if vs else 0); return 360 361 # --- control flow --- 362 if h == 'def' or h == 'now': 363 c.env[expr[1]] = _eval(c, expr[2]); return c.env[expr[1]] 364 if h == 'if': 365 cond = _eval(c, expr[1]) 366 if cond and cond != 0: return _eval(c, expr[2]) 367 elif len(expr) > 3: return _eval(c, expr[3]) 368 return 369 if h in ('repeat', 'bunch'): 370 count = min(int(_eval(c, expr[1])), 10000) 371 if len(expr) >= 4 and isinstance(expr[2], str): 372 var, bodies = expr[2], expr[3:] 373 for i in range(count): 374 c.env[var] = i 375 for body in bodies: _eval(c, body) 376 elif len(expr) >= 3: 377 bodies = expr[2:] 378 for _ in range(count): 379 for body in bodies: _eval(c, body) 380 return 381 if h == 'later': 382 name = expr[1] 383 params = expr[2] if isinstance(expr[2], list) else [expr[2]] 384 c.fns[name] = (params, expr[3:]) 385 return 386 if h == 'do': 387 r = None 388 for sub in expr[1:]: r = _eval(c, sub) 389 return r 390 391 # --- user function call --- 392 if isinstance(h, str) and h in c.fns: 393 params, body = c.fns[h] 394 args = [_eval(c, a) for a in expr[1:]] 395 old = dict(c.env) 396 for p, a in zip(params, args): c.env[p] = a 397 r = None 398 for b in body: r = _eval(c, b) 399 c.env = old 400 return r 401 402 # fallback 403 results = [_eval(c, a) for a in expr] 404 return results[-1] if results else None 405 406 407# --------------------------------------------------------------------------- 408# Public API 409# --------------------------------------------------------------------------- 410 411def kidlisp(source, w=160, h=120, scale=3): 412 """Render a KidLisp program and return a scaled PIL Image. 413 414 Args: 415 source: KidLisp source code (s-expr or comma syntax). 416 w, h: Canvas size in pixels (before scaling). 417 scale: Integer upscale factor (NEAREST for pixel-art look). 418 419 Returns: 420 PIL.Image.Image (RGBA). 421 """ 422 c = _KLCanvas(w, h) 423 try: 424 for expr in _parse_program(source): 425 _eval(c, expr) 426 except Exception as e: 427 c.draw.text((4, 4), f"ERR: {e}", fill=(255, 0, 0, 255)) 428 return c.img.resize((w * scale, h * scale), Image.NEAREST) 429 430 431def show_card(name, source, w=160, h=120, scale=3): 432 """Render a KidLisp program and display it inline as a card. 433 434 Args: 435 name: Card title shown below the image. 436 source: KidLisp source code. 437 w, h: Canvas size in pixels. 438 scale: Upscale factor. 439 """ 440 img = kidlisp(source, w, h, scale) 441 buf = io.BytesIO() 442 img.save(buf, format='PNG') 443 b64 = base64.b64encode(buf.getvalue()).decode() 444 display(HTML(f''' 445 <div style="display:inline-block; margin:10px; vertical-align:top; max-width:{w*scale+24}px;"> 446 <div style="background:#111; border-radius:12px; padding:12px; border:1px solid #333;"> 447 <div style="color:#eee; font-family:monospace; font-size:14px; font-weight:bold; margin-bottom:8px;">{name}</div> 448 <img src="data:image/png;base64,{b64}" style="border-radius:8px; display:block;" /> 449 <pre style="color:#9d9; font-family:monospace; font-size:13px; margin:10px 0 0 0; white-space:pre-wrap; line-height:1.5;">{source}</pre> 450 </div> 451 </div>'''))