use std::{
hash::{Hash, Hasher},
path::PathBuf,
sync::{Mutex, OnceLock},
};
use crossterm::style::Color;
use image::imageops::FilterType;
use indexmap::IndexMap;
use super::{
canvas::TermLayout,
cell::{Block, FontStyle},
};
use crate::resources;
#[derive(Debug, thiserror::Error)]
pub enum ImageError {
#[error("image load failed for {path}: {message}")]
Load {
path: String,
message: String,
},
#[error("image decode failed for {path}: {source}")]
Decode {
path: String,
#[source]
source: image::ImageError,
},
#[error("svg rasterize failed for {path}: {source}")]
SvgRasterize {
path: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("Image cache entry vanished for {path}")]
CacheEntryVanished {
path: String,
},
#[error("Image data empty: {path}")]
DataEmpty {
path: String,
},
}
pub const IMAGE_PIXELS_PER_CELL_COL: u32 = 1;
pub const IMAGE_PIXELS_PER_CELL_ROW: u32 = 2;
const MAX_CACHED_IMAGES: usize = 16;
fn resize_filter(src: (u32, u32), dst: (u32, u32)) -> FilterType {
if src.0 > dst.0 || src.1 > dst.1 {
FilterType::Triangle
} else {
FilterType::Nearest
}
}
#[derive(Debug)]
pub struct Img {
pub path: PathBuf,
pub term_size: (u32, u32),
pub picture: Option<Vec<Block>>,
pub columns: u32,
pub rows: u32,
}
impl Img {
pub fn new(path: PathBuf, term_size: (u32, u32)) -> Self {
Self {
path,
term_size,
picture: None,
columns: 0,
rows: 0,
}
}
pub fn _create_img(&mut self) -> Result<(), ImageError> {
if self.picture.is_some() {
return Ok(());
}
let src = self.path.to_string_lossy().into_owned();
let resource = resources::load(&src).map_err(|e| ImageError::Load {
path: src.clone(),
message: e.to_string(),
})?;
let (width, height, rgba) = decode_rgba(&src, &resource)?;
let img = image::RgbaImage::from_raw(width, height, rgba)
.ok_or_else(|| ImageError::DataEmpty { path: src.clone() })?;
let pixel_w = self
.term_size
.0
.saturating_mul(IMAGE_PIXELS_PER_CELL_COL)
.max(IMAGE_PIXELS_PER_CELL_COL);
let pixel_h = self
.term_size
.1
.saturating_mul(IMAGE_PIXELS_PER_CELL_ROW)
.max(IMAGE_PIXELS_PER_CELL_ROW);
let source = (img.width(), img.height());
let img = image::imageops::resize(
&img,
pixel_w,
pixel_h,
resize_filter(source, (pixel_w, pixel_h)),
);
let (width, height) = img.dimensions();
self.columns = width.div_ceil(IMAGE_PIXELS_PER_CELL_COL);
self.rows = height.div_ceil(IMAGE_PIXELS_PER_CELL_ROW);
let mut picture = Vec::with_capacity((self.columns * self.rows) as usize);
for y in (0..height).step_by(IMAGE_PIXELS_PER_CELL_ROW as usize) {
for x in (0..width).step_by(IMAGE_PIXELS_PER_CELL_COL as usize) {
let top = img.get_pixel(x, y);
let bottom = img.get_pixel(x, y + 1);
let fg = Color::Rgb {
r: top[0],
g: top[1],
b: top[2],
};
let bg = Color::Rgb {
r: bottom[0],
g: bottom[1],
b: bottom[2],
};
let mut word = String::new();
word.push('\u{2580}');
picture.push(Block::new(word, Some(fg), Some(bg), FontStyle::empty()));
}
}
self.picture = Some(picture);
Ok(())
}
}
fn decode_rgba(path: &str, resource: &[u8]) -> Result<(u32, u32, Vec<u8>), ImageError> {
const MAX_IMAGE_DIM: u32 = 16384;
const MAX_IMAGE_ALLOC: u64 = 256 * 1024 * 1024;
let decode_err = |source: image::ImageError| ImageError::Decode {
path: path.to_string(),
source,
};
let mut reader = image::ImageReader::new(std::io::Cursor::new(resource))
.with_guessed_format()
.map_err(|e| decode_err(image::ImageError::IoError(e)))?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_DIM);
limits.max_image_height = Some(MAX_IMAGE_DIM);
limits.max_alloc = Some(MAX_IMAGE_ALLOC);
reader.limits(limits);
match reader.decode() {
Ok(img) => {
let rgba = img.to_rgba8();
Ok((rgba.width(), rgba.height(), rgba.into_raw()))
}
Err(raster_err) => match rasterize_svg(resource) {
Ok((width, height, data)) => Ok((width, height, data)),
Err(svg_err) => {
if looks_like_svg(resource) {
Err(ImageError::SvgRasterize {
path: path.to_string(),
source: svg_err,
})
} else {
Err(decode_err(raster_err))
}
}
},
}
}
fn looks_like_svg(bytes: &[u8]) -> bool {
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(1024)]);
let head = head.trim_start_matches('\u{feff}').trim_start();
head.starts_with("<svg") || head.starts_with("<?xml") || head.starts_with("<!DOCTYPE")
}
fn svg_options() -> &'static resvg::usvg::Options<'static> {
static OPTIONS: OnceLock<resvg::usvg::Options<'static>> = OnceLock::new();
OPTIONS.get_or_init(|| {
let mut opt = resvg::usvg::Options::default();
opt.fontdb_mut().load_system_fonts();
opt
})
}
fn rasterize_svg(
bytes: &[u8],
) -> Result<(u32, u32, Vec<u8>), Box<dyn std::error::Error + Send + Sync>> {
use resvg::tiny_skia::Pixmap;
use resvg::usvg::{Transform, Tree};
let tree = Tree::from_data(bytes, svg_options())?;
let size = tree.size();
let width = size.width().ceil() as u32;
let height = size.height().ceil() as u32;
let mut pixmap = Pixmap::new(width, height).ok_or_else(|| {
Box::<dyn std::error::Error + Send + Sync>::from(
"svg rasterization failed: pixmap allocation too large",
)
})?;
resvg::render(&tree, Transform::default(), &mut pixmap.as_mut());
Ok((width, height, pixmap.take_demultiplied()))
}
fn svg_intrinsic_size(bytes: &[u8]) -> Option<(u32, u32)> {
let tree = resvg::usvg::Tree::from_data(bytes, svg_options()).ok()?;
let size = tree.size();
Some((size.width().ceil() as u32, size.height().ceil() as u32))
}
#[derive(Debug, Clone, Copy)]
pub struct ClipRect {
pub x: usize,
pub y: usize,
pub width: usize,
pub height: usize,
}
static INTRINSIC_SIZES: OnceLock<Mutex<IndexMap<u64, (u32, u32)>>> = OnceLock::new();
const MAX_INTRINSIC_SIZE_ENTRIES: usize = 64;
pub(crate) fn image_intrinsic_size(path: &str) -> Option<(u32, u32)> {
let resource = resources::load(path).ok()?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
resource.as_ref().hash(&mut hasher);
let key = hasher.finish();
let cache = INTRINSIC_SIZES.get_or_init(|| Mutex::new(IndexMap::new()));
let mut map = cache.lock().ok()?;
if let Some(&size) = map.get(&key) {
return Some(size);
}
let size = image::ImageReader::new(std::io::Cursor::new(resource.as_ref()))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
.or_else(|| svg_intrinsic_size(&resource));
if let Some(size) = size {
if map.len() >= MAX_INTRINSIC_SIZE_ENTRIES {
map.shift_remove_index(0);
}
map.insert(key, size);
}
size
}
#[derive(Debug)]
pub struct ImageCache {
entries: IndexMap<(String, u32, u32), Img>,
}
impl Default for ImageCache {
fn default() -> Self {
Self::new()
}
}
impl ImageCache {
pub fn new() -> Self {
Self {
entries: IndexMap::new(),
}
}
pub fn get_or_decode(&mut self, path: &str, size: (u32, u32)) -> Result<&Img, ImageError> {
let key = (path.to_string(), size.0, size.1);
if !self.entries.contains_key(&key) {
if self.entries.len() >= MAX_CACHED_IMAGES {
self.entries.shift_remove_index(0);
}
let mut img = Img::new(PathBuf::from(path), size);
img._create_img()?;
self.entries.insert(key.clone(), img);
}
self.entries
.get(&key)
.ok_or_else(|| ImageError::CacheEntryVanished {
path: path.to_string(),
})
}
}
pub fn render_image_at(
term_layout: &mut TermLayout,
path: &str,
origin: (usize, usize),
max_size: (u32, u32),
) -> Result<(), ImageError> {
let columns = term_layout.columns;
let mut cache = ImageCache::new();
render_image_at_slice(
&mut cache,
&mut term_layout.layout,
columns,
path,
origin,
max_size,
None,
)
}
pub fn render_image_at_slice(
cache: &mut ImageCache,
layout: &mut [Block],
canvas_cols: u16,
path: &str,
origin: (usize, usize),
max_size: (u32, u32),
clip: Option<ClipRect>,
) -> Result<(), ImageError> {
let (x, y) = origin;
let img_data = cache.get_or_decode(path, max_size)?;
let picture = img_data
.picture
.as_ref()
.ok_or_else(|| ImageError::DataEmpty {
path: path.to_string(),
})?;
let img_cols = img_data.columns as usize;
let img_rows = img_data.rows as usize;
let canvas_cols = canvas_cols as usize;
let max = layout.len();
let (clip_x, clip_y, clip_w, clip_h) = match clip {
Some(c) => (c.x, c.y, c.width, c.height),
None => (0, 0, usize::MAX, usize::MAX),
};
for row in 0..img_rows {
let target_row = y + row;
if target_row.saturating_mul(canvas_cols) >= max {
break;
}
if target_row < clip_y || target_row >= clip_y.saturating_add(clip_h) {
continue;
}
for col in 0..img_cols {
let cx = x + col;
if cx >= canvas_cols {
break;
}
if cx < clip_x || cx >= clip_x.saturating_add(clip_w) {
continue;
}
let canvas_idx = target_row * canvas_cols + cx;
let pic_idx = row * img_cols + col;
let Some(src) = picture.get(pic_idx) else {
continue;
};
let dst = &mut layout[canvas_idx];
dst.word.clear();
dst.word.push_str(&src.word);
dst.fcolor = src.fcolor;
dst.bcolor = src.bcolor;
dst.style = src.style;
}
}
Ok(())
}