## 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")