This commit is contained in:
2026-07-08 09:02:47 +02:00
commit 87ae53d2f5
15 changed files with 2316 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
## termimg — Terminal Image Rendering for Nim
##
## Picture-perfect image rendering in the native terminal. Renders RGBA
## pixel data using the highest-fidelity protocol the terminal supports.
##
## ## Supported Protocols
##
## | Protocol | Fidelity | Mechanism |
## |------------- |-----------------------|-------------------------------------------------|
## | kitty | Exact pixels | RGBA transmitted in base64 chunks |
## | iterm2 | Exact | Raw file bytes via OSC 1337 inline image |
## | sixel | 256-color palette | DCS sixel bands with RLE compression |
## | quarterblock | 4 sub-cells per cell | Unicode quadrant blocks with 24-bit truecolor |
## | halfblock | 2 vertical per cell | Unicode ▀ with 24-bit foreground/background |
##
## ## Usage
##
## ```nim
## import termimg
##
## let caps = detectCapabilities()
## let opts = defaultOptions()
## let output = renderImageRgba(rgbaPixels, 640, 480, caps, opts)
## stdout.write(output)
## ```
import termimg/types, termimg/capabilities, termimg/renderer, termimg/scaling
import termimg/kitty, termimg/sixel, termimg/iterm2, termimg/halfblock, termimg/quarterblock
export termimg.types, termimg.capabilities, termimg.renderer, termimg.scaling
export termimg.kitty, termimg.sixel, termimg.iterm2, termimg.halfblock, termimg.quarterblock
+62
View File
@@ -0,0 +1,62 @@
## Terminal capability detection: size, cell dimensions, and protocol probing.
##
## Detection uses environment heuristics combined with terminal escape query
## where available. Falls back gracefully to halfblock in all terminals.
import std/[os, terminal, strutils]
import types
proc terminalIsInteractive*(): bool =
isatty(stdout) and isatty(stdin)
proc cellSize*(columns, rows: int): CellSize =
## Estimate cell pixel dimensions.
## For true-color terminals, these defaults work well.
## Actual pixel dimensions are derived during rendering.
CellSize(width: DefaultCellWidth, height: DefaultCellHeight)
proc protocolsFromEnvironment(): set[Protocol] =
## Detect supported image protocols from environment variables.
let term = getEnv("TERM", "")
let termProgram = getEnv("TERM_PROGRAM", "")
# Kitty protocol
if getEnv("KITTY_WINDOW_ID", "").len > 0:
result.incl ptKitty
if termProgram in ["kitty", "ghostty", "WezTerm"]:
result.incl ptKitty
if getEnv("WEZTERM_EXECUTABLE", "").len > 0:
result.incl ptKitty
result.incl ptSixel
if term.find("kitty") >= 0:
result.incl ptKitty
# iTerm2 protocol
if termProgram in ["iTerm.app", "vscode"]:
result.incl ptIterm2
# Sixel protocol
let colorterm = getEnv("COLORTERM", "")
if colorterm.find("sixel") >= 0 or term.find("sixel") >= 0:
result.incl ptSixel
if term in ["foot", "xterm-foot", "mlterm"]:
result.incl ptSixel
proc detectCapabilities*(): TerminalCapabilities =
## Resolve terminal capabilities: size, cell size, and supported protocols.
## Always returns valid values — never crashes even outside a terminal.
let rawCols = terminalWidth()
let rawRows = terminalHeight()
let columns = if rawCols > 0: rawCols else: 80
let rows = if rawRows > 0: rawRows else: 24
let cell = cellSize(columns, rows)
var protocols: set[Protocol] = {ptHalfBlock}
protocols = protocols + protocolsFromEnvironment()
TerminalCapabilities(
columns: columns,
rows: rows,
cell: cell,
protocols: protocols,
)
+116
View File
@@ -0,0 +1,116 @@
## 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")
+18
View File
@@ -0,0 +1,18 @@
## iTerm2 inline image protocol renderer.
##
## Encodes raw file bytes as base64 and emits via OSC 1337.
import std/base64
const
Osc = "\e]"
Bel = "\a"
proc renderIterm2*(rawBytes: seq[uint8], width, height: int): string =
## Produce iTerm2 inline image escape sequence.
## `rawBytes` is the original image file bytes (PNG, JPEG, etc.).
let b64 = encode(rawBytes)
result = Osc & "1337;File=inline=1;width=" & $width &
"px;height=" & $height &
"px;size=" & $rawBytes.len &
";name=image;base64," & b64 & Bel & "\n"
+52
View File
@@ -0,0 +1,52 @@
## Kitty graphics protocol renderer.
##
## Transmits RGBA pixel data in base64 chunks. See:
## https://sw.kovidgoyal.net/kitty/graphics-protocol/
import std/[base64, math, strutils]
import types, scaling
const
Apc = "\e_"
St = "\e\\"
KittyChunkBytes = 4096
RgbaFormat = 32
proc renderKitty*(img: ImageData, targetW, targetH: int): string =
## Produce kitty graphics escape sequence for the given image.
## Returns empty string if source or target dimensions are invalid.
if img.width <= 0 or img.height <= 0 or targetW <= 0 or targetH <= 0:
return ""
if img.data.len < img.width * img.height * 4:
return ""
let pixels = bilinearResizeRgba(img, targetW, targetH)
if pixels.len == 0:
return ""
let payload = encode(pixels)
let numChunks = max(1, int(ceil(float(payload.len) / float(KittyChunkBytes))))
var seqs: seq[string]
for i in 0 ..< numChunks:
let chunkStart = i * KittyChunkBytes
let chunkEnd = min(chunkStart + KittyChunkBytes, payload.len)
let chunk = payload[chunkStart ..< chunkEnd]
let last = i == numChunks - 1
let m = if last: 0 else: 1
if i == 0:
seqs.add(Apc & "Ga=T,f=" & $RgbaFormat & ",s=" & $targetW &
",v=" & $targetH & ",m=" & $m & ";" & chunk & St)
else:
seqs.add(Apc & "Gm=" & $m & ";" & chunk & St)
# End-of-data marker + present command
seqs.add(Apc & "Gm=0;" & St)
seqs.add(Apc & "Ga=P" & St)
result = seqs.join()
proc renderKittyFile*(filePath: string): string =
## Tell the terminal to read a PNG file directly (t=f, f=100).
## Only works for local terminals that can access the file path.
let encoded = encode(filePath)
result = Apc & "Gf=100,t=f;" & encoded & St
+197
View File
@@ -0,0 +1,197 @@
## Quarter-block Unicode renderer — double-density fallback.
##
## Uses the Unicode quadrant block characters (▖▗▘▙▚▛▜▝▞▟█ and space)
## to render 2×2 pixel samples per terminal cell. Each character
## encodes which of the four quadrants take the foreground colour
## and which take the background colour.
##
## With Floyd-Steinberg dithering, this produces remarkably detailed
## thumbnails even in terminals without graphics protocol support.
import std/[strutils, math]
import types, scaling
const
Csi = "\e["
ResetColor = Csi & "0m"
type
Color = tuple[r, g, b: uint8]
# Quadrant block character lookup table.
# Index bits: bit0 = top-left, bit1 = top-right, bit2 = bottom-left, bit3 = bottom-right
# 1 = foreground colour, 0 = background colour
const
QuadChars: array[16, string] = [
" ", # 0000 — all background
"▘", # 0001 — bottom-right only
"▝", # 0010 — bottom-left only
"▀", # 0011 — bottom row (both bottom quadrants)
"▖", # 0100 — top-right only
"▞", # 0101 — top-right + bottom-right (right column)
"▚", # 0110 — top-right + bottom-left (diagonal)
"▜", # 0111 — all except top-left
"▗", # 1000 — top-left only
"▙", # 1001 — all except top-right
"▛", # 1010 — top-left + bottom-left (left column)
"▟", # 1011 — all except bottom-right
"▄", # 1100 — top row (both top quadrants)
"▌", # 1101 — left column (top-left + bottom-left)
"▐", # 1110 — right column (top-right + bottom-right)
"█", # 1111 — all foreground
]
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 floydSteinbergQuarter(
buf: var seq[uint8], w, h: int,
) =
## In-place Floyd-Steinberg dithering.
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 renderQuarterBlock*(
img: ImageData,
background: tuple[r, g, b: uint8],
columns, sampleHeight: int,
dither: bool = true,
): string =
## Produce quarter-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:
floydSteinbergQuarter(buf, columns, sampleHeight)
# Each cell covers 2×2 source pixels.
# Rows: cells = sampleHeight / 2
# Columns: cells = columns / 2
let cellRows = sampleHeight div 2
let cellCols = columns div 2
var lines: seq[string]
for cy in 0 ..< cellRows:
var parts: seq[string]
var prevFg: Color = (0'u8, 0, 0)
var prevBg: Color = (0'u8, 0, 0)
var first = true
for cx in 0 ..< cellCols:
# Read the 2×2 source region for this cell
let px = cx * 2
let py = cy * 2
template get(xx, yy: int): Color =
let o = ((yy) * columns + (xx)) * 3
(buf[o + 0], buf[o + 1], buf[o + 2])
let
tl = get(px, py) # top-left
tr = get(px + 1, py) # top-right
bl = get(px, py + 1) # bottom-left
br = get(px + 1, py + 1) # bottom-right
# Cluster the 4 colours into 2 groups (fg and bg) via simple threshold.
# Compute luminance for each quadrant.
proc lum(c: Color): float =
0.299 * float(c.r) + 0.587 * float(c.g) + 0.114 * float(c.b)
let lumVals = [lum(tl), lum(tr), lum(bl), lum(br)]
let lumMin = min(lumVals)
let lumMax = max(lumVals)
let lumMid = (lumMin + lumMax) / 2.0
# Quadrants above threshold → fg, below → bg
var mask = 0
var fgSumR, fgSumG, fgSumB: float
var bgSumR, bgSumG, bgSumB: float
var fgCount, bgCount: int
template accum(idx, colr: untyped) =
if lumVals[idx] >= lumMid:
mask = mask or (1 shl idx)
fgSumR += float(colr.r); fgSumG += float(colr.g); fgSumB += float(colr.b)
inc fgCount
else:
bgSumR += float(colr.r); bgSumG += float(colr.g); bgSumB += float(colr.b)
inc bgCount
accum(3, br) # bit0 = bottom-right (matches QuadChars ordering)
accum(2, bl) # bit1 = bottom-left
accum(1, tr) # bit2 = top-right
accum(0, tl) # bit3 = top-left
var fg: Color
var bg: Color
if fgCount > 0:
fg = (
uint8(clamp(int(round(fgSumR / float(fgCount))), 0, 255)),
uint8(clamp(int(round(fgSumG / float(fgCount))), 0, 255)),
uint8(clamp(int(round(fgSumB / float(fgCount))), 0, 255)),
)
else:
fg = (0'u8, 0, 0)
if bgCount > 0:
bg = (
uint8(clamp(int(round(bgSumR / float(bgCount))), 0, 255)),
uint8(clamp(int(round(bgSumG / float(bgCount))), 0, 255)),
uint8(clamp(int(round(bgSumB / float(bgCount))), 0, 255)),
)
else:
bg = background
if first or fg != prevFg:
parts.add(foreground(fg))
prevFg = fg
if first or bg != prevBg:
parts.add(background(bg))
prevBg = bg
parts.add(QuadChars[mask])
first = false
parts.add(ResetColor)
lines.add(join(parts))
result = join(lines, "\n")
+109
View File
@@ -0,0 +1,109 @@
## Terminal image renderer — main API and protocol selector.
##
## Auto-detects terminal capabilities and renders images using the
## best available protocol. The priority order is:
## kitty → iterm2 → sixel → quarterblock → halfblock.
##
## ## Quick Start
##
## ```nim
## import termimg
##
## let caps = detectCapabilities()
## let opts = defaultOptions()
## let output = renderImageRgba(pixels, 640, 480, caps, opts)
## stdout.write(output)
## ```
import std/[logging]
import types
import kitty, iterm2, sixel, halfblock, quarterblock
const
PriorityOrder = [ptKitty, ptIterm2, ptSixel, ptQuarterBlock, ptHalfBlock]
proc selectProtocol*(caps: TerminalCapabilities, preferred: Protocol = ptAuto): Protocol =
## Select the best available protocol.
if preferred != ptAuto:
if caps.supports(preferred):
return preferred
warn("preferred protocol " & $preferred & " not detected; forcing anyway")
return preferred
for proto in PriorityOrder:
if caps.supports(proto):
return proto
return ptHalfBlock
proc renderImageRgba*(data: seq[uint8], width, height: int,
caps: TerminalCapabilities,
opts: RenderOptions): string =
## Render RGBA pixel data to a terminal escape string.
## Returns empty string for invalid input (zero dimensions, short buffer).
if width <= 0 or height <= 0 or data.len < width * height * 4:
return ""
let img = ImageData(width: width, height: height, data: data)
let geo = computeGeometry(width, height, caps, opts)
let proto = selectProtocol(caps, opts.protocol)
case proto
of ptKitty:
result = renderKitty(img, geo.pixelWidth, geo.pixelHeight)
of ptIterm2:
result = renderSixel(img, opts.backgroundRgb, geo.pixelWidth, geo.pixelHeight,
dither = opts.dither)
of ptSixel:
result = renderSixel(img, opts.backgroundRgb, geo.pixelWidth, geo.pixelHeight,
dither = opts.dither)
of ptQuarterBlock:
result = renderQuarterBlock(img, opts.backgroundRgb, geo.columns, geo.sampleHeight,
dither = opts.dither)
of ptHalfBlock:
result = renderHalfBlock(img, opts.backgroundRgb, geo.columns, geo.sampleHeight,
dither = opts.dither)
of ptAuto:
result = renderHalfBlock(img, opts.backgroundRgb, geo.columns, geo.sampleHeight,
dither = opts.dither)
proc renderImageRaw*(rawBytes: seq[uint8], width, height: int,
caps: TerminalCapabilities,
opts: RenderOptions): string =
## Render from raw image file bytes.
## Returns empty string for invalid input or when iterm2 is unavailable.
if width <= 0 or height <= 0:
return ""
let proto = selectProtocol(caps, opts.protocol)
if proto == ptIterm2:
let geo = computeGeometry(width, height, caps, opts)
result = renderIterm2(rawBytes, geo.pixelWidth, geo.pixelHeight)
else:
warn("raw bytes rendering requested but iterm2 not available")
result = ""
proc defaultOptions*: RenderOptions =
## Sensible defaults: auto protocol, contain fit, half density.
RenderOptions(
protocol: ptAuto,
fit: fmContain,
maxWidth: 0,
maxHeight: 0,
maxWidthRatio: 0.9,
maxHeightRatio: 0.8,
backgroundRgb: (0'u8, 0'u8, 0'u8),
dither: false,
density: bdHalf,
)
proc thumbnailOptions*: RenderOptions =
## Options tuned for thumbnail rendering: quarter density with dithering.
RenderOptions(
protocol: ptAuto,
fit: fmContain,
maxWidth: 40,
maxHeight: 20,
maxWidthRatio: 0.5,
maxHeightRatio: 0.5,
backgroundRgb: (0'u8, 0'u8, 0'u8),
dither: true,
density: bdQuarter,
)
+137
View File
@@ -0,0 +1,137 @@
## Shared image scaling: bilinear interpolation for RGBA and RGB buffers.
import std/math
import types
proc bilinearResizeRgba*(img: ImageData, dstW, dstH: int): seq[uint8] =
## Bilinear resize of RGBA image. Edge-clamped sampling.
if img.width <= 0 or img.height <= 0 or dstW <= 0 or dstH <= 0:
return newSeq[uint8](max(0, dstW * dstH * 4))
if img.width == dstW and img.height == dstH:
return img.data
let dataLen = img.data.len
let needed = img.width * img.height * 4
if dataLen < needed:
return newSeq[uint8](max(0, dstW * dstH * 4))
result = newSeq[uint8](dstW * dstH * 4)
let
xRatio = float(img.width - 1) / float(max(dstW, 2) - 1)
yRatio = float(img.height - 1) / float(max(dstH, 2) - 1)
for y in 0 ..< dstH:
let
sy = float(y) * yRatio
sy0 = clamp(int(sy), 0, img.height - 1)
sy1 = clamp(sy0 + 1, 0, img.height - 1)
fy = sy - float(sy0)
for x in 0 ..< dstW:
let
sx = float(x) * xRatio
sx0 = clamp(int(sx), 0, img.width - 1)
sx1 = clamp(sx0 + 1, 0, img.width - 1)
fx = sx - float(sx0)
let
off00 = (sy0 * img.width + sx0) * 4
off01 = (sy0 * img.width + sx1) * 4
off10 = (sy1 * img.width + sx0) * 4
off11 = (sy1 * img.width + sx1) * 4
let dstOff = (y * dstW + x) * 4
for c in 0 ..< 4:
let
v00 = float(img.data[off00 + c])
v01 = float(img.data[off01 + c])
v10 = float(img.data[off10 + c])
v11 = float(img.data[off11 + c])
v0 = v00 + (v01 - v00) * fx
v1 = v10 + (v11 - v10) * fx
v = v0 + (v1 - v0) * fy
result[dstOff + c] = uint8(clamp(int(round(v)), 0, 255))
proc bilinearResizeRgb*(rgb: seq[uint8], srcW, srcH, dstW, dstH: int): seq[uint8] =
## Bilinear resize of packed RGB (3 bytes/pixel). Edge-clamped sampling.
if srcW <= 0 or srcH <= 0 or dstW <= 0 or dstH <= 0:
return newSeq[uint8](max(0, dstW * dstH * 3))
let dataLen = rgb.len
let needed = srcW * srcH * 3
if dataLen < needed:
return newSeq[uint8](max(0, dstW * dstH * 3))
if srcW == dstW and srcH == dstH:
return rgb
result = newSeq[uint8](dstW * dstH * 3)
let
xRatio = float(srcW - 1) / float(max(dstW, 2) - 1)
yRatio = float(srcH - 1) / float(max(dstH, 2) - 1)
for y in 0 ..< dstH:
let
sy = float(y) * yRatio
sy0 = clamp(int(sy), 0, srcH - 1)
sy1 = clamp(sy0 + 1, 0, srcH - 1)
fy = sy - float(sy0)
for x in 0 ..< dstW:
let
sx = float(x) * xRatio
sx0 = clamp(int(sx), 0, srcW - 1)
sx1 = clamp(sx0 + 1, 0, srcW - 1)
fx = sx - float(sx0)
let
off00 = (sy0 * srcW + sx0) * 3
off01 = (sy0 * srcW + sx1) * 3
off10 = (sy1 * srcW + sx0) * 3
off11 = (sy1 * srcW + sx1) * 3
let dstOff = (y * dstW + x) * 3
for c in 0 ..< 3:
let
v00 = float(rgb[off00 + c])
v01 = float(rgb[off01 + c])
v10 = float(rgb[off10 + c])
v11 = float(rgb[off11 + c])
v0 = v00 + (v01 - v00) * fx
v1 = v10 + (v11 - v10) * fx
v = v0 + (v1 - v0) * fy
result[dstOff + c] = uint8(clamp(int(round(v)), 0, 255))
type
RgbBuf* = object
data*: seq[uint8]
w*, h*: int
proc floydSteinberg*(buf: var RgbBuf) =
## Floyd-Steinberg error diffusion dithering.
## Modifies buf in place. Use before quantizing to a reduced palette.
## Distributes quantization error to neighboring pixels.
let stride = buf.w
for y in 0 ..< buf.h:
for x in 0 ..< buf.w:
let off = (y * stride + x) * 3
# Quantize to nearest valid value; for truecolor this is a no-op,
# but callers can override `quantize` per channel.
for c in 0 ..< 3:
var old = float(buf.data[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.data[off + c] = uint8(newVal)
# Distribute error
template d(xx, yy, wgt: untyped) =
if xx >= 0 and xx < buf.w and yy >= 0 and yy < buf.h:
let toff = (yy * stride + xx) * 3
buf.data[toff + c] = uint8(clamp(
int(round(float(buf.data[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)
+185
View File
@@ -0,0 +1,185 @@
## Sixel graphics protocol renderer.
##
## Encodes images as sixel bands (6 pixels vertical per band)
## with a 256-color palette and RLE compression.
## Supports bilinear scaling and optional Floyd-Steinberg dithering.
import std/[math, tables, algorithm, strutils]
import types, scaling
const
Dcs = "\eP"
St = "\e\\"
SixelBandHeight = 6
SixelMaxColors = 256
SixelColorScale = 100
SixelPrintableOffset = 63
RleIntro = '!'
RepeatThreshold = 3
RgbColorSpace = 2
type
Rgb = tuple[r, g, b: int]
proc quantizeColor(r, g, b: uint8): Rgb =
let scale = float(SixelColorScale) / 255.0
(
int(round(float(r) * scale)),
int(round(float(g) * scale)),
int(round(float(b) * scale)),
)
proc encodeRun(code: int, count: int): string =
let glyph = char(SixelPrintableOffset + code)
if count > RepeatThreshold:
result = RleIntro & $count & glyph
else:
result = repeat(glyph, count)
proc encodeRow(codes: seq[int]): string =
var parts: seq[string]
if codes.len == 0:
return ""
var runCode = codes[0]
var runLen = 0
for c in codes:
if c == runCode:
inc runLen
else:
parts.add(encodeRun(runCode, runLen))
runCode = c
runLen = 1
parts.add(encodeRun(runCode, runLen))
result = join(parts)
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 floydSteinbergSixel(buf: var seq[uint8], w, h: int) =
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 quantize(rgb: seq[uint8], pixelCount: int): (seq[int], seq[Rgb]) =
var palette: seq[Rgb]
var indexMap = initTable[tuple[r, g, b: int], int]()
result[0] = newSeq[int](pixelCount)
for i in 0 ..< pixelCount:
let q = quantizeColor(rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2])
if q notin indexMap:
if indexMap.len >= SixelMaxColors:
var bestIdx = -1
var bestDist = high(int)
for j, p in palette:
let dr = q.r - p.r
let dg = q.g - p.g
let db = q.b - p.b
let dist = dr * dr + dg * dg + db * db
if dist < bestDist:
bestDist = dist
bestIdx = j
indexMap[q] = bestIdx
else:
let idx = palette.len
palette.add(q)
indexMap[q] = idx
result[0][i] = indexMap[q]
proc renderSixel*(
img: ImageData,
background: tuple[r, g, b: uint8],
targetW, targetH: int,
dither: bool = false,
): string =
## Produce sixel escape sequence for the given image.
## Returns empty string if source or target dimensions are invalid.
if img.width <= 0 or img.height <= 0 or targetW <= 0 or targetH <= 0:
return ""
if img.data.len < img.width * img.height * 4:
return ""
let rgb = compositeOver(img, background)
var rgbScaled = bilinearResizeRgb(rgb, img.width, img.height, targetW, targetH)
if dither:
floydSteinbergSixel(rgbScaled, targetW, targetH)
let pixelCount = targetW * targetH
let (indices, palette) = quantize(rgbScaled, pixelCount)
var parts: seq[string]
parts.add(Dcs)
parts.add("q")
parts.add("\"1;1;" & $targetW & ";" & $targetH)
var colorOrder: seq[int]
for c in indices:
if c notin colorOrder:
colorOrder.add(c)
var colorSeen: set[0 .. 255]
for c in colorOrder:
if c in colorSeen: continue
colorSeen.incl c
let p = palette[c]
parts.add("#" & $c & ";" & $RgbColorSpace & ";" & $p.r & ";" & $p.g & ";" & $p.b)
var bandLines: seq[string]
for top in countup(0, targetH - 1, SixelBandHeight):
let depth = min(SixelBandHeight, targetH - top)
var bandColors: seq[int]
var seen: set[0 .. 255]
for row in 0 ..< depth:
let base = (top + row) * targetW
for col in 0 ..< targetW:
let c = indices[base + col]
if c notin seen:
seen.incl c
bandColors.add(c)
sort(bandColors)
var passes: seq[string]
for color in bandColors:
var codes: seq[int]
for col in 0 ..< targetW:
var sixelVal = 0
for row in 0 ..< depth:
let idx = (top + row) * targetW + col
if indices[idx] == color:
sixelVal = sixelVal or (1 shl row)
codes.add(sixelVal)
passes.add("#" & $color & encodeRow(codes))
if passes.len > 0:
bandLines.add(join(passes, "$"))
else:
bandLines.add("")
parts.add(join(bandLines, "-"))
parts.add(St)
result = join(parts)
+161
View File
@@ -0,0 +1,161 @@
## Terminal image rendering — shared types and geometry calculations.
import std/math
type
Protocol* = enum
ptKitty = "kitty"
ptSixel = "sixel"
ptIterm2 = "iterm2"
ptHalfBlock = "halfblock"
ptQuarterBlock = "quarterblock"
ptAuto = "auto"
FitMode* = enum
fmContain = "contain"
fmStretch = "stretch"
fmWidth = "width"
fmOriginal = "original"
fmCellExact = "cellexact"
BlockDensity* = enum
bdFull = 1 ## 1 pixel sample per cell (█ solid block)
bdHalf = 2 ## 2 vertical samples per cell (▀ half block)
bdQuarter = 4 ## 4 samples per cell (2×2 quadrant blocks)
CellSize* = object
width*: int
height*: int
TerminalCapabilities* = object
columns*: int
rows*: int
cell*: CellSize
protocols*: set[Protocol]
Geometry* = object
pixelWidth*: int
pixelHeight*: int
columns*: int
sampleHeight*: int
ImageData* = object
width*: int
height*: int
data*: seq[uint8] ## RGBA packed, row-major (width*height*4 bytes)
RenderOptions* = object
protocol*: Protocol
fit*: FitMode
maxWidth*: int
maxHeight*: int
maxWidthRatio*: float
maxHeightRatio*: float
backgroundRgb*: tuple[r, g, b: uint8]
dither*: bool
density*: BlockDensity
Frame* = object
image*: ImageData
duration*: float
ImageSource* = object
frames*: seq[Frame]
loop*: int
const
ReservedRows* = 1
DefaultCellWidth* = 10
DefaultCellHeight* = 20
proc pixelWidth*(caps: TerminalCapabilities): int =
caps.columns * caps.cell.width
proc pixelHeight*(caps: TerminalCapabilities): int =
caps.rows * caps.cell.height
proc supports*(caps: TerminalCapabilities, proto: Protocol): bool =
proto in caps.protocols
proc animated*(src: ImageSource): bool =
src.frames.len > 1
func scaleToFit(w, h, maxW, maxH: int): (int, int) =
if w <= 0 or h <= 0:
return (1, 1)
let ratio = min(float(maxW) / float(w), float(maxH) / float(h))
(max(1, int(float(w) * ratio)), max(1, int(float(h) * ratio)))
func scaleToWidth(w, h, maxW: int): (int, int) =
if w <= 0: return (1, 1)
let ratio = float(maxW) / float(w)
(maxW, max(1, int(float(h) * ratio)))
proc computeGeometry*(
imageWidth, imageHeight: int,
caps: TerminalCapabilities,
opts: RenderOptions,
): Geometry =
## Compute render dimensions preserving aspect ratio.
## Uses maxWidthRatio and maxHeightRatio to bound the image within
## the terminal's pixel area. The density setting determines
## how many pixel samples map to each terminal cell.
let cell = caps.cell
# Effective terminal pixel budget
let termPxW = int(float(caps.columns * cell.width) * opts.maxWidthRatio)
let termPxH = int(float(caps.rows * cell.height) * opts.maxHeightRatio)
# User overrides take precedence
let userCols = if opts.maxWidth > 0: opts.maxWidth else: caps.columns
let userRows = if opts.maxHeight > 0: opts.maxHeight else: caps.rows
var maxPxW = min(userCols * cell.width, termPxW)
var maxPxH = min(userRows * cell.height, termPxH)
maxPxW = max(maxPxW, cell.width) # at least one cell
maxPxH = max(maxPxH, cell.height)
if maxPxH > (caps.rows - ReservedRows) * cell.height:
maxPxH = max(1, (caps.rows - ReservedRows) * cell.height)
var (pxW, pxH) =
case opts.fit
of fmStretch:
(maxPxW, maxPxH)
of fmWidth:
if imageWidth <= 0 or imageHeight <= 0:
(cell.width, cell.height)
else:
scaleToWidth(imageWidth, imageHeight, maxPxW)
of fmOriginal:
if imageWidth <= 0 or imageHeight <= 0:
(cell.width, cell.height)
elif imageWidth <= maxPxW and imageHeight <= maxPxH:
(imageWidth, imageHeight)
else:
scaleToFit(imageWidth, imageHeight, maxPxW, maxPxH)
of fmContain:
if imageWidth <= 0 or imageHeight <= 0:
(cell.width, cell.height)
else:
scaleToFit(imageWidth, imageHeight, maxPxW, maxPxH)
of fmCellExact:
if imageWidth <= 0 or imageHeight <= 0:
(cell.width, cell.height)
else:
# User specifies exact cell count; derive pixels from cell size.
let cols = if opts.maxWidth > 0: opts.maxWidth else: caps.columns
let scaleRatio = float(cols * cell.width) / float(imageWidth)
(cols * cell.width, max(1, int(float(imageHeight) * scaleRatio)))
let densityPx = int(opts.density)
let columns = max(1, int(round(float(pxW) / float(cell.width))))
let sampleRows = max(densityPx, int(round(float(pxH) / float(cell.height) * float(densityPx))))
Geometry(
pixelWidth: pxW,
pixelHeight: pxH,
columns: columns,
sampleHeight: sampleRows,
)