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}