pub mod color_mode;
pub mod convert;
pub mod dither;
pub mod loader;
pub mod mapper;
pub mod resize;
#[cfg(feature = "svg")]
pub mod svg;
pub mod temporal;
pub mod threshold;
pub use color_mode::{render_image_with_color, ColorMode, ColorSamplingStrategy};
pub use convert::to_grayscale;
pub use dither::{apply_dithering, apply_dithering_with_custom_threshold, DitheringMethod};
pub use loader::{load_from_bytes, load_from_path, supported_formats};
pub use mapper::pixels_to_braille;
pub use resize::{resize_to_dimensions, resize_to_terminal};
#[cfg(feature = "svg")]
pub use svg::{load_svg_from_bytes, load_svg_from_path};
pub use threshold::{
adjust_brightness, adjust_contrast, adjust_gamma, apply_threshold, auto_threshold,
otsu_threshold, BinaryImage,
};
use crate::{BrailleGrid, DotmaxError};
use image::DynamicImage;
use std::path::Path;
use tracing::{debug, info, instrument};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResizeMode {
AutoTerminal { preserve_aspect: bool },
Manual {
width: usize,
height: usize,
preserve_aspect: bool,
},
}
#[derive(Debug)]
pub struct ImageRenderer {
image: Option<DynamicImage>,
dithering: DitheringMethod,
color_mode: ColorMode,
threshold: Option<u8>,
resize_mode: ResizeMode,
brightness: f32,
contrast: f32,
gamma: f32,
cached_resized: Option<DynamicImage>,
cached_original_resized: Option<DynamicImage>,
cached_dimensions: Option<(u32, u32)>,
}
impl ImageRenderer {
#[must_use]
pub const fn new() -> Self {
Self {
image: None,
dithering: DitheringMethod::FloydSteinberg,
color_mode: ColorMode::Monochrome,
threshold: None,
resize_mode: ResizeMode::AutoTerminal {
preserve_aspect: true,
},
brightness: 1.0,
contrast: 1.0,
gamma: 1.0,
cached_resized: None,
cached_original_resized: None,
cached_dimensions: None,
}
}
#[instrument(skip(self))]
pub fn load_from_path(mut self, path: &Path) -> Result<Self, DotmaxError> {
let img = load_from_path(path)?;
info!(
"Loaded image from {:?}, dimensions: {}x{}",
path,
img.width(),
img.height()
);
self.image = Some(img);
self.cached_resized = None;
self.cached_original_resized = None;
self.cached_dimensions = None;
Ok(self)
}
#[instrument(skip(self, bytes))]
pub fn load_from_bytes(mut self, bytes: &[u8]) -> Result<Self, DotmaxError> {
let img = load_from_bytes(bytes)?;
info!(
"Loaded image from bytes, dimensions: {}x{}",
img.width(),
img.height()
);
self.image = Some(img);
self.cached_resized = None;
self.cached_original_resized = None;
self.cached_dimensions = None;
Ok(self)
}
#[must_use]
pub fn load_from_rgba(mut self, img: image::RgbaImage) -> Self {
info!(
"Loaded RGBA image, dimensions: {}x{}",
img.width(),
img.height()
);
self.image = Some(DynamicImage::ImageRgba8(img));
self.cached_resized = None;
self.cached_original_resized = None;
self.cached_dimensions = None;
self
}
#[cfg(feature = "svg")]
#[instrument(skip(self))]
pub fn load_svg_from_path(
mut self,
path: &Path,
width: u32,
height: u32,
) -> Result<Self, DotmaxError> {
let img = svg::load_svg_from_path(path, width, height)?;
info!(
"Loaded SVG from {:?}, rasterized to {}x{}",
path, width, height
);
self.image = Some(img);
self.cached_resized = None;
self.cached_original_resized = None;
self.cached_dimensions = None;
Ok(self)
}
pub const fn resize_to_terminal(mut self) -> Result<Self, DotmaxError> {
self.resize_mode = ResizeMode::AutoTerminal {
preserve_aspect: true,
};
Ok(self)
}
pub fn resize(
mut self,
width: usize,
height: usize,
preserve_aspect: bool,
) -> Result<Self, DotmaxError> {
if width == 0 || height == 0 || width > 10_000 || height > 10_000 {
return Err(DotmaxError::InvalidDimensions { width, height });
}
self.resize_mode = ResizeMode::Manual {
width,
height,
preserve_aspect,
};
Ok(self)
}
pub fn brightness(mut self, factor: f32) -> Result<Self, DotmaxError> {
if !(0.0..=2.0).contains(&factor) {
return Err(DotmaxError::InvalidParameter {
parameter_name: "brightness".to_string(),
value: factor.to_string(),
min: "0.0".to_string(),
max: "2.0".to_string(),
});
}
self.brightness = factor;
Ok(self)
}
pub fn contrast(mut self, factor: f32) -> Result<Self, DotmaxError> {
if !(0.0..=2.0).contains(&factor) {
return Err(DotmaxError::InvalidParameter {
parameter_name: "contrast".to_string(),
value: factor.to_string(),
min: "0.0".to_string(),
max: "2.0".to_string(),
});
}
self.contrast = factor;
Ok(self)
}
pub fn gamma(mut self, value: f32) -> Result<Self, DotmaxError> {
if !(0.1..=3.0).contains(&value) {
return Err(DotmaxError::InvalidParameter {
parameter_name: "gamma".to_string(),
value: value.to_string(),
min: "0.1".to_string(),
max: "3.0".to_string(),
});
}
self.gamma = value;
Ok(self)
}
#[must_use]
pub const fn dithering(mut self, method: DitheringMethod) -> Self {
self.dithering = method;
self
}
#[must_use]
pub const fn color_mode(mut self, mode: ColorMode) -> Self {
self.color_mode = mode;
self
}
#[must_use]
pub const fn threshold(mut self, value: u8) -> Self {
self.threshold = Some(value);
self
}
#[instrument(skip(self))]
#[allow(clippy::too_many_lines)]
pub fn render(&mut self) -> Result<BrailleGrid, DotmaxError> {
let img = self
.image
.as_ref()
.ok_or_else(|| DotmaxError::InvalidParameter {
parameter_name: "image".to_string(),
value: "None".to_string(),
min: "Must load image first".to_string(),
max: "loaded image".to_string(),
})?;
info!("Starting image rendering pipeline");
let (target_width_pixels, target_height_pixels) = self.calculate_target_dimensions();
debug!(
"Target dimensions: {}x{} pixels",
target_width_pixels, target_height_pixels
);
let dimensions_match = self.cached_dimensions.is_some_and(|(w, h)| {
w == target_width_pixels && h == target_height_pixels
});
let resized = if let Some(cached) = &self.cached_resized {
if dimensions_match {
debug!("Using cached resized image (fast path for parameter adjustments)");
cached.clone()
} else {
debug!(
"Dimensions changed from {:?} to {}x{}, invalidating cache and re-resizing",
self.cached_dimensions, target_width_pixels, target_height_pixels
);
let resized = match &self.resize_mode {
ResizeMode::AutoTerminal { preserve_aspect }
| ResizeMode::Manual {
preserve_aspect, ..
} => resize_to_dimensions(
img,
target_width_pixels,
target_height_pixels,
*preserve_aspect,
)?,
};
debug!(
"Image resized to {}x{}, caching for future renders",
resized.width(),
resized.height()
);
self.cached_resized = Some(resized.clone());
self.cached_original_resized = Some(resized.clone());
self.cached_dimensions = Some((target_width_pixels, target_height_pixels));
resized
}
} else {
debug!("Resizing image (no cache available)");
let resized = match &self.resize_mode {
ResizeMode::AutoTerminal { preserve_aspect }
| ResizeMode::Manual {
preserve_aspect, ..
} => resize_to_dimensions(
img,
target_width_pixels,
target_height_pixels,
*preserve_aspect,
)?,
};
debug!(
"Image resized to {}x{}, caching for future renders",
resized.width(),
resized.height()
);
self.cached_resized = Some(resized.clone());
self.cached_original_resized = Some(resized.clone());
self.cached_dimensions = Some((target_width_pixels, target_height_pixels));
resized
};
if self.color_mode != ColorMode::Monochrome {
info!("Using color rendering pipeline for {:?}", self.color_mode);
let cell_width = target_width_pixels as usize / 2;
let cell_height = target_height_pixels as usize / 4;
return render_image_with_color(
&resized,
self.color_mode,
cell_width,
cell_height,
self.dithering,
self.threshold,
self.brightness,
self.contrast,
self.gamma,
);
}
let mut gray = to_grayscale(&resized);
debug!("Converted to grayscale");
const EPSILON: f32 = 0.001;
if (self.brightness - 1.0).abs() > EPSILON {
gray = adjust_brightness(&gray, self.brightness)?;
debug!("Applied brightness adjustment: {}", self.brightness);
}
if (self.contrast - 1.0).abs() > EPSILON {
gray = adjust_contrast(&gray, self.contrast)?;
debug!("Applied contrast adjustment: {}", self.contrast);
}
if (self.gamma - 1.0).abs() > EPSILON {
gray = adjust_gamma(&gray, self.gamma)?;
debug!("Applied gamma adjustment: {}", self.gamma);
}
let binary = if self.dithering == DitheringMethod::None {
if let Some(threshold_value) = self.threshold {
debug!(
"Applying manual threshold (no dithering): {}",
threshold_value
);
apply_threshold(&gray, threshold_value)
} else {
debug!("Applying automatic Otsu thresholding (no dithering)");
let gray_dynamic = DynamicImage::ImageLuma8(gray);
auto_threshold(&gray_dynamic)
}
} else {
if let Some(threshold_value) = self.threshold {
debug!(
"Applying {:?} dithering with manual threshold: {}",
self.dithering, threshold_value
);
apply_dithering_with_custom_threshold(&gray, self.dithering, Some(threshold_value))?
} else {
debug!(
"Applying {:?} dithering with default threshold (127)",
self.dithering
);
apply_dithering(&gray, self.dithering)?
}
};
let cell_width = target_width_pixels as usize / 2;
let cell_height = target_height_pixels as usize / 4;
let grid = pixels_to_braille(&binary, cell_width, cell_height)?;
info!(
"Rendering complete: {}x{} braille cells",
cell_width, cell_height
);
Ok(grid)
}
#[allow(clippy::cast_possible_truncation)] fn calculate_target_dimensions(&self) -> (u32, u32) {
match &self.resize_mode {
ResizeMode::AutoTerminal { .. } => {
let (cols, rows) = detect_terminal_size();
(cols as u32 * 2, rows as u32 * 4)
}
ResizeMode::Manual { width, height, .. } => {
(*width as u32 * 2, *height as u32 * 4)
}
}
}
}
impl Default for ImageRenderer {
fn default() -> Self {
Self::new()
}
}
#[instrument]
pub fn render_image_simple(path: &Path) -> Result<BrailleGrid, DotmaxError> {
info!("Simple render from {:?}", path);
ImageRenderer::new()
.load_from_path(path)?
.resize_to_terminal()?
.render()
}
pub fn detect_terminal_size() -> (usize, usize) {
match crossterm::terminal::size() {
Ok((cols, rows)) => {
debug!("Detected terminal size: {}x{} cells", cols, rows);
(cols as usize, rows as usize)
}
Err(e) => {
debug!(
"Terminal size detection failed ({}), using default 80x24",
e
);
(80, 24)
}
}
}