libasciic/lib.rs
1//! A library for converting images to ASCII art with optional colorization.
2//!
3//! This crate provides a builder-pattern API for converting raster images into
4//! ASCII art representations. It supports various character sets, color styles,
5//! and compression options for optimized ANSI output.
6//!
7//! # Examples
8//!
9//! Basic usage:
10//!
11//! ```no_run
12//! use std::fs::File;
13//! use libasciic::{AsciiBuilder, Style};
14//!
15//! let file = File::open("image.png")?;
16//! let ascii = AsciiBuilder::new(file)
17//! .dimensions(80, 40)
18//! .colorize(true)
19//! .style(Style::FgPaint)
20//! .threshold(10)
21//! .make_ascii()?;
22//!
23//! println!("{}", ascii);
24//! # Ok::<(), AsciiError>(())
25//! ```
26#![warn(clippy::pedantic)]
27#![allow(
28 clippy::cast_possible_truncation,
29 clippy::cast_precision_loss,
30 clippy::cast_sign_loss
31)]
32
33mod error;
34
35use std::io::{BufReader, Read, Seek};
36
37use image::{GenericImageView, ImageReader};
38
39pub use image::imageops::FilterType;
40
41pub use crate::error::AsciiError;
42
43type Res<T> = Result<T, AsciiError>;
44/// Defines how colors are applied to ASCII art output.
45///
46/// Different styles control whether characters themselves carry color information
47/// or if colors are applied to the background.
48#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
49#[non_exhaustive]
50#[derive(Debug, Clone, Copy)]
51pub enum Style {
52 /// Paint the foreground (characters) with RGB colors.
53 /// Characters vary based on brightness, and each character is colored.
54 FgPaint,
55
56 /// Paint the background with RGB colors while keeping characters visible.
57 /// Characters vary based on brightness with colored backgrounds.
58 BgPaint,
59
60 /// Paint only the background with RGB colors using space characters.
61 /// Creates a purely color-based representation without visible ASCII characters.
62 BgOnly,
63
64 /// Paint both background and foreground.
65 /// It darkens the background by a configurable percentage, so you can actually see the
66 /// foreground characters.
67 ///
68 /// Be very mindful that **this doubles the amount of ansi control strings**, so it costs
69 /// a lot more to print the output.
70 Mixed,
71}
72
73impl Style {
74 /// Generates an ANSI escape sequence to colorize a character.
75 ///
76 /// # Arguments
77 ///
78 /// * `char` - The character to colorize
79 /// * `rgb` - RGBA color values [red, green, blue, alpha]
80 /// * `factor` - Background brightness factor (0.0-1.0), only used for [`Style::Mixed`]
81 ///
82 /// # Returns
83 ///
84 /// A string containing the ANSI escape sequence and the character.
85 #[must_use]
86 pub fn colorize(&self, char: char, rgb: [u8; 4], factor: f32) -> String {
87 if let Self::Mixed = self {
88 return format!(
89 "\x1b[38;2;{}\x1b[48;2;{}{char}",
90 Self::rgb_to_string(rgb),
91 Self::reduce_brightness(rgb, factor)
92 );
93 }
94 format!("\x1b[{}8;2;{}{char}", self.ansi(), Self::rgb_to_string(rgb))
95 }
96
97 fn ansi(self) -> u8 {
98 match self {
99 Style::FgPaint => 3,
100 Style::BgPaint | Style::BgOnly => 4,
101 Style::Mixed => {
102 unreachable!("Mixed does not require Style::ansi()")
103 }
104 }
105 }
106
107 #[inline]
108 fn reduce_brightness([r, g, b, _]: [u8; 4], factor: f32) -> String {
109 let [r, g, b] = [f32::from(r), f32::from(g), f32::from(b)];
110 Self::rgb_to_string([
111 (r * factor) as u8,
112 (g * factor) as u8,
113 (b * factor) as u8,
114 0,
115 ])
116 }
117
118 #[inline]
119 fn rgb_to_string([r, g, b, _]: [u8; 4]) -> String {
120 format!("{r};{g};{b}m")
121 }
122}
123
124/// Internal character set mapping brightness levels to ASCII characters.
125///
126/// Maps pixel brightness values (0-255) to appropriate characters based on
127/// configured thresholds. Characters are ordered from darkest to brightest.
128#[derive(Debug, Clone)]
129pub struct Charset(Vec<u8>, Vec<char>, char);
130
131impl Charset {
132 /// Finds the appropriate character for a given brightness level.
133 ///
134 /// # Arguments
135 ///
136 /// * `brightness` - Pixel brightness value (0-255)
137 ///
138 /// # Returns
139 ///
140 /// The character that best represents this brightness level.
141 #[must_use]
142 pub fn match_char(&self, brightness: u8) -> char {
143 self.0
144 .iter()
145 .zip(self.1.iter())
146 .find(|(threshold, _)| brightness <= **threshold)
147 .map_or(self.2, |(_, c)| *c)
148 }
149
150 /// Creates a new character set from a specification string.
151 ///
152 /// # Arguments
153 ///
154 /// * `spec` - A string of characters ordered from darkest to brightest.
155 /// A space character is automatically prepended for the darkest value.
156 ///
157 /// # Examples
158 ///
159 /// ```no_run
160 /// use libasciic::Charset;
161 /// let charset = Charset::mkcharset(".:-+=#@");
162 /// ```
163 ///
164 /// # Returns
165 ///
166 /// A `Charset` with evenly distributed brightness thresholds.
167 #[must_use]
168 pub fn mkcharset(spec: &str) -> Self {
169 let mut chars: Vec<char> = spec.chars().collect();
170 chars.insert(0, ' ');
171
172 let steps = chars.len();
173 let mut thresholds = Vec::with_capacity(steps);
174
175 for i in 0..steps {
176 let t =
177 (i as f32 / (steps - 1).max(1) as f32 * 250.0).round() as u8;
178 thresholds.push(t);
179 }
180
181 // I add an element at the start regardless, so it should never panic.
182 #[allow(clippy::missing_panics_doc)]
183 let last = *chars.last().unwrap();
184
185 Self(thresholds, chars, last)
186 }
187}
188
189/// Builder for converting images to ASCII art.
190///
191/// Provides a fluent API for configuring ASCII art generation with support for
192/// dimensions, colorization, character sets, and compression.
193///
194/// # Type Parameters
195///
196/// * `R` - A readable and seekable source (e.g., `File`, `Cursor<Vec<u8>>`)
197///
198/// # Examples
199///
200/// ```no_run
201/// use std::fs::File;
202/// use libasciic::{AsciiBuilder, Style, FilterType};
203///
204/// let file = File::open("photo.jpg")?;
205/// let ascii = AsciiBuilder::new(file)
206/// .dimensions(100, 50)
207/// .colorize(true)
208/// .style(Style::BgPaint)
209/// .threshold(15)
210/// .charset(".:;+=xX$@")?
211/// .filter_type(FilterType::Lanczos3)
212/// .make_ascii()?;
213/// ```
214pub struct AsciiBuilder<R: Read + Seek> {
215 image: BufReader<R>,
216 dimensions: (u32, u32),
217 compression_threshold: u8,
218 charset: Charset,
219 style: Style,
220 colour: bool,
221 filter_type: FilterType,
222 background_brightness: f32,
223}
224
225impl<R: Read + Seek> AsciiBuilder<R> {
226 /// Creates a new ASCII art builder from an image source.
227 ///
228 /// # Arguments
229 ///
230 /// * `image` - A readable and seekable image source
231 ///
232 /// # Returns
233 ///
234 /// A builder with default settings:
235 /// - No dimensions set (must be configured before calling `make_ascii`)
236 /// - Default charset: `.:-+=#@`
237 /// - No colorization
238 /// - Foreground paint style
239 /// - Nearest neighbor filtering
240 /// - Zero compression threshold
241 /// - Background brightness: 0.2 (Only used on [`Style::Mixed`])
242 pub fn new(image: R) -> Self {
243 Self {
244 image: BufReader::new(image),
245 dimensions: (0, 0),
246 compression_threshold: 0,
247 charset: Charset::mkcharset(".:-+=#@"),
248 style: Style::FgPaint,
249 colour: false,
250 filter_type: FilterType::Nearest,
251 background_brightness: 0.2,
252 }
253 }
254
255 /// Generates the ASCII art string from the configured image.
256 ///
257 /// Decodes the image, resizes it to the specified dimensions, and converts
258 /// each pixel to an appropriate ASCII character based on brightness and color.
259 ///
260 /// # Returns
261 ///
262 /// A string containing the ASCII art with optional ANSI color codes.
263 /// Each line is terminated with `\n`.
264 ///
265 /// # Errors
266 ///
267 /// Returns an error if:
268 /// - Dimensions have not been set (are `(0, 0)`)
269 /// - Image format cannot be determined
270 /// - Image decoding fails
271 pub fn make_ascii(self) -> Res<String> {
272 if self.dimensions.0 == 0 || self.dimensions.1 == 0 {
273 return Err(AsciiError::DimensionsNotSet);
274 }
275
276 let resized_image = ImageReader::new(self.image)
277 .with_guessed_format()?
278 .decode()?
279 .resize_exact(
280 self.dimensions.0,
281 self.dimensions.1,
282 self.filter_type,
283 );
284
285 let mut frame = String::new();
286 let mut last_colorized_pixel = resized_image.get_pixel(0, 0).0;
287
288 for y in 0..self.dimensions.1 {
289 for x in 0..self.dimensions.0 {
290 let current_pixel = resized_image.get_pixel(x, y).0;
291 let [r, g, b, _] = current_pixel;
292 let brightness = r.max(g).max(b);
293
294 let char = self.charset.match_char(brightness);
295
296 if !self.colour {
297 frame.push(char);
298 continue;
299 }
300
301 let char = match self.style {
302 Style::BgOnly => ' ',
303 _ => char,
304 };
305
306 let should_colorize =
307 max_colour_diff(current_pixel, last_colorized_pixel)
308 > self.compression_threshold
309 || x == 0;
310
311 if should_colorize {
312 frame.push_str(&self.style.colorize(
313 char,
314 current_pixel,
315 self.background_brightness,
316 ));
317 last_colorized_pixel = current_pixel;
318 } else {
319 frame.push(char);
320 }
321 }
322 if self.colour {
323 frame.push_str("\x1b[0m");
324 }
325
326 if y != self.dimensions.1 - 1 {
327 frame.push('\n');
328 }
329 }
330
331 Ok(frame)
332 }
333
334 /// Enables or disables ANSI color output.
335 ///
336 /// # Arguments
337 ///
338 /// * `colorize` - `true` to enable RGB colors, `false` for monochrome
339 ///
340 /// # Returns
341 ///
342 /// The builder for method chaining.
343 #[inline]
344 #[must_use]
345 pub fn colorize(mut self, colorize: bool) -> Self {
346 self.colour = colorize;
347 self
348 }
349
350 /// Sets the output dimensions for the ASCII art.
351 ///
352 /// # Arguments
353 ///
354 /// * `width` - Number of characters per line
355 /// * `height` - Number of lines
356 ///
357 /// # Returns
358 ///
359 /// The builder for method chaining.
360 ///
361 /// # Notes
362 ///
363 /// Must be called before `make_ascii()`. Consider that characters are typically
364 /// taller than they are wide, so you may want to adjust the aspect ratio.
365 #[inline]
366 #[must_use]
367 pub fn dimensions(mut self, width: u32, height: u32) -> Self {
368 self.dimensions = (width, height);
369 self
370 }
371
372 /// Sets the color compression threshold.
373 ///
374 /// # Arguments
375 ///
376 /// * `threshold` - Maximum color difference (0-255) before emitting new ANSI codes.
377 /// Higher values reduce output size but decrease color accuracy.
378 /// A value of 0 emits color codes for every pixel change.
379 ///
380 /// # Returns
381 ///
382 /// The builder for method chaining.
383 ///
384 /// # Notes
385 ///
386 /// Only applies when colorization is enabled. Useful for reducing the size of
387 /// colored ASCII art output by avoiding redundant ANSI escape sequences.
388 #[inline]
389 #[must_use]
390 pub fn threshold(mut self, threshold: u8) -> Self {
391 self.compression_threshold = threshold;
392 self
393 }
394
395 /// Sets a custom character set for brightness mapping.
396 ///
397 /// # Arguments
398 ///
399 /// * `charset` - Characters ordered from darkest to brightest (space is added automatically)
400 ///
401 /// # Returns
402 ///
403 /// The builder for method chaining.
404 ///
405 /// # Examples
406 ///
407 /// ```no_run
408 /// # use std::fs::File;
409 /// # use libasciic::AsciiBuilder;
410 /// # let file = File::open("image.png")?;
411 /// let builder = AsciiBuilder::new(file)
412 /// .charset(".'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$");
413 /// ```
414 #[inline]
415 #[must_use]
416 pub fn charset(mut self, charset: &str) -> Self {
417 self.charset = Charset::mkcharset(charset);
418 self
419 }
420
421 /// Sets the color application style.
422 ///
423 /// # Arguments
424 ///
425 /// * `style` - The style to use (see [`Style`] for options)
426 ///
427 /// # Returns
428 ///
429 /// The builder for method chaining.
430 #[inline]
431 #[must_use]
432 pub fn style(mut self, style: Style) -> Self {
433 self.style = style;
434 self
435 }
436
437 /// Sets the image resampling filter type.
438 ///
439 /// # Arguments
440 ///
441 /// * `filter_type` - The filter to use when resizing (from `image::imageops::FilterType`)
442 ///
443 /// # Returns
444 ///
445 /// The builder for method chaining.
446 ///
447 /// # Notes
448 ///
449 /// - `Nearest`: Fastest but lowest quality
450 /// - `Triangle`: Good balance of speed and quality
451 /// - `CatmullRom`: High quality
452 /// - `Lanczos3`: Highest quality but slowest
453 #[inline]
454 #[must_use]
455 pub fn filter_type(mut self, filter_type: FilterType) -> Self {
456 self.filter_type = filter_type;
457 self
458 }
459
460 /// Sets the background brightness factor for Mixed style.
461 ///
462 /// # Arguments
463 ///
464 /// * `factor` - A value between 0.0 and 1.0 that controls background brightness.
465 /// - `1.0` keeps the background at full brightness (same as foreground)
466 /// - `0.5` reduces background to 50% brightness
467 /// - `0.2` (default) reduces background to 20% brightness
468 /// - `0.0` makes the background completely black
469 ///
470 /// # Returns
471 ///
472 /// The builder for method chaining.
473 ///
474 /// # Notes
475 ///
476 /// Only applies when using [`Style::Mixed`]. Lower values create more contrast
477 /// between foreground characters and background, making text more readable.
478 #[inline]
479 #[must_use]
480 pub fn background_brightness(mut self, factor: f32) -> Self {
481 self.background_brightness = factor.clamp(0.0, 1.0);
482 self
483 }
484}
485
486#[inline]
487fn max_colour_diff(pixel_a: [u8; 4], pixel_b: [u8; 4]) -> u8 {
488 let [r1, g1, b1, _] = pixel_a;
489 let [r2, g2, b2, _] = pixel_b;
490 r1.abs_diff(r2).max(g1.abs_diff(g2)).max(b1.abs_diff(b2))
491}