Update
This commit is contained in:
commit
87ae53d2f5
26
Makefile
Normal file
26
Makefile
Normal file
@ -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
|
||||
337
README.md
Normal file
337
README.md
Normal file
@ -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)
|
||||
232
examples/demo.nim
Normal file
232
examples/demo.nim
Normal file
@ -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 "╰──────────────────────────────────────────────╯"
|
||||
24
nimimg.nimble
Normal file
24
nimimg.nimble
Normal file
@ -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"
|
||||
31
src/termimg.nim
Normal file
31
src/termimg.nim
Normal 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
src/termimg/capabilities.nim
Normal file
62
src/termimg/capabilities.nim
Normal 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
src/termimg/halfblock.nim
Normal file
116
src/termimg/halfblock.nim
Normal 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
src/termimg/iterm2.nim
Normal file
18
src/termimg/iterm2.nim
Normal 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
src/termimg/kitty.nim
Normal file
52
src/termimg/kitty.nim
Normal 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
src/termimg/quarterblock.nim
Normal file
197
src/termimg/quarterblock.nim
Normal 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
src/termimg/renderer.nim
Normal file
109
src/termimg/renderer.nim
Normal 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
src/termimg/scaling.nim
Normal file
137
src/termimg/scaling.nim
Normal 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
src/termimg/sixel.nim
Normal file
185
src/termimg/sixel.nim
Normal 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
src/termimg/types.nim
Normal file
161
src/termimg/types.nim
Normal 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,
|
||||
)
|
||||
629
tests/test_termimg.nim
Normal file
629
tests/test_termimg.nim
Normal file
@ -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 ==="
|
||||
Loading…
Reference in New Issue
Block a user