## Half-block Unicode renderer - universal fallback. ## ## Uses ▀ (UPPER HALF BLOCK) with 24-bit foreground/background ## colors. Two vertical samples per character cell. Works in any ## truecolor terminal. Supports bilinear scaling and optional ## Floyd-Steinberg dithering for downscaled images. import std/[strutils, math] import types, scaling const Csi = "\e[" ResetColor = Csi & "0m" UpperHalfBlock = "▀" type Color = tuple[r, g, b: uint8] proc foreground(color: Color): string = Csi & "38;2;" & $color.r & ";" & $color.g & ";" & $color.b & "m" proc background(color: Color): string = Csi & "48;2;" & $color.r & ";" & $color.g & ";" & $color.b & "m" proc compositeOver(img: ImageData, bg: tuple[r, g, b: uint8]): seq[uint8] = let total = img.width * img.height result = newSeq[uint8](total * 3) for i in 0 ..< total: let off = i * 4 let a = float(img.data[off + 3]) / 255.0 let inva = 1.0 - a result[i * 3 + 0] = uint8(float(img.data[off + 0]) * a + float(bg.r) * inva) result[i * 3 + 1] = uint8(float(img.data[off + 1]) * a + float(bg.g) * inva) result[i * 3 + 2] = uint8(float(img.data[off + 2]) * a + float(bg.b) * inva) proc floydSteinbergHalfBlock( buf: var seq[uint8], w, h: int, ) = ## In-place Floyd-Steinberg dithering of the resized RGB buffer. ## Distributes quantization error to smooth banding in downscaled images. for y in 0 ..< h: for x in 0 ..< w: let off = (y * w + x) * 3 for c in 0 ..< 3: var old = float(buf[off + c]) var newVal = round(old) if newVal < 0.0: newVal = 0.0 if newVal > 255.0: newVal = 255.0 let err = old - newVal buf[off + c] = uint8(newVal) template d(xx, yy, wgt: untyped) = if xx >= 0 and xx < w and yy >= 0 and yy < h: let toff = (yy * w + xx) * 3 buf[toff + c] = uint8(clamp( int(round(float(buf[toff + c]) + err * wgt)), 0, 255)) d(x + 1, y, 7.0 / 16.0) d(x - 1, y + 1, 3.0 / 16.0) d(x, y + 1, 5.0 / 16.0) d(x + 1, y + 1, 1.0 / 16.0) proc renderHalfBlock*( img: ImageData, background: tuple[r, g, b: uint8], columns, sampleHeight: int, dither: bool = false, ): string = ## Produce half-block Unicode output. ## Returns empty string if source or target dimensions are invalid. if img.width <= 0 or img.height <= 0 or columns <= 0 or sampleHeight <= 0: return "" if img.data.len < img.width * img.height * 4: return "" let rgb = compositeOver(img, background) let rgbScaled = bilinearResizeRgb(rgb, img.width, img.height, columns, sampleHeight) var buf = rgbScaled if dither: floydSteinbergHalfBlock(buf, columns, sampleHeight) var lines: seq[string] for top in countup(0, sampleHeight - 2, 2): var parts: seq[string] var prevTop: Color = (0'u8, 0, 0) var prevBot: Color = (0'u8, 0, 0) var first = true for col in 0 ..< columns: let topOff = (top * columns + col) * 3 let botOff = ((top + 1) * columns + col) * 3 let upper: Color = ( buf[topOff + 0], buf[topOff + 1], buf[topOff + 2], ) let lower: Color = ( buf[botOff + 0], buf[botOff + 1], buf[botOff + 2], ) if first or upper != prevTop: parts.add(foreground(upper)) prevTop = upper if first or lower != prevBot: parts.add(background(lower)) prevBot = lower parts.add(UpperHalfBlock) first = false parts.add(ResetColor) lines.add(join(parts)) result = join(lines, "\n")