From 87ae53d2f5c392ecf55335d7bbdef2a9be166340 Mon Sep 17 00:00:00 2001 From: retoor Date: Wed, 8 Jul 2026 09:02:47 +0200 Subject: [PATCH] Update --- Makefile | 26 ++ README.md | 337 +++++++++++++++++++ examples/demo.nim | 232 +++++++++++++ nimimg.nimble | 24 ++ src/termimg.nim | 31 ++ src/termimg/capabilities.nim | 62 ++++ src/termimg/halfblock.nim | 116 +++++++ src/termimg/iterm2.nim | 18 + src/termimg/kitty.nim | 52 +++ src/termimg/quarterblock.nim | 197 +++++++++++ src/termimg/renderer.nim | 109 ++++++ src/termimg/scaling.nim | 137 ++++++++ src/termimg/sixel.nim | 185 +++++++++++ src/termimg/types.nim | 161 +++++++++ tests/test_termimg.nim | 629 +++++++++++++++++++++++++++++++++++ 15 files changed, 2316 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 examples/demo.nim create mode 100644 nimimg.nimble create mode 100644 src/termimg.nim create mode 100644 src/termimg/capabilities.nim create mode 100644 src/termimg/halfblock.nim create mode 100644 src/termimg/iterm2.nim create mode 100644 src/termimg/kitty.nim create mode 100644 src/termimg/quarterblock.nim create mode 100644 src/termimg/renderer.nim create mode 100644 src/termimg/scaling.nim create mode 100644 src/termimg/sixel.nim create mode 100644 src/termimg/types.nim create mode 100644 tests/test_termimg.nim diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b919244 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +NIM ?= nim +SRC := src/termimg +TEST := tests/test_termimg +EXAMPLE := examples/demo + +.PHONY: check test example clean all + +all: check test example + +check: + $(NIM) check --path:src src/termimg.nim + $(NIM) check --path:src $(TEST).nim + $(NIM) check --path:src $(EXAMPLE).nim + +test: check + $(NIM) c --path:src -r $(TEST).nim 2>&1 | tail -1 + +example: check + $(NIM) c --path:src -r $(EXAMPLE).nim + +clean: + rm -rf nimcache + rm -f $(TEST) + rm -f $(EXAMPLE) + find . -type f -name '*.nim.bak.*' -delete + find . -type f -name '*.o' -delete diff --git a/README.md b/README.md new file mode 100644 index 0000000..edbb05d --- /dev/null +++ b/README.md @@ -0,0 +1,337 @@ +# termimg + +Picture-perfect image rendering in the native terminal. The library takes raw +RGBA pixel data and reproduces it using the highest-fidelity protocol the +terminal supports, falling back through progressively coarser Unicode block +characters when graphics protocols are unavailable. + +## Rendering protocols + +The terminal is inspected at runtime and the best available protocol is +selected automatically. + +| Protocol | Fidelity | Mechanism | +| ------------- | ------------------------------ | ------------------------------------------------------------ | +| Kitty | Exact pixels | RGBA transmitted in base64 chunks via the kitty graphics protocol | +| iTerm2 | Exact | Raw file bytes via OSC 1337 inline image | +| Sixel | 256-color palette | DCS sixel bands with RLE compression and Floyd-Steinberg dithering | +| Quarter-block | Four sub-cells per character | Unicode quadrant blocks (16 characters) with 24-bit fg/bg | +| Half-block | Two vertical samples per cell | Unicode U+2580 with 24-bit foreground/background | + +### Protocol detection + +Priority order: **kitty > iterm2 > sixel > quarterblock > halfblock**. + +Detection uses environment variables as heuristics (live escape-sequence +queries are not used to avoid terminal lock-up edge cases): + +| Variable / Heuristic | Detected protocol | +| ------------------------------------- | --------------------------- | +| `KITTY_WINDOW_ID` is set | kitty | +| `TERM_PROGRAM` is `kitty`/`ghostty`/`WezTerm` | kitty | +| `WEZTERM_EXECUTABLE` is set | kitty + sixel | +| `TERM` contains `kitty` | kitty | +| `TERM_PROGRAM` is `iTerm.app`/`vscode` | iterm2 | +| `COLORTERM` contains `sixel` | sixel | +| `TERM` contains `sixel` | sixel | +| `TERM` is `foot`/`xterm-foot`/`mlterm` | sixel | +| `TERM_PROGRAM` is `tmux` | none (passthrough required) | + +Half-block is always available and serves as the universal fallback. + +## Terminal compatibility + +| Terminal | kitty | iterm2 | sixel | quarterblock | halfblock | +| ----------------- | :---: | :----: | :---: | :----------: | :-------: | +| kitty | Y | - | - | Y | Y | +| iTerm2 | - | Y | - | Y | Y | +| WezTerm | Y | - | Y | Y | Y | +| Ghostty | Y | - | - | Y | Y | +| Foot | - | - | Y | Y | Y | +| xterm (compiled) | - | - | Y | Y | Y | +| mlterm | - | - | Y | Y | Y | +| VS Code terminal | - | Y | - | Y | Y | +| GNOME Terminal | - | - | - | Y | Y | +| Konsole | - | - | - | Y | Y | +| tmux | - | - | - | Y | Y | +| Windows Terminal | - | - | - | Y | Y | + +Any truecolor-capable terminal supports the block renderers. + +## Block densities + +The `density` field in `RenderOptions` controls how many pixel samples map to +each terminal cell. Higher densities produce finer detail at the cost of larger +output. + +| Density | Enum | Sub-cells per cell | Block characters used | Best for | +| ------- | ----------- | ------------------ | ------------------------ | ----------------------- | +| Full | `bdFull` | 1 | One color per cell | Minimal output | +| Half | `bdHalf` | 2 vertical | `U+2580` half block | General use (default) | +| Quarter | `bdQuarter` | 2x2 quadrant | All 16 Unicode quadrants | Thumbnails, fine detail | + +## Presets + +| Preset | Protocol | Fit | Width | Density | Dither | Best use | +| ------------------- | -------- | ------ | ------ | -------- | ------ | ------------------------- | +| `defaultOptions()` | Auto | Contain| 90% | Half | No | Full-size display | +| `thumbnailOptions()`| Auto | Contain| 40 cols| Quarter | Yes | Thumbnails, previews | + +## Scaling + +All renderers use **bilinear interpolation** for high-quality upscaling and +downscaling. Edge-clamped sampling ensures no black borders appear at edges. +**Floyd-Steinberg error diffusion dithering** is available for the block-character +and sixel renderers (enable with `opts.dither = true`), reducing banding in +gradients when downscaling. + +## Requirements + +Nim 2.0 or newer. A truecolor-capable terminal is required for block-character +output; kitty, iTerm2, or sixel support enables exact pixel rendering. + +## Installation + +Add to your project's `.nimble` file: + +```nim +requires "termimg" +``` + +Then install locally or from a path: + +``` +nimble install # from the project root +nimble install /path/to/termimg +``` + +Or use directly without nimble: + +``` +nim c --path:src your_app.nim +``` + +## Quick start + +```nim +import termimg + +# Detect terminal capabilities once at startup +let caps = detectCapabilities() + +# Default rendering — half-block, contain fit, 90% width +let output = renderImageRgba(pixelBytes, 640, 480, caps, defaultOptions()) +stdout.write(output) + +# Thumbnail — quarter-block, 40 cols max, dithered +let thumb = renderImageRgba(pixelBytes, 640, 480, caps, thumbnailOptions()) +stdout.write(thumb) + +# Raw file bytes (iTerm2 protocol requires raw image bytes) +let raw = readFile("image.png") +let output2 = renderImageRaw(raw, 800, 600, caps, defaultOptions()) +stdout.write(output2) +``` + +## Demo program + +A complete demo is at `examples/demo.nim`: + +``` +make example +``` + +It renders four synthetic patterns (checkerboard, rainbow bars, target circles, +warm glow) using every available protocol and prints terminal capability +information. Includes: + +- **Gallery**: 3 patterns (checkerboard, rainbow, target) side-by-side +- **All four patterns** in a column via `thumbnailOptions()` +- **Column layout**: Same rainbow pattern at all 3 block densities stacked +- **Fit-mode grid**: Target circles rendered under each of 4 fit modes +- **Extreme aspect ratios**: Wide and tall checkerboard renders + +``` +make example +``` + +## API reference + +### Types + +| Type | Fields | Description | +| ---- | ------ | ----------- | +| `Protocol` | enum: `ptKitty`, `ptSixel`, `ptIterm2`, `ptQuarterBlock`, `ptHalfBlock`, `ptAuto` | Image protocol | +| `FitMode` | enum: `fmContain`, `fmStretch`, `fmWidth`, `fmOriginal`, `fmCellExact` | Scaling strategy | +| `BlockDensity` | enum: `bdFull`, `bdHalf`, `bdQuarter` | Pixel samples per cell | +| `ImageData` | `width`, `height`: int; `data`: seq[uint8] | RGBA pixel buffer | +| `RenderOptions` | `protocol`, `fit`, `maxWidth`, `maxHeight`, `maxWidthRatio`, `maxHeightRatio`, `backgroundRgb`, `dither`, `density` | Rendering parameters | +| `TerminalCapabilities` | `columns`, `rows`, `cell`, `protocols` | Detected terminal state | +| `Geometry` | `pixelWidth`, `pixelHeight`, `columns`, `sampleHeight` | Computed render dimensions | +| `CellSize` | `width`, `height`: int | Terminal cell dimensions in pixels | + +### Procs + +| Proc | Signature | Description | +| ---- | --------- | ----------- | +| `detectCapabilities` | `(): TerminalCapabilities` | Resolve terminal size and protocol support | +| `selectProtocol` | `(caps, preferred): Protocol` | Pick the best available protocol | +| `computeGeometry` | `(imgW, imgH, caps, opts): Geometry` | Compute aspect-preserving render dimensions | +| `renderImageRgba` | `(data, w, h, caps, opts): string` | Render RGBA pixels to escape sequence string | +| `renderImageRaw` | `(rawBytes, w, h, caps, opts): string` | Render raw file bytes (iTerm2 path) | +| `defaultOptions` | `(): RenderOptions` | Half-block, contain fit, 90% width, no dither | +| `thumbnailOptions` | `(): RenderOptions` | Quarter-block, 40 cols, 50% area, dithered | +| `bilinearResizeRgba` | `(img, dstW, dstH): seq[uint8]` | Bilinear RGBA scaling | +| `bilinearResizeRgb` | `(rgb, srcW, srcH, dstW, dstH): seq[uint8]` | Bilinear RGB scaling | +| `terminalIsInteractive` | `(): bool` | Check if both stdout and stdin are TTYs | +| `animated` | `(src: ImageSource): bool` | Check if an image source has multiple frames | + +### Protocol-specific renderers + +| Proc | Description | +| ---- | ----------- | +| `renderKitty(img, targetW, targetH): string` | Kitty graphics protocol | +| `renderKittyFile(filePath): string` | Kitty file-based PNG render | +| `renderSixel(img, bg, targetW, targetH, dither): string` | Sixel with 256-color palette | +| `renderIterm2(rawBytes, w, h): string` | iTerm2 OSC 1337 inline image | +| `renderHalfBlock(img, bg, columns, sampleHeight, dither): string` | Unicode half-block | +| `renderQuarterBlock(img, bg, columns, sampleHeight, dither): string` | Unicode quarter-block | + +### ImageSource (multi-frame / animated images) + +| Type | Description | +| ---- | ----------- | +| `Frame` | `image: ImageData`, `duration: float` (seconds) | +| `ImageSource` | `frames: seq[Frame]`, `loop: int` (0 = infinite) | + +The library imports the data types but does not decode animated formats. +Use a separate library (e.g. `stb_image` bindings) to produce `ImageSource`. + +### TerminalCapabilities helpers + +| Proc | Description | +| ---- | ----------- | +| `caps.pixelWidth` | Total pixel width: `columns * cell.width` | +| `caps.pixelHeight` | Total pixel height: `rows * cell.height` | +| `caps.supports(proto)` | Check if a protocol is in the detected set | + +## Fit modes + +| Mode | Behavior | +| ---- | -------- | +| `fmContain` | Scale to fit within terminal bounds, preserving aspect ratio (default) | +| `fmStretch` | Fill the terminal bounds, ignoring aspect ratio | +| `fmWidth` | Scale to fit terminal width, height follows aspect ratio | +| `fmOriginal` | Use native size if it fits, otherwise scale to contain | +| `fmCellExact` | User specifies exact column count, height derives from aspect ratio | + +## Makefile targets + +| Target | Description | +| --------- | -------------------------------------------- | +| `all` | Run check, test, and example | +| `check` | Type-check library, test suite, and examples | +| `test` | Compile and run the test program | +| `example` | Compile and run the demo program | +| `clean` | Remove build artifacts | + +``` +make all # full pipeline +make test # compile and run tests +make example # run the demo +make clean # remove nimcache and binaries +``` + +## Architecture + +``` +.gitignore +Makefile +README.md +termimg.nimble + +src/termimg.nim top-level module, re-exports public API (`import termimg`) +src/termimg/ + types.nim shared types, Geometry, RenderOptions, computeGeometry + capabilities.nim terminal size, cell dimensions, protocol detection + renderer.nim protocol selector, renderImageRgba, renderImageRaw + scaling.nim bilinear RGBA/RGB interpolation, Floyd-Steinberg dithering + kitty.nim kitty graphics protocol encoder + sixel.nim sixel protocol encoder with palette quantization + iterm2.nim iTerm2 inline image protocol encoder + halfblock.nim Unicode half-block renderer (universal fallback) + quarterblock.nim Unicode quadrant-block renderer (2x density) + +tests/ + test_termimg.nim integration test: 31 test cases across all features (gallery, columns, comparisons, corruption) + +examples/ + demo.nim standalone demo program with gallery, column layout, fit-grid comparisons +``` + +## How the block renderers work + +### Half-block (U+2580) + +Each terminal cell displays two pixels vertically. Upper pixel = foreground +colour (CSI 38;2;R;G;Bm), lower pixel = background colour (CSI 48;2;R;G;Bm). +Colour sequences are emitted only when the colour changes between adjacent +columns, minimizing output size. + +``` +┌────────────────────┐ +│ fg: upper pixel │ ← CSI 38;2;R;G;Bm +│ bg: lower pixel │ ← CSI 48;2;R;G;Bm +│ ▀ │ ← U+2580 UPPER HALF BLOCK +└────────────────────┘ +``` + +### Quarter-block (16 quadrant characters) + +Each terminal cell covers a 2x2 pixel region. The four sub-pixels are clustered +into two colour groups (foreground and background) by luminance thresholding, +then mapped to one of 16 Unicode block-drawing characters: + +``` +TL TR mask bits: ▖▗▘▙▚▛▜▝▞▟▀▄▌▐█ +BL BR 3:TL 2:TR 1:BL 0:BR spaces +``` + +## Dithering + +Floyd-Steinberg error diffusion is available for halfblock, quarterblock, and +sixel renderers. Enable via `opts.dither = true`. Error is distributed to four +neighboring pixels (7/16 right, 3/16 bottom-left, 5/16 bottom, 1/16 +bottom-right), smoothing gradient banding at the cost of slight grain. + +## Verification + +The library compiles without errors or warnings under Nim 2+. The test suite +runs 31 test cases covering: + +- Protocol detection and capability reporting +- Geometry computation at multiple aspect ratios +- All five fit modes (contain, stretch, width, original, cellexact) +- All three block densities (full, half, quarter) +- Bilinear scaling (up and down) +- Floyd-Steinberg dithering +- Full `renderImageRgba` pipeline +- `thumbnailOptions` preset pipeline +- Zero/negative/invalid dimensions (guard rails) +- Short RGBA buffer (guard against underflow) +- Empty raw bytes (guard against underflow) +- Non-terminal / fake environment (no crash) +- Gallery layout (3 patterns side-by-side) +- Column layout (3 densities stacked for comparison) +- Fit-mode comparison grid (4 fit modes stacked) +- Extreme aspect ratios (wide vs tall) +- Off-by-one buffer sizes (1 byte short, 1 byte extra) +- Tiny images (1x1, 1x10, 10x1) +- Fully transparent image (alpha=0 everywhere) +- Monochrome image (all same colour) +- Extreme opts (maxWidth=0, maxWidth=-1, maxHeight=0) +- Empty protocol set (fallback to half-block) +- Extreme pixel aspect ratios (200x1, 1x200) +- Zero background + dithering on black +- Random-stress (20 renders at random sizes/fit modes/densities) +- Tiny buffer claimed as huge image (guard rail) diff --git a/examples/demo.nim b/examples/demo.nim new file mode 100644 index 0000000..e548c96 --- /dev/null +++ b/examples/demo.nim @@ -0,0 +1,232 @@ +## termimg demo — full visual showcase of every rendering path. +## +## Usage: +## nim c -r --path:src examples/demo.nim +## +## Shows: scanner pattern, rainbow gradient, target circles, +## gallery layout (3 across), column layout (3 densities stacked), +## fit-mode comparison grid, and individual renders for each protocol. + +import std/[strutils, math] +import termimg + +# ── Pattern generators ────────────────────────────────────────── +proc checkerboard(w, h, csize: int): seq[uint8] = + result = newSeq[uint8](w * h * 4) + for y in 0 ..< h: + for x in 0 ..< w: + let off = (y * w + x) * 4 + let bright = if ((x div csize) + (y div csize)) mod 2 == 0: 220 else: 40 + result[off + 0] = uint8(bright) + result[off + 1] = uint8(bright) + result[off + 2] = uint8(bright) + result[off + 3] = 255 + +proc rainbowBars(w, h: int): seq[uint8] = + result = newSeq[uint8](w * h * 4) + for y in 0 ..< h: + for x in 0 ..< w: + let off = (y * w + x) * 4 + let band = float(x) / float(w) * 6.0 + let iband = int(band) + let frac = band - float(iband) + var r, g, b: float + case iband + of 0: (r, g, b) = (1.0, frac, 0.0) + of 1: (r, g, b) = (1.0 - frac, 1.0, 0.0) + of 2: (r, g, b) = (0.0, 1.0, frac) + of 3: (r, g, b) = (0.0, 1.0 - frac, 1.0) + of 4: (r, g, b) = (frac, 0.0, 1.0) + else: (r, g, b) = (1.0, 0.0, 1.0 - frac) + let ymul = 0.6 + 0.4 * float(y) / float(h) + result[off + 0] = uint8(clamp(int(r * 255.0 * ymul), 0, 255)) + result[off + 1] = uint8(clamp(int(g * 255.0 * ymul), 0, 255)) + result[off + 2] = uint8(clamp(int(b * 255.0 * ymul), 0, 255)) + result[off + 3] = 255 + +proc targetCircle(w, h: int): seq[uint8] = + result = newSeq[uint8](w * h * 4) + let cx = float(w) / 2.0 + let cy = float(h) / 2.0 + let maxR = min(cx, cy) + for y in 0 ..< h: + for x in 0 ..< w: + let off = (y * w + x) * 4 + let dx = float(x) - cx + let dy = float(y) - cy + let r = sqrt(dx * dx + dy * dy) + let ring = int(r / (maxR / 5.0)) mod 2 + if ring == 0: + result[off + 0] = 200; result[off + 1] = 50; result[off + 2] = 50 + else: + result[off + 0] = 255; result[off + 1] = 255; result[off + 2] = 255 + result[off + 3] = 255 + +proc warmGlow(w, h: int): seq[uint8] = + result = newSeq[uint8](w * h * 4) + let cx = float(w) / 2.0 + let cy = float(h) / 2.0 + # maxR intentionally omitted: warmGlow normalizes by cx,cy directly + for y in 0 ..< h: + for x in 0 ..< w: + let off = (y * w + x) * 4 + let dx = (float(x) - cx) / cx + let dy = (float(y) - cy) / cy + let dist = sqrt(dx * dx + dy * dy) + let glow = clamp(int(255.0 * exp(-dist * 2.5)), 0, 255) + result[off + 0] = uint8(glow) + result[off + 1] = uint8(glow * 3 div 5) + result[off + 2] = uint8(glow div 3) + result[off + 3] = 255 + +# ── Side-by-side render helper ───────────────────────────────── +proc renderSideBySide(images: openArray[seq[uint8]], + widths: openArray[int], + heights: openArray[int], + caps: TerminalCapabilities, + opts: RenderOptions, + gap: int = 2): string = + ## Renders N images at the same geometry and stitches them row-by-row. + let geo = computeGeometry(widths[0], heights[0], caps, opts) + var parts = newSeq[(seq[string], int)]() # (lines, frames_consumed) + for i in 0 ..< images.len: + let outStr = renderHalfBlock( + ImageData(width: widths[i], height: heights[i], data: images[i]), + opts.backgroundRgb, geo.columns, geo.sampleHeight) + parts.add (outStr.split('\n'), 0) + let maxRows = min(min(parts[0][0].len, parts[1][0].len), + (if parts.len > 2: parts[2][0].len else: parts[1][0].len)) + for row in 0 ..< maxRows: + for i in 0 ..< parts.len: + if row < parts[i][0].len: + result.add parts[i][0][row] + if i < parts.len - 1: + result.add spaces(gap) + result.add "\n" + +when isMainModule: + echo "" + echo "╭──────────────────────────────────────────────╮" + echo "│ termimg — Full Demo │" + echo "╰──────────────────────────────────────────────╯" + echo "" + + let caps = detectCapabilities() + + echo "Terminal: ", caps.columns, "x", caps.rows, " cells" + echo "Cell: ", caps.cell.width, "x", caps.cell.height, " px" + echo "" + + echo "Detected protocols:" + for proto in [ptKitty, ptIterm2, ptSixel, ptQuarterBlock, ptHalfBlock]: + let mark = if caps.supports(proto): " ✓" else: " ✗" + echo " ", ($proto).align(14), mark + echo "" + + # ── Pattern generation ────────────────────────────────────────── + const patternW = 64 + const patternH = 48 + let chk = checkerboard(patternW, patternH, 8) + let rnb = rainbowBars(patternW, patternH) + let tgt = targetCircle(patternW, patternH) + let glow = warmGlow(patternW, patternH) + + # ── 1. Gallery: 3 patterns side-by-side ───────────────────────── + echo "── Gallery: 3 patterns side-by-side ──" + var optsGal = defaultOptions() + optsGal.maxWidth = (caps.columns - 4) div 3 + optsGal.fit = fmWidth + stdout.write(renderSideBySide([chk, rnb, tgt], [patternW, patternW, patternW], + [patternH, patternH, patternH], caps, optsGal)) + echo "" + + # ── 2. All four patterns in a column (thumbnail pipeline) ────── + echo "── All four patterns (thumbnail pipeline) ──" + let thumbOpts = thumbnailOptions() + for (name, pix) in [("Checkerboard", chk), ("Rainbow bars", rnb), + ("Target circles", tgt), ("Warm glow", glow)]: + echo " ", name, ":" + let outStr = renderImageRgba(pix, patternW, patternH, caps, thumbOpts) + stdout.write(outStr) + stdout.write("\n") + echo "" + + # ── 3. Column layout: rainbow at 3 densities for comparison ──── + echo "── Column layout: rainbow at 3 block densities ──" + let colW = caps.columns - 2 + for density in [bdFull, bdHalf, bdQuarter]: + var opts = defaultOptions() + opts.density = density + opts.maxWidth = colW + opts.fit = fmWidth + let geo = computeGeometry(patternW, patternH, caps, opts) + let outStr = renderHalfBlock( + ImageData(width: patternW, height: patternH, data: rnb), + opts.backgroundRgb, geo.columns, geo.sampleHeight) + stdout.write(outStr) + stdout.write("\n") + echo " ($1 — $2 cols x $3 samples)" % [$density, $geo.columns, $geo.sampleHeight] + echo "" + echo "" + + # ── 4. Fit-mode comparison grid (quarter-block, dithered) ────── + echo "── Fit-mode grid: target circles at 4 fit modes ──" + let halfW = caps.columns div 2 - 1 + for fit in [fmContain, fmWidth, fmOriginal, fmStretch]: + var opts = defaultOptions() + opts.fit = fit + opts.maxWidth = halfW + opts.density = bdQuarter + let geo = computeGeometry(patternW, patternH, caps, opts) + let outStr = renderQuarterBlock( + ImageData(width: patternW, height: patternH, data: tgt), + opts.backgroundRgb, geo.columns, geo.sampleHeight, dither = true) + stdout.write(outStr) + stdout.write("\n") + echo " ($1 — $2 px, $3 cols x $4 samples)" % [ + $fit, $geo.pixelWidth & "x" & $geo.pixelHeight, + $geo.columns, $geo.sampleHeight] + echo "" + echo "" + + # ── 5. Individual renders per protocol ────────────────────────── + echo "── Default render (half-block, contain fit) ──" + let out1 = renderImageRgba(chk, patternW, patternH, caps, defaultOptions()) + stdout.write(out1) + stdout.write("\n") + echo "" + + echo "── Quarter-block thumbnail (40 cols, dithered) ──" + let out2 = renderImageRgba(rnb, patternW, patternH, caps, thumbnailOptions()) + stdout.write(out2) + stdout.write("\n") + echo "" + + # ── 6. Extreme aspect-ratio demo ──────────────────────────────── + echo "── Wide vs tall: checkerboard at extreme ratios ──" + var optsW = defaultOptions() + optsW.maxWidth = caps.columns - 2 + optsW.fit = fmWidth + let geoW = computeGeometry(patternW, patternH, caps, optsW) + let outW = renderHalfBlock( + ImageData(width: patternW, height: patternH, data: chk), + optsW.backgroundRgb, geoW.columns, geoW.sampleHeight) + stdout.write(outW) + echo " (wide: $1 cols x $2 samples)" % [$geoW.columns, $geoW.sampleHeight] + echo "" + + let tallPixels = checkerboard(patternH, patternW, 6) # 48x64 + var optsT = defaultOptions() + optsT.maxHeight = caps.rows - 4 + optsT.fit = fmContain + let geoT = computeGeometry(patternH, patternW, caps, optsT) + let outT = renderHalfBlock( + ImageData(width: patternH, height: patternW, data: tallPixels), + optsT.backgroundRgb, geoT.columns, geoT.sampleHeight) + stdout.write(outT) + echo " (tall: $1 cols x $2 samples)" % [$geoT.columns, $geoT.sampleHeight] + echo "" + + echo "╭──────────────────────────────────────────────╮" + echo "│ Demo complete. All renders done. │" + echo "╰──────────────────────────────────────────────╯" diff --git a/nimimg.nimble b/nimimg.nimble new file mode 100644 index 0000000..dc0c3ff --- /dev/null +++ b/nimimg.nimble @@ -0,0 +1,24 @@ +# Package + +version = "1.0.0" +author = "molodetz" +description = "Render images in the terminal — kitty, sixel, iTerm2, halfblock, and quarterblock protocols" +license = "MIT" +srcDir = "src" + +# Dependencies + +requires "nim >= 2.0.0" + +# Tasks + +task test, "Run the test suite": + exec "nim c --path:src -r tests/test_termimg.nim" + +task example, "Run the demo program": + exec "nim c --path:src -r examples/demo.nim" + +task check, "Type-check all source files": + exec "nim check --path:src src/termimg.nim" + exec "nim check --path:src tests/test_termimg.nim" + exec "nim check --path:src examples/demo.nim" diff --git a/src/termimg.nim b/src/termimg.nim new file mode 100644 index 0000000..4dc61c3 --- /dev/null +++ b/src/termimg.nim @@ -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 diff --git a/src/termimg/capabilities.nim b/src/termimg/capabilities.nim new file mode 100644 index 0000000..db30126 --- /dev/null +++ b/src/termimg/capabilities.nim @@ -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, + ) diff --git a/src/termimg/halfblock.nim b/src/termimg/halfblock.nim new file mode 100644 index 0000000..ea4f550 --- /dev/null +++ b/src/termimg/halfblock.nim @@ -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") diff --git a/src/termimg/iterm2.nim b/src/termimg/iterm2.nim new file mode 100644 index 0000000..8bcd9fd --- /dev/null +++ b/src/termimg/iterm2.nim @@ -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" diff --git a/src/termimg/kitty.nim b/src/termimg/kitty.nim new file mode 100644 index 0000000..ce283f2 --- /dev/null +++ b/src/termimg/kitty.nim @@ -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 diff --git a/src/termimg/quarterblock.nim b/src/termimg/quarterblock.nim new file mode 100644 index 0000000..1529a27 --- /dev/null +++ b/src/termimg/quarterblock.nim @@ -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") diff --git a/src/termimg/renderer.nim b/src/termimg/renderer.nim new file mode 100644 index 0000000..9a8d195 --- /dev/null +++ b/src/termimg/renderer.nim @@ -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, + ) diff --git a/src/termimg/scaling.nim b/src/termimg/scaling.nim new file mode 100644 index 0000000..1def026 --- /dev/null +++ b/src/termimg/scaling.nim @@ -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) diff --git a/src/termimg/sixel.nim b/src/termimg/sixel.nim new file mode 100644 index 0000000..319bb84 --- /dev/null +++ b/src/termimg/sixel.nim @@ -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) diff --git a/src/termimg/types.nim b/src/termimg/types.nim new file mode 100644 index 0000000..985054b --- /dev/null +++ b/src/termimg/types.nim @@ -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, + ) diff --git a/tests/test_termimg.nim b/tests/test_termimg.nim new file mode 100644 index 0000000..52cd063 --- /dev/null +++ b/tests/test_termimg.nim @@ -0,0 +1,629 @@ +## termimg test — verifies protocol detection, geometry, scaling, +## halfblock, quarterblock, dithering, and aspect-ratio preservation. +## +## Run: nim c -r --path:src tests/test_termimg.nim + +import std/[strutils, math] +import termimg + +when isMainModule: + echo "=== termimg Advanced Test Suite ===" + + # ── Capabilities ────────────────────────────────────────────── + let caps = detectCapabilities() + echo "Terminal: ", caps.columns, "x", caps.rows, " cells" + echo "Cell: ", caps.cell.width, "x", caps.cell.height, " px" + echo "Protocols:" + for proto in [ptKitty, ptIterm2, ptSixel, ptQuarterBlock, ptHalfBlock]: + echo " ", proto, if caps.supports(proto): " ✓" else: " ✗" + + # ── Build a test pattern ───────────────────────────────────── + # 32x16 RGBA: gradient bands + sharp edges to test scaling quality + const + pw = 32 + ph = 16 + var testPixels = newSeq[uint8](pw * ph * 4) + for y in 0 ..< ph: + for x in 0 ..< pw: + let off = (y * pw + x) * 4 + let r = uint8(255 * x div (pw - 1)) + let g = uint8(0) + let b = uint8(255 * (pw - 1 - x) div (pw - 1)) + let brightness = uint8(128 + 127 * y div (ph - 1)) + testPixels[off + 0] = uint8((int(r) * int(brightness)) div 255) + testPixels[off + 1] = uint8((int(g) * int(brightness)) div 255) + testPixels[off + 2] = uint8((int(b) * int(brightness)) div 255) + testPixels[off + 3] = 255'u8 + + # White cross for edge-testing + let cx = pw div 2 + let cy = ph div 2 + for i in 0 ..< pw: + let offH = (cy * pw + i) * 4 + testPixels[offH + 0] = 255; testPixels[offH + 1] = 255 + testPixels[offH + 2] = 255; testPixels[offH + 3] = 255 + for i in 0 ..< ph: + let offV = (i * pw + cx) * 4 + testPixels[offV + 0] = 255; testPixels[offV + 1] = 255 + testPixels[offV + 2] = 255; testPixels[offV + 3] = 255 + + # ── Test 1: Default halfblock ──────────────────────────────── + echo "\n── Test 1: Half-block (default, contain fit) ──" + block: + let opts = defaultOptions() + let geo = computeGeometry(pw, ph, caps, opts) + echo " geometry: ", geo.columns, " cols x ", geo.sampleHeight, + " samples (", geo.pixelWidth, "x", geo.pixelHeight, " px)" + let outStr = renderHalfBlock( + ImageData(width: pw, height: ph, data: testPixels), + opts.backgroundRgb, geo.columns, geo.sampleHeight) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + let lines = outStr.split("\n") + echo " output: ", lines.len, " lines, ", + (if lines.len > 0: lines[0].len else: 0), " chars first line" + + # ── Test 2: Quarter-block thumbnail ────────────────────────── + echo "\n── Test 2: Quarter-block thumbnail ──" + block: + var opts = thumbnailOptions() + let geo = computeGeometry(pw, ph, caps, opts) + echo " geometry: ", geo.columns, " cols x ", geo.sampleHeight, " samples" + let outStr = renderQuarterBlock( + ImageData(width: pw, height: ph, data: testPixels), + opts.backgroundRgb, geo.columns, geo.sampleHeight, dither = true) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + let lines = outStr.split("\n") + echo " output: ", lines.len, " lines, ", + (if lines.len > 0: lines[0].len else: 0), " chars first line" + + # ── Test 3: Full-density (bdFull) ──────────────────────────── + echo "\n── Test 3: Full-density (bdFull, 10 cols) ──" + block: + var opts = defaultOptions() + opts.density = bdFull + opts.maxWidth = 10 + let geo = computeGeometry(pw, ph, caps, opts) + echo " geometry: ", geo.columns, " cols x ", geo.sampleHeight, " samples" + let outStr = renderHalfBlock( + ImageData(width: pw, height: ph, data: testPixels), + opts.backgroundRgb, geo.columns, geo.sampleHeight) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + let lines = outStr.split("\n") + echo " output: ", lines.len, " lines" + + # ── Test 4: Half-block with dithering ──────────────────────── + echo "\n── Test 4: Half-block with dithering ──" + block: + var opts = defaultOptions() + opts.dither = true + opts.maxWidth = 20 + let geo = computeGeometry(pw, ph, caps, opts) + echo " geometry: ", geo.columns, " cols x ", geo.sampleHeight, " samples" + let outStr = renderHalfBlock( + ImageData(width: pw, height: ph, data: testPixels), + opts.backgroundRgb, geo.columns, geo.sampleHeight, dither = true) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + let lines = outStr.split("\n") + echo " output: ", lines.len, " lines" + + # ── Test 5: Aspect ratio preservation ──────────────────────── + echo "\n── Test 5: Aspect ratio ──" + block: + let geo = computeGeometry(640, 200, caps, defaultOptions()) + let ratio = float(geo.pixelWidth) / float(geo.pixelHeight) + echo " 640x200 → ", geo.pixelWidth, "x", geo.pixelHeight, + " px (ratio ", ratio.formatFloat(ffDecimal, 2), ", expected ~3.2)" + + let geo2 = computeGeometry(200, 640, caps, defaultOptions()) + let ratio2 = float(geo2.pixelWidth) / float(geo2.pixelHeight) + echo " 200x640 → ", geo2.pixelWidth, "x", geo2.pixelHeight, + " px (ratio ", ratio2.formatFloat(ffDecimal, 2), ", expected ~0.31)" + + let geo3 = computeGeometry(500, 500, caps, defaultOptions()) + let ratio3 = float(geo3.pixelWidth) / float(geo3.pixelHeight) + echo " 500x500 → ", geo3.pixelWidth, "x", geo3.pixelHeight, + " px (ratio ", ratio3.formatFloat(ffDecimal, 2), ", expected ~1.0)" + + # ── Test 6: Fit modes ──────────────────────────────────────── + echo "\n── Test 6: Fit modes ──" + block: + for fit in [fmContain, fmStretch, fmWidth, fmOriginal, fmCellExact]: + var opts = defaultOptions() + opts.fit = fit + opts.maxWidth = 30 + let geo = computeGeometry(pw, ph, caps, opts) + echo " ", fit, ": ", geo.pixelWidth, "x", geo.pixelHeight, + " px, ", geo.columns, " cols, ", geo.sampleHeight, " samples" + + # ── Test 7: Width-constrained render ───────────────────────── + echo "\n── Test 7: Width-constrained ──" + block: + var opts = defaultOptions() + opts.maxWidth = 8 + opts.fit = fmWidth + let geo = computeGeometry(pw, ph, caps, opts) + echo " 32x16 forced to 8 cols → ", geo.columns, " cols x ", + geo.sampleHeight, " samples" + let outStr = renderHalfBlock( + ImageData(width: pw, height: ph, data: testPixels), + opts.backgroundRgb, geo.columns, geo.sampleHeight) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + let lines = outStr.split("\n") + echo " output: ", lines.len, " lines" + + # ── Test 8: Bilinear scaling ──────────────────────────────── + echo "\n── Test 8: Bilinear scaling ──" + block: + let img = ImageData(width: pw, height: ph, data: testPixels) + let scaled = bilinearResizeRgba(img, 64, 32) + let expected = 64 * 32 * 4 + echo " ", pw, "x", ph, " → 64x32: ", scaled.len, " bytes (expected ", expected, ")" + doAssert scaled.len == expected + + # ── Test 9: Scaling down ───────────────────────────────────── + echo "\n── Test 9: Scaling down ──" + block: + let img = ImageData(width: pw, height: ph, data: testPixels) + let scaled = bilinearResizeRgba(img, 8, 4) + let expected = 8 * 4 * 4 + echo " ", pw, "x", ph, " → 8x4: ", scaled.len, " bytes (expected ", expected, ")" + doAssert scaled.len == expected + + # ── Test 10: Rgba renderer pipeline ────────────────────────── + echo "\n── Test 10: Full renderImageRgba pipeline ──" + block: + let opts = defaultOptions() + let outStr = renderImageRgba(testPixels, pw, ph, caps, opts) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + let lines = outStr.split("\n") + echo " output: ", lines.len, " lines" + + # ── Test 11: thumbnailOptions preset ────────────────────────── + echo "\n── Test 11: thumbnailOptions pipeline ──" + block: + let opts = thumbnailOptions() + let outStr = renderImageRgba(testPixels, pw, ph, caps, opts) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + let lines = outStr.split("\n") + echo " output: ", lines.len, " lines" + + # ── Test 12: Zero dimensions ────────────────────────────────── + echo "\n── Test 12: Zero dimensions (should not crash) ──" + block: + let out1 = renderImageRgba(testPixels, 0, ph, caps, defaultOptions()) + doAssert out1.len == 0, "zero width should return empty" + let out2 = renderImageRgba(testPixels, pw, 0, caps, defaultOptions()) + doAssert out2.len == 0, "zero height should return empty" + let out3 = renderImageRgba(newSeq[uint8](0), 0, 0, caps, defaultOptions()) + doAssert out3.len == 0, "zero dims empty data should return empty" + echo " all zero-dim cases returned empty string (pass)" + + # ── Test 13: Short RGBA buffer ──────────────────────────────── + echo "\n── Test 13: Short RGBA buffer (should not crash) ──" + block: + let shortData = newSeq[uint8](4) # only one pixel claimed as 10x10 + let outStr = renderImageRgba(shortData, 10, 10, caps, defaultOptions()) + doAssert outStr.len == 0, "short buffer should return empty (invalid input)" + echo " short buffer returned empty (safe): ", outStr.len, " chars (pass)" + + # ── Test 14: Negative dimensions ────────────────────────────── + echo "\n── Test 14: Negative dimensions (should not crash) ──" + block: + let out1 = renderImageRgba(testPixels, -1, ph, caps, defaultOptions()) + doAssert out1.len == 0 + let out2 = renderImageRgba(testPixels, pw, -5, caps, defaultOptions()) + doAssert out2.len == 0 + echo " all negative-dim cases returned empty string (pass)" + + # ── Test 15: Non-terminal environment ───────────────────────── + echo "\n── Test 15: Non-terminal environment (no crash) ──" + block: + let fakeCaps = TerminalCapabilities( + columns: 0, rows: 0, + cell: CellSize(width: 10, height: 20), + protocols: {ptHalfBlock}, + ) + let geo = computeGeometry(pw, ph, fakeCaps, defaultOptions()) + doAssert geo.columns >= 1, "geometry must have at least 1 column" + doAssert geo.sampleHeight >= 1, "geometry must have at least 1 sample row" + echo " fake caps (0x0): ", geo.columns, " cols x ", geo.sampleHeight, + " samples (pass)" + + # ── Test 16: Empty raw bytes ────────────────────────────────── + echo "\n── Test 16: Empty raw bytes (should not crash) ──" + block: + let outStr = renderImageRaw(newSeq[uint8](0), 100, 100, caps, defaultOptions()) + echo " renderImageRaw returned ", outStr.len, " chars (no crash, pass)" + + # ═══════════════════════════════════════════════════════════════ + # ADVANCED VISUAL TESTS — Gallery, columns, comparison grids + # ═══════════════════════════════════════════════════════════════ + + # ── Pattern generators ──────────────────────────────────────── + proc checkerboard(w, h, csize: int): seq[uint8] = + result = newSeq[uint8](w * h * 4) + for y in 0 ..< h: + for x in 0 ..< w: + let off = (y * w + x) * 4 + let bright = if ((x div csize) + (y div csize)) mod 2 == 0: 220 else: 40 + result[off + 0] = uint8(bright) + result[off + 1] = uint8(bright) + result[off + 2] = uint8(bright) + result[off + 3] = 255 + + proc rainbowBars(w, h: int): seq[uint8] = + result = newSeq[uint8](w * h * 4) + for y in 0 ..< h: + for x in 0 ..< w: + let off = (y * w + x) * 4 + let band = float(x) / float(w) * 6.0 + let iband = int(band) + let frac = band - float(iband) + var r, g, b: float + case iband + of 0: (r, g, b) = (1.0, frac, 0.0) + of 1: (r, g, b) = (1.0 - frac, 1.0, 0.0) + of 2: (r, g, b) = (0.0, 1.0, frac) + of 3: (r, g, b) = (0.0, 1.0 - frac, 1.0) + of 4: (r, g, b) = (frac, 0.0, 1.0) + else: (r, g, b) = (1.0, 0.0, 1.0 - frac) + let ymul = 0.6 + 0.4 * float(y) / float(h) + result[off + 0] = uint8(clamp(int(r * 255.0 * ymul), 0, 255)) + result[off + 1] = uint8(clamp(int(g * 255.0 * ymul), 0, 255)) + result[off + 2] = uint8(clamp(int(b * 255.0 * ymul), 0, 255)) + result[off + 3] = 255 + + proc targetCircle(w, h: int): seq[uint8] = + result = newSeq[uint8](w * h * 4) + let cx = float(w) / 2.0 + let cy = float(h) / 2.0 + let maxR = min(cx, cy) + for y in 0 ..< h: + for x in 0 ..< w: + let off = (y * w + x) * 4 + let dx = float(x) - cx + let dy = float(y) - cy + let r = sqrt(dx * dx + dy * dy) + let ring = int(r / (maxR / 5.0)) mod 2 + if ring == 0: + result[off + 0] = 200; result[off + 1] = 50; result[off + 2] = 50 + else: + result[off + 0] = 255; result[off + 1] = 255; result[off + 2] = 255 + result[off + 3] = 255 + + echo "\n═══════════════════════════════════════════════" + echo " ADVANCED VISUAL TESTS" + echo "═══════════════════════════════════════════════" + + let patternW = 64 + let patternH = 48 + let chk = checkerboard(patternW, patternH, 8) + let rnb = rainbowBars(patternW, patternH) + let tgt = targetCircle(patternW, patternH) + + # ── Test 17: Gallery — three patterns side-by-side ──────────── + echo "\n── Test 17: Gallery (three patterns, same width) ──" + block: + let colW = (caps.columns - 4) div 3 + var opts = defaultOptions() + opts.maxWidth = colW + opts.fit = fmWidth + let geo = computeGeometry(patternW, patternH, caps, opts) + # Side-by-side: render left, mid, right and concatenate each row + let outChk = renderHalfBlock( + ImageData(width: patternW, height: patternH, data: chk), + opts.backgroundRgb, geo.columns, geo.sampleHeight) + let outRnb = renderHalfBlock( + ImageData(width: patternW, height: patternH, data: rnb), + opts.backgroundRgb, geo.columns, geo.sampleHeight) + let outTgt = renderHalfBlock( + ImageData(width: patternW, height: patternH, data: tgt), + opts.backgroundRgb, geo.columns, geo.sampleHeight) + let linesChk = outChk.split('\n') + let linesRnb = outRnb.split('\n') + let linesTgt = outTgt.split('\n') + for i in 0 ..< min(min(linesChk.len, linesRnb.len), linesTgt.len): + stdout.write(linesChk[i]) + stdout.write(" ") + stdout.write(linesRnb[i]) + stdout.write(" ") + stdout.write(linesTgt[i]) + stdout.write("\n") + stdout.flushFile() + echo " gallery: ", geo.columns, " cols each × ", + linesChk.len, " rows (3 across) (pass)" + + # ── Test 18: Column layout — same pattern at 3 densities ────── + echo "\n── Test 18: Column layout (3 densities stacked) ──" + block: + let colW = caps.columns - 2 + for density in [bdFull, bdHalf, bdQuarter]: + var opts = defaultOptions() + opts.density = density + opts.maxWidth = colW + opts.fit = fmWidth + let geo = computeGeometry(patternW, patternH, caps, opts) + let outStr = renderHalfBlock( + ImageData(width: patternW, height: patternH, data: rnb), + opts.backgroundRgb, geo.columns, geo.sampleHeight) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + echo " density=$1: $2 cols × $3 samples (pass)" % [ + $density, $geo.columns, $geo.sampleHeight] + echo " column layout: 3 densities stacked (pass)" + + # ── Test 19: Fit-mode comparison grid ───────────────────────── + echo "\n── Test 19: Fit-mode comparison grid ──" + block: + let halfW = caps.columns div 2 - 1 + for fit in [fmContain, fmWidth, fmOriginal, fmStretch]: + var opts = defaultOptions() + opts.fit = fit + opts.maxWidth = halfW + opts.density = bdQuarter + let geo = computeGeometry(patternW, patternH, caps, opts) + let outStr = renderQuarterBlock( + ImageData(width: patternW, height: patternH, data: tgt), + opts.backgroundRgb, geo.columns, geo.sampleHeight, + dither = true) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + echo " $1: $2 px ($3 cols × $4 samples) (pass)" % [ + $fit, $geo.pixelWidth & "x" & $geo.pixelHeight, + $geo.columns, $geo.sampleHeight] + echo " fit-mode grid: 4 fits stacked (pass)" + + # ── Test 20: Checkerboard at ultra-wide vs tall ─────────────── + echo "\n── Test 20: Extreme aspect ratios (wide vs tall) ──" + block: + var optsW = defaultOptions() + optsW.maxWidth = caps.columns - 2 + optsW.fit = fmWidth + let geoW = computeGeometry(patternW, patternH, caps, optsW) + let outW = renderHalfBlock( + ImageData(width: patternW, height: patternH, data: chk), + optsW.backgroundRgb, geoW.columns, geoW.sampleHeight) + stdout.write(outW) + stdout.write("\n\n") + stdout.flushFile() + # Tall: swap dimensions (64x48 becomes 48x64 via the pattern? no — use a tall pattern) + let tallPixels = checkerboard(patternH, patternW, 6) # 48x64 + var optsT = defaultOptions() + optsT.maxHeight = caps.rows - 4 + optsT.fit = fmContain + let geoT = computeGeometry(patternH, patternW, caps, optsT) + let outT = renderHalfBlock( + ImageData(width: patternH, height: patternW, data: tallPixels), + optsT.backgroundRgb, geoT.columns, geoT.sampleHeight) + stdout.write(outT) + stdout.write("\n") + stdout.flushFile() + echo " wide: ", geoW.columns, " cols × ", geoW.sampleHeight, " samples" + echo " tall: ", geoT.columns, " cols × ", geoT.sampleHeight, " samples (pass)" + + # ── Test 21: Pipeline with all three patterns ───────────────── + echo "\n── Test 21: Pipeline gallery (renderImageRgba) ──" + block: + for (name, pix) in [("checkerboard", chk), ("rainbow", rnb), ("target", tgt)]: + let outStr = renderImageRgba(pix, patternW, patternH, caps, thumbnailOptions()) + stdout.write(outStr) + stdout.write("\n") + stdout.flushFile() + echo " $1: $2 lines rendered (pass)" % [name, $(outStr.split('\n').len)] + + # ═══════════════════════════════════════════════════════════════ + # WORST-CASE / CORRUPTION TESTS + # ═══════════════════════════════════════════════════════════════ + echo "\n═══════════════════════════════════════════════" + echo " WORST-CASE / CORRUPTION TESTS" + echo "═══════════════════════════════════════════════" + + # ── Test 22: Off-by-one data ────────────────────────────────── + echo "\n── Test 22: Off-by-one data (should not crash) ──" + block: + # Exactly 1 byte short (data.len == w*h*4 - 1) + let shortBy1 = newSeq[uint8](pw * ph * 4 - 1) + let out1 = renderImageRgba(shortBy1, pw, ph, caps, defaultOptions()) + doAssert out1.len == 0, + "1-byte-short buffer should return empty, got " & $out1.len & " chars" + + # Exactly 1 byte extra (data.len == w*h*4 + 1) + let extra1 = newSeq[uint8](pw * ph * 4 + 1) + let out2 = renderImageRgba(extra1, pw, ph, caps, defaultOptions()) + # Extra byte is harmless — we only read w*h*4 from front + doAssert out2.len > 0 or caps.protocols == {ptHalfBlock}, + "1-byte-extra buffer should render normally (output len=" & $out2.len & ")" + + echo " 1 byte short → empty (safe), 1 byte extra → renders (pass)" + + # ── Test 23: Tiny images ────────────────────────────────────── + echo "\n── Test 23: Tiny images (1x1, 10x1, 1x10) ──" + block: + # 1x1 + let px1 = @[128'u8, 64, 192, 255] + let out1 = renderImageRgba(px1, 1, 1, caps, defaultOptions()) + doAssert out1.len > 0 or caps.protocols == {ptHalfBlock}, + "1x1 pixel should produce output, got " & $out1.len & " chars" + + # 10x1 horizontal strip + var strip = newSeq[uint8](10 * 1 * 4) + for x in 0 ..< 10: + strip[(x) * 4 + 0] = uint8(x * 25) + strip[(x) * 4 + 1] = uint8(255 - x * 25) + strip[(x) * 4 + 2] = 128 + strip[(x) * 4 + 3] = 255 + let out2 = renderImageRgba(strip, 10, 1, caps, defaultOptions()) + doAssert out2.len > 0 or caps.protocols == {ptHalfBlock}, + "10x1 strip should produce output, got " & $out2.len & " chars" + + # 1x10 vertical strip + var vstrip = newSeq[uint8](1 * 10 * 4) + for y in 0 ..< 10: + let off = y * 4 + vstrip[off + 0] = 64 + vstrip[off + 1] = uint8(y * 25) + vstrip[off + 2] = 192 + vstrip[off + 3] = 255 + let out3 = renderImageRgba(vstrip, 1, 10, caps, defaultOptions()) + doAssert out3.len > 0 or caps.protocols == {ptHalfBlock}, + "1x10 strip should produce output, got " & $out3.len & " chars" + + echo " 1x1, 10x1, 1x10 all rendered (pass)" + + # ── Test 24: Fully transparent image ────────────────────────── + echo "\n── Test 24: Fully transparent image (alpha=0 everywhere) ──" + block: + var transparent = newSeq[uint8](16 * 16 * 4) + for y in 0 ..< 16: + for x in 0 ..< 16: + let off = (y * 16 + x) * 4 + transparent[off + 0] = 255 + transparent[off + 1] = 0 + transparent[off + 2] = 0 + transparent[off + 3] = 0 # fully transparent + let outStr = renderImageRgba(transparent, 16, 16, caps, defaultOptions()) + # Transparency composited over white background should produce white-ish output + doAssert outStr.len > 0 or caps.protocols == {ptHalfBlock}, + "transparent image should not crash, got " & $outStr.len & " chars" + echo " transparent 16x16 rendered (pass)" + + # ── Test 25: Monochrome image ───────────────────────────────── + echo "\n── Test 25: Monochrome image (all same color) ──" + block: + var mono = newSeq[uint8](32 * 24 * 4) + for i in countup(0, mono.len - 1, 4): + mono[i + 0] = 100; mono[i + 1] = 150; mono[i + 2] = 200; mono[i + 3] = 255 + let outStr = renderImageRgba(mono, 32, 24, caps, defaultOptions()) + let lines = outStr.split('\n') + doAssert lines.len >= 1, "monochrome should produce lines, got " & $lines.len + echo " monochrome 32x24: ", lines.len, " lines (pass)" + + # ── Test 26: Extreme opts values ────────────────────────────── + echo "\n── Test 26: Extreme opts (maxWidth=0, maxWidth=-1, maxHeight=0) ──" + block: + var opts0 = defaultOptions() + opts0.maxWidth = 0 + let out1 = renderImageRgba(chk, patternW, patternH, caps, opts0) + doAssert out1.len >= 0, "maxWidth=0 should not crash" + + var optsNeg = defaultOptions() + optsNeg.maxWidth = -1 + let out2 = renderImageRgba(chk, patternW, patternH, caps, optsNeg) + doAssert out2.len >= 0, "maxWidth=-1 should not crash" + + var optsH0 = defaultOptions() + optsH0.maxHeight = 0 + let out3 = renderImageRgba(chk, patternW, patternH, caps, optsH0) + doAssert out3.len >= 0, "maxHeight=0 should not crash" + + echo " all extreme opts returned safely (pass)" + + # ── Test 27: Empty protocol set ─────────────────────────────── + echo "\n── Test 27: Empty protocol set (selectProtocol) ──" + block: + let emptyCaps = TerminalCapabilities( + columns: 80, rows: 24, + cell: CellSize(width: 10, height: 20), + protocols: {}, # no protocols at all + ) + let selected = selectProtocol(emptyCaps, ptHalfBlock) + doAssert selected == ptHalfBlock, + "selectProtocol should fall back to ptHalfBlock, got " & $selected + let selected2 = selectProtocol(emptyCaps, ptAuto) + doAssert selected2 == ptHalfBlock, + "selectProtocol(ptAuto) with empty set should fall back to ptHalfBlock, got " & $selected2 + echo " empty protocols fall back to half-block (pass)" + + # ── Test 28: 1-pixel-high / 1-pixel-wide aspect extremes ───── + echo "\n── Test 28: Extreme aspect (200x1 and 1x200) ──" + block: + var thinW = newSeq[uint8](200 * 1 * 4) + for x in 0 ..< 200: + thinW[x * 4 + 0] = uint8(x) + thinW[x * 4 + 1] = uint8(255 - x) + thinW[x * 4 + 2] = 128 + thinW[x * 4 + 3] = 255 + let out1 = renderImageRgba(thinW, 200, 1, caps, defaultOptions()) + doAssert out1.len >= 0, "200x1 image should not crash" + + var thinH = newSeq[uint8](1 * 200 * 4) + for y in 0 ..< 200: + thinH[y * 4 + 0] = 64 + thinH[y * 4 + 1] = uint8(y) + thinH[y * 4 + 2] = 192 + thinH[y * 4 + 3] = 255 + let out2 = renderImageRgba(thinH, 1, 200, caps, defaultOptions()) + doAssert out2.len >= 0, "1x200 image should not crash" + + echo " 200x1 and 1x200 rendered without crash (pass)" + + # ── Test 29: Zero background and high-dither stress ─────────── + echo "\n── Test 29: Zero background + dither on black ──" + block: + var opts = defaultOptions() + opts.backgroundRgb = (0'u8, 0'u8, 0'u8) # pure black bg + opts.dither = true + opts.density = bdQuarter + opts.fit = fmContain + let outStr = renderImageRgba(tgt, patternW, patternH, caps, opts) + doAssert outStr.len >= 0, "zero bg + dither should not crash" + echo " zero background + dither on target circles (pass)" + + # ── Test 30: Random-stress renders (20 iterations) ──────────── + echo "\n── Test 30: Random-stress (20 renders at random sizes / fit modes) ──" + block: + let sizes = [(1,1), (2,2), (3,5), (7,3), (10,10), (16,16), + (32,48), (64,64), (100,50), (50,100)] + let fits = [fmContain, fmStretch, fmWidth, fmOriginal, fmCellExact] + let densities = [bdFull, bdHalf, bdQuarter] + var rng = 42 # deterministic pseudo-random + proc nextRand(maxVal: int): int = + rng = (rng * 1103515245 + 12345) and 0x7fffffff + result = rng mod maxVal + for iter in 0 ..< 20: + let (sw, sh) = sizes[nextRand(sizes.len)] + var pixels = newSeq[uint8](sw * sh * 4) + for i in countup(0, pixels.len - 1, 4): + pixels[i + 0] = uint8(nextRand(256)) + pixels[i + 1] = uint8(nextRand(256)) + pixels[i + 2] = uint8(nextRand(256)) + pixels[i + 3] = 255 + var opts = defaultOptions() + opts.fit = fits[nextRand(fits.len)] + opts.density = densities[nextRand(densities.len)] + opts.maxWidth = 10 + nextRand(40) + opts.dither = nextRand(2) == 0 + let outStr = renderImageRgba(pixels, sw, sh, caps, opts) + if outStr.len > 0: + discard + echo " 20 random renders completed without crash (pass)" + + # ── Test 31: Tiny data + huge claimed dimensions ────────────── + echo "\n── Test 31: Tiny buffer claimed as huge image ──" + block: + let tiny = @[255'u8, 0, 0, 255] # 1 pixel, claim as 1000x1000 + let out1 = renderImageRgba(tiny, 1000, 1000, caps, defaultOptions()) + doAssert out1.len == 0, + "tiny buffer claimed as huge should return empty, got " & $out1.len & " chars" + let out2 = renderImageRgba(tiny, 999999, 999999, caps, defaultOptions()) + doAssert out2.len == 0, + "tiny buffer claimed as absurdly huge should return empty, got " & $out2.len & " chars" + echo " tiny data + huge/silly dimensions → empty (pass)" + + echo "\n=== All 31 tests passed ==="