1use std::{
72 collections::{hash_map::Entry, HashMap},
73 error::Error,
74 io,
75};
76
77use crossterm::style::{style, Color, Stylize};
78use image::{
79 imageops::{resize, FilterType},
80 DynamicImage, Rgb, RgbImage,
81};
82
83pub struct SymbolizeResult(pub Vec<Vec<String>>);
85
86impl Into<String> for SymbolizeResult {
87 fn into(self) -> String {
88 self.0
89 .iter()
90 .map(|row| row.join(""))
91 .collect::<Vec<String>>()
92 .join("\n")
93 }
94}
95
96impl Into<Vec<u8>> for SymbolizeResult {
97 fn into(self) -> Vec<u8> {
98 self.0
99 .iter()
100 .map(|row| row.join(""))
101 .collect::<Vec<String>>()
102 .join("\n")
103 .into_bytes()
104 }
105}
106
107impl Into<Vec<String>> for SymbolizeResult {
108 fn into(self) -> Vec<String> {
109 self.0
110 .iter()
111 .map(|row| row.join(""))
112 .collect::<Vec<String>>()
113 }
114}
115
116pub fn symbolize(
118 original_image: DynamicImage,
119 scale: f32,
120 palette: &[char],
121 filter_type: FilterType,
122 colorize: bool,
123) -> Result<SymbolizeResult, Box<dyn Error>> {
124 if palette.is_empty() {
125 return Err(Box::new(io::Error::new(
126 io::ErrorKind::InvalidInput,
127 "pallete should contain at leasst one symbol, aborting",
128 )));
129 }
130
131 if scale < 0.0 {
132 return Err(Box::new(io::Error::new(
133 io::ErrorKind::InvalidInput,
134 "scale should be > 0, aborting",
135 )));
136 }
137
138 let original_image_rgb = original_image.into_rgb8();
139 let scaled_image = resize(
140 &original_image_rgb,
141 (original_image_rgb.width() as f32 * scale) as u32,
142 (original_image_rgb.height() as f32 * scale) as u32,
143 filter_type,
144 );
145 let colors_to_use = get_most_used_colours_with_symbols(&scaled_image, palette);
146
147 let mut result = vec![];
148 for row in scaled_image.rows() {
149 let mut result_row = vec![];
150 for pixel in row {
151 let (symbol, average_pixel) = get_symbol_by_pixel(&colors_to_use, pixel)?;
152
153 let str_symbol = if colorize {
154 format!(
155 "{}",
156 style(symbol.to_string()).with(Color::from((
157 average_pixel.0[0],
158 average_pixel.0[1],
159 average_pixel.0[2]
160 )))
161 )
162 } else {
163 symbol.to_string()
164 };
165 result_row.push(str_symbol.clone());
166 result_row.push(str_symbol);
167 }
168
169 result.push(result_row)
170 }
171
172 Ok(SymbolizeResult(result))
173}
174
175#[derive(Debug)]
176struct PixelWithSymbol {
177 pixel: Rgb<u8>,
178 symbol: char,
179}
180
181impl PixelWithSymbol {
182 fn new(pixel: Rgb<u8>, symbol: char) -> Self {
183 Self { pixel, symbol }
184 }
185}
186
187fn get_most_used_colours_with_symbols(image: &RgbImage, symbols: &[char]) -> Vec<PixelWithSymbol> {
188 let mut colors_uses: HashMap<&image::Rgb<u8>, usize> = HashMap::new();
189 for pixel in image.pixels() {
190 match colors_uses.entry(pixel) {
191 Entry::Vacant(entry) => {
192 entry.insert(1);
193 }
194 Entry::Occupied(mut entry) => {
195 *entry.get_mut() += 1;
196 }
197 }
198 }
199 let mut colours_uses_vec: Vec<(&Rgb<u8>, usize)> = colors_uses.into_iter().collect();
200 colours_uses_vec.sort_by_key(|(_, count)| *count);
201
202 let (start, end) = (
203 colours_uses_vec
204 .len()
205 .checked_sub(symbols.len())
206 .unwrap_or(0),
207 colours_uses_vec.len(),
208 );
209
210 let mut symbol_idx = symbols.len() - 1;
211 colours_uses_vec
212 .drain(start..end)
213 .map(|(pixel, _)| {
214 let pixel_with_symbol = PixelWithSymbol::new(*pixel, symbols[symbol_idx]);
215 symbol_idx = symbol_idx.checked_sub(1).unwrap_or(0);
216
217 pixel_with_symbol
218 })
219 .collect()
220}
221
222fn get_symbol_by_pixel(
223 pixels_with_symbols: &[PixelWithSymbol],
224 pixel_to_compare: &Rgb<u8>,
225) -> Result<(char, Rgb<u8>), io::Error> {
226 let mut char = None;
227 let mut rgb_pixel = None;
228 let mut comparison = None;
229
230 for PixelWithSymbol { pixel, symbol } in pixels_with_symbols {
231 let pretendent_comparison = get_pixel_comparison(pixel_to_compare, pixel);
232 if comparison.is_none() || pretendent_comparison < comparison.unwrap() {
233 char = Some(*symbol);
234 comparison = Some(pretendent_comparison);
235 rgb_pixel = Some(*pixel);
236 }
237 }
238
239 if let (Some(char), Some(rgb_pixel)) = (char, rgb_pixel) {
240 return Ok((char, rgb_pixel));
241 }
242
243 Err(io::Error::new(
244 io::ErrorKind::Other,
245 "unexpected error, can't find matching char for a pixel. aborting",
246 ))
247}
248
249fn get_pixel_comparison(first: &Rgb<u8>, second: &Rgb<u8>) -> usize {
250 ((first.0[0] as i16 - second.0[0] as i16).abs()
251 + (first.0[1] as i16 - second.0[1] as i16).abs()
252 + (first.0[2] as i16 - second.0[2] as i16).abs()) as usize
253}
254
255#[cfg(test)]
256mod tests {
257 use image::{imageops::FilterType, open};
258
259 use crate::symbolize;
260
261 fn get_ferris() -> Vec<&'static str> {
262 vec![
263 " ",
264 " ",
265 " ",
266 " @@ @@@@ @@ ",
267 " @@ @@@@@@@@@@@@@@@@@@ ",
268 " @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
269 " @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@ ",
270 " @@ @@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@ ",
271 " @@ @@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@ @@@@",
272 "@@@@ @@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@ ",
273 " @@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@ ",
274 " @@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
275 " @@@@ @@@@@@@@@@@@@@@@@@&& @@@@&&&& @@@@@@@@@@@@@@ @@ ",
276 " @@@@@@@@@@@@@@@@@@@@@@&& @@@@ @@@@@@@@@@@@@@@@@@ ",
277 " @@@@@@@@@@@@@@@@@@@@ @@@@ @@@@@@@@@@@@@@@@@@ ",
278 " @@@@@@$$@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@$$$$@@@@@@ ",
279 " @@@@$$ $$$$@@@@@@@@@@@@@@ @@@@@@@@@@$$$$ $$@@@@ ",
280 " @@ $$ $$$$$$$$$$$$$$$$$$$$$$ $$ @@ ",
281 " @@ @@ ",
282 " @@ ",
283 " ",
284 " ",
285 " ",
286 " ",
287 ]
288 }
289
290 fn get_colorized_ferris() -> &'static str {
291 "\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\n\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\n\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\n\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\n\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\n\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;165;43;0m&\u{1b}[39m\u{1b}[38;2;165;43;0m&\u{1b}[39m\u{1b}[38;2;165;43;0m&\u{1b}[39m\u{1b}[38;2;165;43;0m&\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;165;43;0m&\u{1b}[39m\u{1b}[38;2;165;43;0m&\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;247;76;0m$\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\n\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\n\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m\u{1b}[38;2;0;0;0m@\u{1b}[39m"
292 }
293
294 #[test]
295 fn renders_ferris_as_vec_of_strings() {
296 let image = open("./test-data/ferris.png").unwrap();
297 let result: Vec<String> = symbolize(
298 image,
299 0.03,
300 &vec![' ', '@', '$', '&'],
301 FilterType::Nearest,
302 false,
303 )
304 .unwrap()
305 .into();
306
307 assert_eq!(result, get_ferris());
308 }
309
310 #[test]
311 fn renders_colorized_ferris_as_string() {
312 let image = open("./test-data/ferris.png").unwrap();
313 let result: String = symbolize(
314 image,
315 0.01,
316 &vec![' ', '@', '$', '&'],
317 FilterType::Nearest,
318 true,
319 )
320 .unwrap()
321 .into();
322
323 assert_eq!(result, get_colorized_ferris());
324 }
325
326 #[test]
327 fn renders_ferris_as_string() {
328 let image = open("./test-data/ferris.png").unwrap();
329 let result: String = symbolize(
330 image,
331 0.03,
332 &vec![' ', '@', '$', '&'],
333 FilterType::Nearest,
334 false,
335 )
336 .unwrap()
337 .into();
338
339 assert_eq!(result, get_ferris().join("\n"));
340 }
341
342 #[test]
343 fn renders_ferris_as_byteslice() {
344 let image = open("./test-data/ferris.png").unwrap();
345 let result: Vec<u8> = symbolize(
346 image,
347 0.03,
348 &vec![' ', '@', '$', '&'],
349 FilterType::Nearest,
350 false,
351 )
352 .unwrap()
353 .into();
354
355 assert_eq!(result, get_ferris().join("\n").into_bytes());
356 }
357
358 #[test]
359 fn returns_error_if_no_palette_passed() {
360 let image = open("./test-data/ferris.png").unwrap();
361 let result = symbolize(image, 0.03, &vec![], FilterType::Nearest, false);
362
363 assert!(result.is_err());
364 assert_eq!(
365 result.err().unwrap().to_string(),
366 "pallete should contain at leasst one symbol, aborting"
367 );
368 }
369
370 #[test]
371 fn returns_error_if_scale_less_than_zero() {
372 let image = open("./test-data/ferris.png").unwrap();
373 let result = symbolize(image, -0.03, &vec![' '], FilterType::Nearest, false);
374
375 assert!(result.is_err());
376 assert_eq!(
377 result.err().unwrap().to_string(),
378 "scale should be > 0, aborting"
379 );
380 }
381}