## Sprite → truecolor ANSI for terminals (half-block packing). ## Each terminal cell covers 2 vertical pixels: bg = upper, fg = lower. ## Sequences are kept compact (single CSI, reset once per line) so tools like ## termshot/bunt and narrow PTYs do not choke mid-escape. import std/[strutils, strformat] import ./avatar type Rgba* = object width*, height*: int data*: seq[int] ## w*h*4 bytes func hexToRgb*(hex: string): (int, int, int) = (parseHexInt(hex[1 .. 2]), parseHexInt(hex[3 .. 4]), parseHexInt(hex[5 .. 6])) func spriteToRgba*(s: Sprite): Rgba = ## Expand a palette-indexed sprite to RGBA (alpha 0 for transparent). result = Rgba(width: s.width, height: s.height, data: newSeq[int](s.width * s.height * 4)) for i, idx in s.pixels: let color = s.palette[idx] if color != "transparent": let (r, g, b) = hexToRgb(color) let o = i * 4 result.data[o] = r result.data[o + 1] = g result.data[o + 2] = b result.data[o + 3] = 255 func scaleRgba*(src: Rgba, scale: int): Rgba = ## Nearest-neighbor upscale. if scale <= 1: return src let w = src.width * scale h = src.height * scale result = Rgba(width: w, height: h, data: newSeq[int](w * h * 4)) for y in 0 ..< h: let sy = y div scale for x in 0 ..< w: let sx = x div scale si = (sy * src.width + sx) * 4 di = (y * w + x) * 4 for k in 0 ..< 4: result.data[di + k] = src.data[si + k] const ESC = "\x1b" RESET = ESC & "[0m" LOWER = "▄" UPPER = "▀" func fg(r, g, b: int): string = &"{ESC}[38;2;{r};{g};{b}m" func bgFg(ur, ug, ub, lr, lg, lb: int): string = &"{ESC}[48;2;{ur};{ug};{ub};38;2;{lr};{lg};{lb}m" type Mode = enum mNone, mSpace, mLower, mUpper, mBoth func rgbaToAnsi*(img: Rgba): string = ## Convert an RGBA raster to a multi-line truecolor ANSI string. let w = img.width h = img.height func px(y, x, k: int): int = img.data[(y * w + x) * 4 + k] var lines: seq[string] var y = 0 while y < h: let y2 = y + 1 var line = "" mode = mNone fr, fgc, fb, br, bg, bb = -1 for x in 0 ..< w: let ur = px(y, x, 0) ug = px(y, x, 1) ub = px(y, x, 2) ua = px(y, x, 3) lr = if y2 < h: px(y2, x, 0) else: 0 lg = if y2 < h: px(y2, x, 1) else: 0 lb = if y2 < h: px(y2, x, 2) else: 0 la = if y2 < h: px(y2, x, 3) else: 0 if ua == 0 and la == 0: if mode notin {mNone, mSpace}: line.add RESET line.add " " mode = mSpace fr = -1; fgc = -1; fb = -1; br = -1; bg = -1; bb = -1 elif ua == 0: if mode != mLower or fr != lr or fgc != lg or fb != lb: line.add fg(lr, lg, lb) & LOWER mode = mLower fr = lr; fgc = lg; fb = lb; br = -1; bg = -1; bb = -1 else: line.add LOWER elif la == 0: if mode != mUpper or fr != ur or fgc != ug or fb != ub: line.add fg(ur, ug, ub) & UPPER mode = mUpper fr = ur; fgc = ug; fb = ub; br = -1; bg = -1; bb = -1 else: line.add UPPER else: # both: single CSI with bg = upper, fg = lower if mode != mBoth or fr != lr or fgc != lg or fb != lb or br != ur or bg != ug or bb != ub: line.add bgFg(ur, ug, ub, lr, lg, lb) & LOWER mode = mBoth fr = lr; fgc = lg; fb = lb; br = ur; bg = ug; bb = ub else: line.add LOWER if mode notin {mNone, mSpace}: line.add RESET lines.add line y += 2 lines.join("\n") func avatarToAnsi*(av: Avatar, scale = 2, facing = fSouth, frame = 0): string = rgbaToAnsi(scaleRgba(spriteToRgba(renderSpritePixels(av, facing, frame)), scale)) func didToAnsi*(did: string, scale = 2, facing = fSouth, frame = 0): string = ## DID → truecolor ANSI art (no network). avatarToAnsi(deriveAvatar(did), scale, facing, frame)