#![deny(unsafe_code)]
#![warn(missing_docs)]
#![allow(clippy::many_single_char_names)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::similar_names)]
pub mod error;
pub mod palettes;
pub mod processing;
pub mod sets;
pub mod settings;
use std::path::Path;
use fast_image_resize::images::Image;
use fast_image_resize::{PixelType, ResizeOptions, Resizer};
use image::{DynamicImage, GenericImageView};
use imagequant::{
Attributes as LiqAttr, Image as LiqImage, QuantizationResult as LiqResult, RGBA as LiqRGBA,
};
use rayon::iter::{
IndexedParallelIterator as _, IntoParallelRefMutIterator as _, ParallelIterator as _,
};
pub use self::settings::{
Advanced, AsciiCharSet, CharacterMode, Characters, ColorMode, Colors, DitherMatrix, Dithering,
Settings, Size, SizeMode, UnicodeCharSet,
};
pub(crate) const BLACK_LUV: processing::LuvColor = palette::Luv::new(0.0, 0.0, 0.0);
pub fn convert(path: &Path, settings: &Settings) -> error::Result<String> {
let img = image::open(path)?;
convert_image(&img, settings)
}
pub fn convert_image(img: &DynamicImage, settings: &Settings) -> error::Result<String> {
if !settings.colors.is_truecolor && settings.colors.palette.is_empty() {
return Err(error::AnsiImageError::InvalidSettings(
"A color palette must be selected when not in truecolor mode.".into(),
));
}
if let CharacterMode::Custom(chars) = &settings.characters.mode
&& chars.is_empty()
{
return Err(error::AnsiImageError::InvalidSettings(
"Custom character mode requires at least one character.".into(),
));
}
let (img_w, img_h) = img.dimensions();
let (w, h) = calculate_dimensions(
img_w,
img_h,
settings.size.width,
settings.size.height,
settings.size.mode,
settings.characters.aspect_ratio,
);
let target_w = (w * 2) as u32;
let target_h = (h * 2) as u32;
let src_image = Image::from_vec_u8(img_w, img_h, img.to_rgb8().into_raw(), PixelType::U8x3)
.map_err(|e| error::AnsiImageError::Processing(e.to_string()))?;
let mut dst_image = Image::new(target_w, target_h, src_image.pixel_type());
let algorithm = fast_image_resize::ResizeAlg::Convolution(settings.advanced.resize_filter);
let resize_options = ResizeOptions::new().resize_alg(algorithm);
let mut resizer = Resizer::new();
resizer
.resize(&src_image, &mut dst_image, Some(&resize_options))
.map_err(|e| error::AnsiImageError::Processing(e.to_string()))?;
let resized_buffer = image::RgbImage::from_raw(target_w, target_h, dst_image.into_vec())
.ok_or_else(|| {
error::AnsiImageError::Processing("Failed to create image from resized buffer.".into())
})?;
let processed_img = if settings.colors.is_truecolor {
resized_buffer
} else {
quantize_with_imagequant(
&resized_buffer,
&settings.colors.palette,
settings.advanced.dithering.is_enabled,
)?
};
let mut rows: Vec<String> = vec![String::new(); h];
rows.par_iter_mut().enumerate().for_each(|(y, row_buf)| {
*row_buf = processing::process_row(y, w, &processed_img, settings);
});
Ok(rows.join("\n"))
}
fn calculate_dimensions(
img_w: u32,
img_h: u32,
width: usize,
height: usize,
mode: SizeMode,
char_ratio: f32,
) -> (usize, usize) {
if mode == SizeMode::Exact {
return (width.max(1), height.max(1));
}
let char_ratio = char_ratio.max(0.01);
let img_w_f = img_w as f32;
let img_h_f = img_h as f32;
let width_f = width as f32;
let height_f = height as f32;
let fit_height = width_f * (img_h_f / img_w_f) * char_ratio;
let fit_width = (height_f * (img_w_f / img_h_f)) / char_ratio;
let (w_calc, h_calc) = if fit_height > height_f {
(fit_width.round() as usize, height)
} else {
(width, fit_height.round() as usize)
};
(w_calc.max(1), h_calc.max(1))
}
fn quantize_with_imagequant(
rgb: &image::RgbImage,
palette_rgb: &[image::Rgb<u8>],
dithering_enabled: bool,
) -> error::Result<image::RgbImage> {
let (w, h) = rgb.dimensions();
let rgba_pixels: Vec<LiqRGBA> = rgb
.pixels()
.map(|p| LiqRGBA {
r: p[0],
g: p[1],
b: p[2],
a: 255,
})
.collect();
let fixed_palette: Vec<LiqRGBA> = palette_rgb
.iter()
.map(|p| LiqRGBA {
r: p[0],
g: p[1],
b: p[2],
a: 255,
})
.collect();
let attr = LiqAttr::new();
let mut liq_img = LiqImage::new_borrowed(
&attr,
&rgba_pixels,
w as usize,
h as usize,
0.0, )
.map_err(|e| {
error::AnsiImageError::Processing(format!("imagequant new_image failed: {e:?}"))
})?;
let mut res = LiqResult::from_palette(&attr, &fixed_palette, 0.0).map_err(|e| {
error::AnsiImageError::Processing(format!("imagequant from_palette failed: {e:?}"))
})?;
let level = if dithering_enabled { 1.0 } else { 0.0 };
res.set_dithering_level(level).map_err(|e| {
error::AnsiImageError::Processing(format!("imagequant set_dithering_level failed: {e:?}"))
})?;
let (out_palette, indices) = res.remapped(&mut liq_img).map_err(|e| {
error::AnsiImageError::Processing(format!("imagequant remapped failed: {e:?}"))
})?;
let mut out_buffer = Vec::with_capacity(indices.len() * 3);
for &idx in &indices {
let c = out_palette[idx as usize];
out_buffer.extend_from_slice(&[c.r, c.g, c.b]);
}
image::RgbImage::from_vec(w, h, out_buffer).ok_or_else(|| {
error::AnsiImageError::Processing(
"Failed to construct RgbImage from quantized buffer".into(),
)
})
}
#[cfg(test)]
mod tests {
use super::calculate_dimensions;
use crate::settings::SizeMode;
#[test]
fn dims_never_zero_exact() {
assert_eq!(
calculate_dimensions(100, 100, 0, 0, SizeMode::Exact, 0.5),
(1, 1)
);
}
#[test]
fn dims_never_zero_fit() {
assert_eq!(
calculate_dimensions(100, 100, 0, 0, SizeMode::Fit, 0.5),
(1, 1)
);
}
}