iascii
A small, fast Rust library for converting a decoded RGB image into ASCII or colored ANSI terminal art.
You decode the image yourself (with the image crate, or anything else) and hand
this library the raw width, height, and RGB8 bytes. It maps blocks of pixels
down to a grid of characters, each carrying its own color, and renders that grid
either as plain ASCII or as ANSI-escaped color — as a String (batch) or
streamed straight to any writer (streaming).
Features
- Two render formats, one grid. Convert once, then render as escape-free
ASCII (
RenderFormat::Ascii) or color at three ANSI depths (RenderFormat::Ansi(ColorDepth::…)) — chosen at render time, not conversion time. - Batch and streaming output.
render/render_ascii/render_ansireturn aString;render_to/render_ascii_to/render_ansi_towrite row by row to anystd::io::Write. Streaming keeps peak extra memory at roughly one row, so you can generate output far larger than RAM. - Good → Best quality tiers.
Quality::Gooduses a fixed-point luminance fast path,Better(default) uses perceptual floating-point luminance, andBestadds horizontal error-diffusion dithering for visibly smoother gradients. - Sensible built-in glyphs.
Ramp::ascii()(.:-=+*#%@, the default) is a clean, evenly spaced, ambiguity-free ASCII ramp;Ramp::blocks()(░▒▓█) gives contiguous shading. Plus the classic presets and fully custom ramps. - Sync core, no forced runtime.
convert_imageis a plain synchronous call — no tokio, no hidden threads. Call it from anywhere, including insidespawn_blockingif you're in an async context. - Optional parallelism, entirely caller-controlled. Behind the
parallelfeature flag,convert_with_poolaccepts anOption<&rayon::ThreadPool>. Pass your own pool, orNoneto fall back to the sequential path — the crate never touches rayon's global pool implicitly, so it won't oversubscribe threads when called concurrently from multiple places. - Structured output, not just a string. Conversion produces a
GridofCell { ch, color }values, so you can post-process it yourself (HTML, a TUI framework, a diff) without ever touching escape codes. - Configurable everything: character ramp, luminance formula (plain average,
BT.601, BT.709), light or dark ramp orientation, terminal aspect-ratio
correction, output sizing, and (with
parallel) the row-count threshold before concurrency kicks in.
What this crate does not do
- It does not decode images. Bring your own decoder (e.g. the
imagecrate) and pass in raw RGB8 bytes plus dimensions. - It only accepts RGB8 input (3 bytes per pixel, row-major, no alpha). Strip or blend alpha yourself before calling in.
Installation
[]
= "0.1"
# Optional: enables `convert_with_pool` and pulls in rayon.
# iascii = { version = "0.1", features = ["parallel"] }
Quick start
use ;
Streaming large output
render_to writes row by row, so the full output never has to exist in memory —
useful for very large grids, log files, or piping to another process:
use ;
Parallel usage (feature parallel)
use ;
Passing None instead of Some(&pool) — or an output that doesn't clear
parallel_threshold cells — transparently falls back to the exact same
sequential code path used by convert_image, so output is always identical
regardless of which one runs.
Work is split by row for Quality::Best (its error diffusion is row-local) and by
cell otherwise, so wide-but-short grids parallelize instead of staying
single-threaded.
Rendering
Conversion and rendering are separate steps, so one Grid can be rendered in as
many formats as you like.
Formats
| Format | Entry point | Output |
|---|---|---|
| Plain ASCII | RenderFormat::Ascii, render_ascii, render_ascii_to |
Glyphs + \n only, no escape sequences |
| ANSI color | RenderFormat::Ansi(depth), render_ansi, render_ansi_to |
Glyphs wrapped in color escapes |
ANSI color depths (chosen at render time, independent of conversion):
use ColorDepth;
TrueColor; // 24-bit RGB: \x1b[38;2;R;G;Bm
Ansi256; // 256-color: \x1b[38;5;Nm
Ansi16; // 16-color: \x1b[30m–\x1b[37m, \x1b[90m–\x1b[97m
Grid also implements Display, which yields the plain escape-free rendering, so
println!("{grid}") just works.
Batch vs. streaming
| Batch | Streaming | |
|---|---|---|
| Functions | render, render_ascii, render_ansi |
render_to, render_ascii_to, render_ansi_to |
| Returns | String |
Result<(), ImageError> |
| Memory | Whole rendering in memory | ~one row |
| Use when | Output is small, or you need the full string anyway | Output is large, or goes straight to a file/socket/stdout |
Both paths produce identical bytes; the streaming functions simply avoid materializing the result.
Configuration reference
Built via Config::builder()...build(), which returns Result<Config, ImageError>
and validates everything in one place.
| Setter | Default | What it controls |
|---|---|---|
.ramp(RampType) |
RampType::Dark(Ramp::ascii()) |
Which characters represent which brightness, and whether bright pixels map to dense or sparse characters |
.luminance_method(LuminanceMethod) |
Bt601 |
How RGB is reduced to a single brightness value — Average, Bt601, or Bt709 |
.quality(Quality) |
Better |
Speed/fidelity tier — Good, Better, or Best (see below) |
.aspect_ratio_correction(f32) |
0.5 |
Compensates for terminal characters being taller than they are wide |
.max_width(usize) / .explicit_dimensions(w, h) |
MaxWidth(80) |
Output sizing — either a max width with height derived automatically, or exact dimensions |
.parallel_threshold(usize) |
4096 |
(with parallel) Minimum output cells before a supplied thread pool is actually used |
Quality tiers
| Tier | Brightness math | Dithering | When to use |
|---|---|---|---|
Quality::Good |
Fixed-point integers (no f32) |
No | Previews, huge images, hot loops |
Quality::Better (default) |
Perceptual f32 (BT.601/BT.709) |
No | Balanced daily use |
Quality::Best |
Perceptual f32 |
Horizontal error diffusion | Smoothest gradients, final output |
Best diffuses quantization error left-to-right within each row, turning a
fractional brightness into a mix of the two neighbouring glyphs instead of
snapping to one. Diffusion is deliberately row-local, so rows stay
independent and sequential vs. parallel output remains byte-identical.
Quality affects only brightness mapping — never output dimensions.
Ramps
RampType::Dark(ramp) maps bright pixels to visually dense characters (the right
choice for the typical dark-background terminal). RampType::Light(ramp) inverts
that, for light-background output.
Built-in Ramp presets:
| Preset | Glyphs | Notes |
|---|---|---|
ascii() (default) |
.:-=+*#%@ |
Clean, evenly spaced, printable ASCII — copy/paste and log safe |
blocks() |
░▒▓█ |
Contiguous Unicode block shading |
smooth_gradient() |
70 glyphs | Classic fine-grained density ramp |
quantized(), seven_tier() |
repeated blocks | Coarse, posterized looks |
inverted_smooth(), high_variety() |
mixed | Classic alternatives |
emoji_brightness(), celestial() |
emoji | Best-effort (see the limitation below) |
Custom ramps:
use ;
let ramp = Dark;
Known limitation: ramp indexing works on
char(Unicode scalar values), not grapheme clusters. Multi-codepoint emoji (base character + variation selector, ZWJ sequences, etc.) can occupy more than one ramp slot and shift the brightness mapping. The built-inemoji_brightness()/celestial()ramps are best-effort for this reason — prefer single-codepoint custom ramps if exact alignment matters.
How it works
- Validate — buffer length must equal
width * height * 3; dimensions must be non-zero. - Compute target size — source dimensions + your sizing config + aspect
correction → a concrete
(grid_width, grid_height)in character cells. - Sample, row by row — for each output cell, average the block of source
pixels it represents, reduce that to a luminance value (integer for
Good, perceptual forBetter/Best), apply dithering underBest, and look up the matching ramp character. The column → source-x geometry is computed once per conversion and shared across all rows and threads; row geometry is computed once per row. Sequential by default; concurrent withparallel. - Return a
Grid— structuredCell { ch, color }data. Valid to consume directly (e.g. in a TUI) without ever rendering to text. - Render (optional, in any format) — batch to a
String, or stream row by row to a writer. The ANSI path tracks the active color so redundant escape codes are skipped.
Because output resolution is a small character grid rather than the source's full pixel count, expect a blocky, color-approximate impression of the image — that's inherent to ASCII/ANSI art, not a bug.
Error handling
All fallible entry points return iascii::Result<T> (Result<T, ImageError>).
Notable variants: ZeroSizedImage, BufferSizeMismatch, EmptyRamp,
InvalidRamp, InvalidAspectRatio, and Io (propagated from streaming writers).
License
Licensed under either of MIT or Apache-2.0, at your option.