Skip to content

Images

ImageArt draws a still image in the terminal. It owns the decoded picture, picks a backend (ASCII, Braille, half blocks, quadrants or Sixel), and applies the optional steps in between: fitting and cropping, flattening transparency, reducing colours, dithering, tone adjustments, rotation and flips.

Use it for thumbnails, previews, a logo in a report, or the picture half of a tool that already prints tables. It needs the image feature (cargo add rs-rich-art --features image).

The smallest example

The examples on this page draw their own test card, so they need no image file:

/// A 96×64 test card: a diagonal gradient, a yellow disc, a red square and a
/// white bar, so every renderer has edges, flat areas and smooth ramps.
fn test_card() -> DynamicImage {
    let (w, h) = (96u32, 64u32);
    DynamicImage::ImageRgba8(RgbaImage::from_fn(w, h, |x, y| {
        let (fx, fy) = (x as f32 / (w - 1) as f32, y as f32 / (h - 1) as f32);
        let mut rgb = [
            (30.0 + 200.0 * fx) as u8,
            (40.0 + 150.0 * fy) as u8,
            (210.0 - 150.0 * fx) as u8,
        ];
        let (dx, dy) = (x as f32 - 30.0, y as f32 - 32.0);
        if dx * dx + dy * dy <= 18.0 * 18.0 {
            rgb = [250, 205, 40]; // the disc
        }
        if (60..84).contains(&x) && (14..38).contains(&y) {
            rgb = [225, 55, 85]; // the square
        }
        if (56..90).contains(&x) && (46..52).contains(&y) {
            rgb = [245, 245, 245]; // the bar
        }
        Rgba([rgb[0], rgb[1], rgb[2], 255])
    }))
}

Render it with half blocks, 48 columns wide:

let art = ImageArt::new(test_card()) // or ImageArt::from_path("photo.png")?
    .mode(ImageMode::Blocks)
    .width(48);
console.print(&art);

The test card drawn with half blocks

For a file, use ImageArt::from_path("photo.png")? (PNG and JPEG with the image feature; GIF too with gif). ImageArt::from_bytes decodes from memory and ImageArt::new takes an image::DynamicImage you already have.

Modes

.mode(ImageMode::…) pins a backend. Each one trades detail, colour and compatibility differently:

let card = test_card();
let art = |mode| ImageArt::new(card.clone()).mode(mode).width(30);
console.print(&grid(
    vec![
        ("Ascii", art(ImageMode::Ascii)),
        ("Ascii + .color(true)", art(ImageMode::Ascii).color(true)),
        ("Blocks", art(ImageMode::Blocks)),
        ("Quadrants", art(ImageMode::Quadrants)),
        ("Braille", art(ImageMode::Braille)),
    ],
    2,
    30,
));

ASCII, coloured ASCII, half blocks, quadrants and Braille side by side

Mode What to notice
Ascii One character per pixel, chosen by brightness from a density ramp (rich_art::DEFAULT_RAMP, spaces through M). Works with no colour at all. .color(true) also colours each character with its pixel.
Blocks ▀ with the top pixel as foreground and the bottom one as background: twice ASCII's vertical detail and every cell painted. Needs colour.
Quadrants Splits each cell into 2×2 pixels. A cell still has only two colours, so it picks the split with the smallest error. Sharper edges than blocks.
Braille 2×4 dots per cell, each on or off by a fixed brightness threshold. Monochrome.
Sixel Real pixels. See Sixel below.
Auto The default. ASCII without colour, Sixel where it is compiled in and looks supported, otherwise blocks.

Terminal cells are about twice as tall as they are wide. Every backend corrects for that, so a square image stays square.

Size

  • .width(columns) sets the width. Without it the image fills the console width (or the table cell, panel or column it is placed in).
  • .height(rows) sets the height. Without .fit, the backends treat it differently: ASCII uses it as an exact row count, the others as a cap that keeps the aspect ratio.
  • .max_width(columns) and .max_height(rows) are upper bounds that never enlarge anything. They are useful when the width comes from the terminal.

The terminal's height does not cap an image: an image taller than the screen scrolls, like any long output. Rows are capped only by .height, .max_height, or a fixed options.height from the container rendering it (a Layout region, for one). This is deliberate: shrinking every tall image to the screen would change output for everyone who scrolls it or pipes it. To fit the screen, pass its height yourself, as .max_height(rows).

Fit and crop

.fit(…) makes the image fill an exact width × height box of cells. It needs both dimensions.

let card = test_card();
// A 20×10-cell box is taller (in pixels) than the 3:2 card, so each
// policy has to do something different to fill it.
let boxed = |fit| {
    ImageArt::new(card.clone())
        .mode(ImageMode::Blocks)
        .width(20)
        .height(10)
        .fit(fit)
        .background([40, 40, 40])
};
console.print(&grid(
    vec![
        ("Contain", boxed(ImageFit::Contain)),
        ("Cover", boxed(ImageFit::Cover)),
        (
            "Cover, TopLeft",
            boxed(ImageFit::Cover).anchor(ImageAnchor::TopLeft),
        ),
        ("Stretch", boxed(ImageFit::Stretch)),
    ],
    4,
    20,
));

Contain, cover, cover anchored top-left, and stretch

Fit Effect
ImageFit::Contain The whole image, centred, with background-coloured padding.
ImageFit::Cover Fills the box and crops what overflows. .anchor(…) picks what to keep.
ImageFit::Stretch Fills the box exactly, ignoring the aspect ratio.

ImageAnchor is Center (the default), Top, Bottom, Left, Right, TopLeft, TopRight, BottomLeft or BottomRight. It only matters for Cover.

Fitting clamps the width to the space available, and refuses boxes above 16 megapixels (including the intermediate image cover resizes to). Invalid sizes are reported by render; console.print then draws nothing.

Native size

.fit(ImageFit::Native) renders the image at its own size, in whole cells, and needs no width or height. Icons, logos and pixel art come out the size they are, where the default would enlarge them to fill the console.

// A 16×16 icon at its own size: one pixel per cell across in half-blocks,
// two in quadrants and Braille. Without `.fit(ImageFit::Native)` it would
// fill the whole width.
let art = |mode| {
    ImageArt::new(icon())
        .mode(mode)
        .fit(ImageFit::Native)
        .background_mode(rich_art::ImageBackground::TerminalDefault)
};
console.print(&grid(
    vec![
        ("Blocks: 16×8", art(ImageMode::Blocks)),
        ("Quadrants: 8×4", art(ImageMode::Quadrants)),
        ("Braille: 8×4", art(ImageMode::Braille)),
    ],
    3,
    18,
));

A 16×16 icon at native size in blocks, quadrants and Braille

Mode Pixels per cell A 16×16 image
Ascii, Blocks 1 × 2 16 × 8 cells
Quadrants, Braille 2 × 4 8 × 4 cells
Sixel 8 × 16 (the assumed cell size) 2 × 1 cells

Every mode keeps the aspect ratio on a cell twice as tall as it is wide. A size that is not a whole number of cells rounds up: each pixel lands on exactly one sub-cell pixel, unscaled, and the rest of the last cell takes the background (transparent with ImageBackground::TerminalDefault). A 1×1 image is one cell.

The console width, .width/.height and .max_width/.max_height only ever shrink a native image. It then fits the capped grid as Contain does, keeping the aspect ratio. ImageArt::native_grid(mode, available) returns the grid a render will use.

Transparency

Without a background, transparent pixels read as black (in ASCII, as the darkest character). .background([r, g, b]) flattens transparency onto a colour of your choice before resizing, and also colours Contain padding:

let art = || ImageArt::new(logo()).mode(ImageMode::Blocks).width(28);
console.print(&grid(
    vec![
        ("No background", art()),
        (
            "background([120, 40, 160])",
            art().background([120, 40, 160]),
        ),
    ],
    2,
    30,
));

A transparent logo without and with a purple background

Colour depth

By default colours are sent as 24-bit truecolor. .color_mode(…) reduces them to a palette, for terminals (or recordings) that cannot show truecolor:

let card = test_card();
let art = |mode| {
    ImageArt::new(card.clone())
        .mode(ImageMode::Blocks)
        .width(30)
        .color_mode(mode)
};
console.print(&grid(
    vec![
        ("TrueColor (default)", art(ImageColorMode::TrueColor)),
        ("Ansi256", art(ImageColorMode::Ansi256)),
        ("Ansi16", art(ImageColorMode::Ansi16)),
        ("Grayscale", art(ImageColorMode::Grayscale)),
    ],
    2,
    30,
));

Truecolor, ANSI 256, ANSI 16 and grayscale

ImageColorMode Palette
TrueColor The sampled RGB, unchanged (default).
Ansi256 The fixed entries 16–255. Entries 0–15 are left out because terminal themes redefine them.
Ansi16 The 16 system colours. The terminal theme decides how they look, so output follows the user's theme at the cost of fidelity.
Grayscale The 26 neutral entries (16, 232–255 and 231), chosen by luma.

The nearest colour is found by squared distance in encoded RGB, with ties going to the lowest palette index. It is deterministic, not perceptual.

Dithering

Reducing colours creates flat bands. .dither(…) trades them for a pattern that averages to the right colour from a distance:

let card = test_card();
let art = |dither| {
    ImageArt::new(card.clone())
        .mode(ImageMode::Blocks)
        .width(30)
        .color_mode(ImageColorMode::Ansi16)
        .dither(dither)
};
console.print(&grid(
    vec![
        ("Ansi16, Dither::None", art(Dither::None)),
        ("Ansi16, FloydSteinberg", art(Dither::FloydSteinberg)),
        ("Ansi16, Bayer4x4", art(Dither::Bayer4x4)),
    ],
    3,
    30,
));

ANSI 16 with no dithering, Floyd–Steinberg and Bayer 4×4

  • Dither::FloydSteinberg diffuses each pixel's error to its neighbours, scanning left to right, top to bottom. Error at the edges is dropped.
  • Dither::Bayer4x4 adds a fixed 4×4 threshold pattern anchored at the top-left corner. It is stable from frame to frame.

Dithering needs a reduced palette (Ansi256, Ansi16 or Grayscale).

Palette reduction and dithering work with the Ascii, Blocks and Quadrants backends. Braille has no colour and Sixel does its own palette, so both reject them.

Tone adjustments

ImageTransforms carries brightness, contrast and gamma. Each defaults to 1.0, which leaves the image untouched:

let card = test_card();
let art = |transforms| {
    ImageArt::new(card.clone())
        .mode(ImageMode::Blocks)
        .width(22)
        .transforms(transforms)
};
let tone = |brightness, contrast, gamma| ImageTransforms {
    brightness,
    contrast,
    gamma,
    ..ImageTransforms::default()
};
console.print(&grid(
    vec![
        ("Unchanged", art(ImageTransforms::default())),
        ("brightness 0.6", art(tone(0.6, 1.0, 1.0))),
        ("contrast 1.8", art(tone(1.0, 1.8, 1.0))),
        ("gamma 2.2", art(tone(1.0, 1.0, 2.2))),
    ],
    4,
    22,
));

Unchanged, darker, higher contrast and brighter mid-tones

They act on each colour channel v in 0..1, clamping after each step, and never touch alpha:

  1. brightness b: v × b
  2. contrast c: (v − 0.5) × c + 0.5
  3. gamma g: v^(1/g), so values above 1 brighten the mid-tones

Brightness and contrast must be finite and at least 0; gamma must be finite and above 0. Anything else is ImageArtError::InvalidAdjustment.

Rotation, flips and grayscale

The same struct rotates clockwise in quarter turns, flips, and converts to grayscale:

let card = test_card();
let art = |transforms| {
    ImageArt::new(card.clone())
        .mode(ImageMode::Blocks)
        .width(22)
        .transforms(transforms)
};
console.print(&grid(
    vec![
        (
            "Clockwise90",
            art(ImageTransforms {
                rotation: Rotation::Clockwise90,
                ..ImageTransforms::default()
            }),
        ),
        (
            "flip_horizontal",
            art(ImageTransforms {
                flip_horizontal: true,
                ..ImageTransforms::default()
            }),
        ),
        (
            "flip_vertical",
            art(ImageTransforms {
                flip_vertical: true,
                ..ImageTransforms::default()
            }),
        ),
        (
            "grayscale",
            art(ImageTransforms {
                grayscale: true,
                ..ImageTransforms::default()
            }),
        ),
    ],
    4,
    22,
));

Rotated 90°, flipped horizontally, flipped vertically, and grayscale

The order is fixed: rotation, horizontal flip, vertical flip, brightness, contrast, gamma, grayscale; then fitting and background, sampling, palette reduction and dithering, and finally the backend picks characters. Rotation and flips keep transparency. Grayscale composites onto the background first (and turns contain padding gray too).

These transforms apply to still images only, not to GIFs or image diffs.

Strict rendering

console.print(&art) cannot fail, so when a request is impossible — an explicit Sixel without the feature, palette reduction with Braille, a bad adjustment — it quietly falls back to ASCII. Call render instead when you want to know why:

// `console.print` cannot fail, so an impossible request quietly degrades
// to ASCII. `render` is the strict entry point that says why.
let art = ImageArt::new(test_card())
    .mode(ImageMode::Braille)
    .color_mode(ImageColorMode::Ansi256); // Braille has no colour
match art.render(console, &console.options()) {
    Ok(segments) => console.print_str(&format!("{} segments", segments.len())),
    Err(ImageArtError::UnsupportedColorOptions) => {
        console.print_str("[red]rejected:[/] Braille cannot be quantized")
    }
    Err(other) => console.print_str(&format!("[red]rejected:[/] {other}")),
}

render returns the segments on success, or an ImageArtError:

Error Cause
FeatureNotEnabled { mode, feature } An explicit mode this build cannot draw, e.g. Sixel without sixel.
NonTerminalDestination Sixel requested for output that is not a terminal.
SixelNotSupported Sixel requested on a terminal not known to support it; RICH_SIXEL=1 or RICH_GRAPHICS=sixel forces it.
SixelEncodeFailed The encoder rejected this image or size.
SixelTooLarge The Sixel raster would exceed 16 megapixels (8×16 pixels per cell); narrow it or cap its height.
InvalidFitDimensions Fitting without a positive width and height, or above 16 megapixels.
UnsupportedColorOptions Palette reduction or dithering with a backend that cannot do it, or dithering with truecolor.
InvalidAdjustment Brightness, contrast or gamma out of range.

To see what Auto would choose, ask resolve_mode with the capabilities you care about. RenderCapabilities::from_console reads them off a console:

// What would Auto pick? Explicit modes are returned unchanged.
let auto = ImageArt::new(test_card());
for (label, caps) in [
    ("no colour", RenderCapabilities::default()),
    (
        "colour",
        RenderCapabilities {
            color: true,
            sixel_supported: false,
        },
    ),
    (
        "colour + Sixel",
        RenderCapabilities {
            color: true,
            sixel_supported: true,
        },
    ),
    ("this console", RenderCapabilities::from_console(console)),
] {
    let mode = auto.resolve_mode(caps);
    console.print_str(&format!("Auto with {label:<14} → {mode:?}"));
}

Strict rendering output and what Auto resolves to

A console with an attached rich::protocol::RenderEnvironment (as the CLI uses) is honoured by render; render_with_environment takes one explicitly. Through an environment, Auto picks ASCII when Unicode is unavailable.

Sixel

Sixel draws real pixels, so a photo keeps its detail. It needs the sixel feature and a terminal that understands it, such as Windows Terminal 1.22+, WezTerm, mintty, foot or mlterm.

// Sixel is opt-in: it needs the `sixel` feature and a terminal that
// draws it. `render` reports why when either is missing.
let art = ImageArt::new(test_card()).mode(ImageMode::Sixel).width(40);
match art.render(console, &console.options()) {
    Ok(_) => console.print(&art),
    Err(ImageArtError::FeatureNotEnabled { feature, .. }) => {
        console.print_str(&format!("rebuild rs-rich-art with the {feature:?} feature"))
    }
    Err(ImageArtError::NonTerminalDestination) => {
        console.print(&art.mode(ImageMode::Blocks)) // e.g. output is redirected
    }
    Err(other) => console.print_str(&format!("no Sixel: {other}")),
}

There is no reliable way to ask a terminal whether it supports Sixel without a round trip on a tty, so support is guessed from environment variables (TERM, TERM_PROGRAM, WT_SESSION). RICH_GRAPHICS=sixel forces Sixel on and RICH_GRAPHICS=none (or kitty, iterm) rules it out; otherwise RICH_SIXEL=1 or RICH_SIXEL=0 overrides the guess. With the feature on you can also use SixelArt directly:

use rich_art::sixel::{is_probably_supported, SixelArt};
// A guess from environment variables (RICH_GRAPHICS or RICH_SIXEL override it).
if console.is_terminal() && is_probably_supported() {
    console.print(&SixelArt::new(test_card()).width(40).max_colors(64));
}

Sixel output is terminal graphics, not text, so it cannot be exported to HTML or SVG; exports use blocks instead. Run the example with -- --sixel in a Sixel terminal to see it.

The individual backends

ImageArt is a front end. The backends are public too, for when you want one and none of the preprocessing: AsciiArt (with .ramp(), .invert(), .color(), and to_text() for a plain String), BlockArt, QuadrantArt, BrailleArt and SixelArt. Each has new(image), width and height.

Gotchas

  • Colour backends (blocks, quadrants) need colour. With NO_COLOR, or output that is not a terminal, prefer Ascii or Braille; Auto does this for you.
  • The image is resampled to the cell grid, so fine text in a screenshot will not survive at 40 columns. Sixel is the only mode that keeps pixels.
  • .color(true) only affects ASCII; the colour backends are always in colour.

See also