use crate::color::ColorTransform;
use crate::dither::{DitherMethod, DitherOptions};
use iascii::config::{ConfigBuilder as AsciiConfigBuilder, OutputSizing};
use iascii::grid::Grid;
use iascii::render::ColorDepth;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn image_bytes_to_string(
bytes: &[u8],
max_width: usize,
color: bool,
) -> Result<String, JsError> {
let grid = decode_and_convert(bytes, max_width, None)?;
let depth = color.then_some(ColorDepth::TrueColor);
Ok(crate::render::grid_to_string(&grid, depth))
}
#[wasm_bindgen]
pub fn image_bytes_to_lines(
bytes: &[u8],
max_width: usize,
color: bool,
) -> Result<Vec<JsValue>, JsError> {
let grid = decode_and_convert(bytes, max_width, None)?;
let depth = color.then_some(ColorDepth::TrueColor);
Ok(strings_to_js(crate::render::grid_to_lines(&grid, depth)))
}
#[wasm_bindgen]
pub fn gif_bytes_to_frame_strings(
bytes: &[u8],
max_width: usize,
color: bool,
) -> Result<Vec<JsValue>, JsError> {
use ::image::{codecs::gif::GifDecoder, AnimationDecoder};
let decoder =
GifDecoder::new(std::io::Cursor::new(bytes)).map_err(|e| JsError::new(&e.to_string()))?;
let depth = color.then_some(ColorDepth::TrueColor);
let ascii_config = sized_ascii_config(max_width, None)?;
let mut out = Vec::new();
for frame in decoder.into_frames() {
let frame = frame.map_err(|e| JsError::new(&e.to_string()))?;
let (numer, denom) = frame.delay().numer_denom_ms();
let delay_ms = numer as u64 / denom.max(1) as u64;
let dynamic = ::image::DynamicImage::ImageRgba8(frame.into_buffer());
let rgb = dynamic.to_rgb8();
let grid =
iascii::convert::convert_image(rgb.width(), rgb.height(), rgb.as_raw(), &ascii_config)
.map_err(|e| JsError::new(&e.to_string()))?;
let text = crate::render::grid_to_string(&grid, depth);
out.push(JsValue::from_str(&format!("{delay_ms}\u{1e}{text}")));
}
Ok(out)
}
#[wasm_bindgen]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorDepthKind {
Plain,
Ansi16,
Ansi256,
TrueColor,
}
impl ColorDepthKind {
fn to_iascii(self) -> Option<ColorDepth> {
match self {
ColorDepthKind::Plain => None,
ColorDepthKind::Ansi16 => Some(ColorDepth::Ansi16),
ColorDepthKind::Ansi256 => Some(ColorDepth::Ansi256),
ColorDepthKind::TrueColor => Some(ColorDepth::TrueColor),
}
}
}
#[wasm_bindgen]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorTransformKind {
None,
Grayscale,
Invert,
Sepia,
Brightness,
Contrast,
Saturate,
HueRotate,
}
fn build_transform(kind: ColorTransformKind, amount: f32) -> ColorTransform {
match kind {
ColorTransformKind::None => ColorTransform::None,
ColorTransformKind::Grayscale => ColorTransform::Grayscale,
ColorTransformKind::Invert => ColorTransform::Invert,
ColorTransformKind::Sepia => ColorTransform::Sepia,
ColorTransformKind::Brightness => ColorTransform::Brightness(amount),
ColorTransformKind::Contrast => ColorTransform::Contrast(amount),
ColorTransformKind::Saturate => ColorTransform::Saturate(amount),
ColorTransformKind::HueRotate => ColorTransform::HueRotate(amount),
}
}
fn render_grid_ex(
grid: &Grid,
depth: ColorDepthKind,
dither: DitherMethod,
dither_levels: u8,
transform: ColorTransformKind,
transform_amount: f32,
) -> String {
let Some(color_depth) = depth.to_iascii() else {
return crate::render::grid_to_string(grid, None);
};
let options = DitherOptions::new(dither).levels(dither_levels);
if matches!(transform, ColorTransformKind::None) {
crate::dither::render_ansi_dithered(grid, color_depth, options)
} else {
let built = build_transform(transform, transform_amount);
crate::dither::render_ansi_dithered_transformed(grid, color_depth, options, &built)
}
}
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn image_bytes_to_string_ex(
bytes: &[u8],
max_width: usize,
depth: ColorDepthKind,
dither: DitherMethod,
dither_levels: u8,
transform: ColorTransformKind,
transform_amount: f32,
) -> Result<String, JsError> {
let grid = decode_and_convert(bytes, max_width, None)?;
Ok(render_grid_ex(
&grid,
depth,
dither,
dither_levels,
transform,
transform_amount,
))
}
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn image_bytes_to_lines_ex(
bytes: &[u8],
max_width: usize,
depth: ColorDepthKind,
dither: DitherMethod,
dither_levels: u8,
transform: ColorTransformKind,
transform_amount: f32,
) -> Result<Vec<JsValue>, JsError> {
let grid = decode_and_convert(bytes, max_width, None)?;
let text = render_grid_ex(
&grid,
depth,
dither,
dither_levels,
transform,
transform_amount,
);
Ok(strings_to_js(text.lines().map(str::to_string).collect()))
}
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn image_bytes_to_string_with_script(
bytes: &[u8],
max_width: usize,
script: crate::script::Script,
dark: bool,
depth: ColorDepthKind,
dither: DitherMethod,
dither_levels: u8,
transform: ColorTransformKind,
transform_amount: f32,
) -> Result<String, JsError> {
let ramp_type = script
.ramp_type(dark)
.map_err(|e| JsError::new(&e.to_string()))?;
let grid = decode_and_convert(bytes, max_width, Some(ramp_type))?;
Ok(render_grid_ex(
&grid,
depth,
dither,
dither_levels,
transform,
transform_amount,
))
}
#[wasm_bindgen]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnimationKind {
Rainbow,
Wave,
Pulse,
Blink,
Plasma,
Ripple,
Gradient,
}
#[allow(clippy::too_many_arguments)]
fn build_animation(
kind: AnimationKind,
speed: f32,
scale: f32,
color1: (u8, u8, u8),
color2: (u8, u8, u8),
horizontal: bool,
) -> crate::colorize::PixelAnimation {
use crate::colorize::PixelAnimation;
use crate::rgb::Rgb;
let c1 = Rgb::new(color1.0, color1.1, color1.2);
let c2 = Rgb::new(color2.0, color2.1, color2.2);
match kind {
AnimationKind::Rainbow => PixelAnimation::Rainbow { speed },
AnimationKind::Wave => PixelAnimation::Wave { base_color: c1 },
AnimationKind::Pulse => PixelAnimation::Pulse { base_color: c1 },
AnimationKind::Blink => PixelAnimation::Blink {
on_color: c1,
off_color: c2,
},
AnimationKind::Plasma => PixelAnimation::Plasma { scale, speed },
AnimationKind::Ripple => PixelAnimation::Ripple {
base_color: c1,
speed,
},
AnimationKind::Gradient => PixelAnimation::Gradient {
from: c1,
to: c2,
horizontal,
},
}
}
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn colorize_plain_text(
plain_text: &str,
kind: AnimationKind,
frame: u64,
speed: f32,
scale: f32,
color1: u32,
color2: u32,
horizontal: bool,
transform: ColorTransformKind,
transform_amount: f32,
) -> String {
let animation = build_animation(
kind,
speed,
scale,
unpack_rgb(color1),
unpack_rgb(color2),
horizontal,
);
let animation = if matches!(transform, ColorTransformKind::None) {
animation
} else {
crate::colorize::PixelAnimation::Transformed {
base: Box::new(animation),
transform: build_transform(transform, transform_amount),
}
};
let lines: Vec<&str> = plain_text.lines().collect();
crate::colorize::colorize_lines(&lines, &animation, frame)
}
fn unpack_rgb(packed: u32) -> (u8, u8, u8) {
(
((packed >> 16) & 0xFF) as u8,
((packed >> 8) & 0xFF) as u8,
(packed & 0xFF) as u8,
)
}
#[wasm_bindgen]
pub fn banner_lines_wasm(text: &str) -> Vec<JsValue> {
strings_to_js(crate::banner::banner_lines(text))
}
#[wasm_bindgen]
pub fn banner_frame_at(text: &str, animation: TextAnimationKind, frame_index: u32) -> Vec<JsValue> {
use crate::text::TextAnimOptions;
let anim = text_animation_kind_to_animation(animation);
let opts = TextAnimOptions {
frames: frame_index as u64 + 1,
..Default::default()
};
let frame = crate::banner::banner_frames(text, &anim, &opts)
.ok()
.and_then(|frames| frames.into_iter().last())
.unwrap_or_default();
strings_to_js(frame)
}
#[wasm_bindgen]
pub fn text_frame_at(text: &str, animation: TextAnimationKind, frame_index: u32) -> String {
use crate::text::TextAnimOptions;
let anim = text_animation_kind_to_animation(animation);
let opts = TextAnimOptions {
frames: frame_index as u64 + 1,
..Default::default()
};
crate::text::text_frames(text, &anim, &opts)
.ok()
.and_then(|frames| frames.into_iter().last())
.unwrap_or_default()
}
#[wasm_bindgen]
#[derive(Debug, Clone, Copy)]
pub enum TextAnimationKind {
Typewriter,
Rainbow,
Wave,
Blink,
Pulse,
}
fn text_animation_kind_to_animation(kind: TextAnimationKind) -> crate::text::TextAnimation {
use crate::rgb::Rgb;
use crate::text::TextAnimation;
match kind {
TextAnimationKind::Typewriter => TextAnimation::Typewriter,
TextAnimationKind::Rainbow => TextAnimation::Rainbow,
TextAnimationKind::Wave => TextAnimation::Wave,
TextAnimationKind::Pulse => TextAnimation::Pulse,
TextAnimationKind::Blink => TextAnimation::Blink {
on_color: Rgb::WHITE,
off_color: Rgb::new(0, 0, 0),
},
}
}
#[wasm_bindgen]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IllusionKind {
CafeWall,
HermannGrid,
TwistedCord,
}
fn build_illusion(kind: IllusionKind, param_a: u32, param_b: u32) -> crate::illusions::Illusion {
use crate::illusions::Illusion;
match kind {
IllusionKind::CafeWall => Illusion::CafeWall {
tile: param_a,
offset: param_b,
},
IllusionKind::HermannGrid => Illusion::HermannGrid {
cell: param_a,
line_width: param_b,
},
IllusionKind::TwistedCord => Illusion::TwistedCord { lines: param_a },
}
}
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn illusion_to_string(
kind: IllusionKind,
param_a: u32,
param_b: u32,
src_width: u32,
src_height: u32,
max_width: usize,
depth: ColorDepthKind,
dither: DitherMethod,
dither_levels: u8,
transform: ColorTransformKind,
transform_amount: f32,
) -> Result<String, JsError> {
let illusion = build_illusion(kind, param_a, param_b);
let img = crate::illusions::generate(illusion, src_width, src_height);
let ascii_config = sized_ascii_config(max_width, None)?;
let grid =
iascii::convert::convert_image(img.width(), img.height(), img.as_raw(), &ascii_config)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(render_grid_ex(
&grid,
depth,
dither,
dither_levels,
transform,
transform_amount,
))
}
#[wasm_bindgen]
pub fn rotating_rings_to_lines(
width: usize,
height: usize,
rings: u32,
speed: f32,
frame_count: u64,
fill_char: char,
) -> Vec<JsValue> {
strings_to_js(crate::illusions::rotating_rings_frames(
width,
height,
rings,
speed,
frame_count,
fill_char,
))
}
fn strings_to_js(lines: Vec<String>) -> Vec<JsValue> {
lines
.into_iter()
.map(|line| JsValue::from_str(&line))
.collect()
}
fn sized_ascii_config(
max_width: usize,
ramp: Option<iascii::ramp::RampType>,
) -> Result<iascii::config::Config, JsError> {
let mut builder = AsciiConfigBuilder::new();
if max_width > 0 {
builder = builder.output_sizing(OutputSizing::MaxWidth(max_width));
}
if let Some(ramp) = ramp {
builder = builder.ramp(ramp);
}
builder.build().map_err(|e| JsError::new(&e.to_string()))
}
fn decode_and_convert(
bytes: &[u8],
max_width: usize,
ramp: Option<iascii::ramp::RampType>,
) -> Result<Grid, JsError> {
let img = ::image::load_from_memory(bytes).map_err(|e| JsError::new(&e.to_string()))?;
let rgb = img.to_rgb8();
let ascii_config = sized_ascii_config(max_width, ramp)?;
iascii::convert::convert_image(rgb.width(), rgb.height(), rgb.as_raw(), &ascii_config)
.map_err(|e| JsError::new(&e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
trait UnwrapJs<T> {
fn unwrap_js(self) -> T;
}
impl<T> UnwrapJs<T> for Result<T, JsError> {
fn unwrap_js(self) -> T {
match self {
Ok(v) => v,
Err(_) => panic!("expected Ok, got a JsError"),
}
}
}
fn tiny_png_bytes() -> Vec<u8> {
let img = ::image::RgbImage::from_fn(4, 4, |x, y| {
if (x + y) % 2 == 0 {
::image::Rgb([200u8, 40, 40])
} else {
::image::Rgb([40u8, 40, 200])
}
});
let mut buf = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut buf),
::image::ImageFormat::Png,
)
.unwrap();
buf
}
#[test]
fn image_bytes_to_string_plain_has_no_escapes() {
let bytes = tiny_png_bytes();
let out = image_bytes_to_string(&bytes, 4, false).unwrap_js();
assert!(!out.contains('\x1b'));
assert!(!out.is_empty());
}
#[test]
fn image_bytes_to_string_color_has_truecolor_escapes() {
let bytes = tiny_png_bytes();
let out = image_bytes_to_string(&bytes, 4, true).unwrap_js();
assert!(out.contains("\x1b[38;2;"));
}
#[test]
fn image_bytes_to_string_ex_plain_ignores_dither_and_transform() {
let bytes = tiny_png_bytes();
let plain = image_bytes_to_string_ex(
&bytes,
4,
ColorDepthKind::Plain,
DitherMethod::FloydSteinberg,
6,
ColorTransformKind::Invert,
0.0,
)
.unwrap_js();
assert!(!plain.contains('\x1b'));
}
#[test]
fn image_bytes_to_string_ex_covers_every_depth() {
let bytes = tiny_png_bytes();
for depth in [
ColorDepthKind::Ansi16,
ColorDepthKind::Ansi256,
ColorDepthKind::TrueColor,
] {
let out = image_bytes_to_string_ex(
&bytes,
4,
depth,
DitherMethod::None,
6,
ColorTransformKind::None,
0.0,
)
.unwrap_js();
assert!(
out.contains('\x1b'),
"{depth:?} should produce ANSI escapes"
);
}
}
#[test]
fn image_bytes_to_string_ex_with_grayscale_transform_has_no_pure_source_red() {
let bytes = tiny_png_bytes();
let out = image_bytes_to_string_ex(
&bytes,
4,
ColorDepthKind::TrueColor,
DitherMethod::None,
6,
ColorTransformKind::Grayscale,
0.0,
)
.unwrap_js();
assert!(!out.contains("38;2;200;40;40"));
}
#[cfg_attr(not(target_arch = "wasm32"), ignore = "JsValue only works on wasm32")]
#[test]
fn image_bytes_to_lines_ex_matches_line_count_of_the_string_form() {
let bytes = tiny_png_bytes();
let text = image_bytes_to_string_ex(
&bytes,
4,
ColorDepthKind::TrueColor,
DitherMethod::FloydSteinberg,
6,
ColorTransformKind::None,
0.0,
)
.unwrap_js();
let lines = image_bytes_to_lines_ex(
&bytes,
4,
ColorDepthKind::TrueColor,
DitherMethod::FloydSteinberg,
6,
ColorTransformKind::None,
0.0,
)
.unwrap_js();
assert_eq!(lines.len(), text.lines().count());
}
#[test]
fn image_bytes_to_string_with_script_covers_every_script() {
let bytes = tiny_png_bytes();
for script in [
crate::script::Script::Latin,
crate::script::Script::Block,
crate::script::Script::Braille,
crate::script::Script::Cyrillic,
crate::script::Script::Cjk,
] {
let out = image_bytes_to_string_with_script(
&bytes,
4,
script,
true,
ColorDepthKind::Plain,
DitherMethod::None,
6,
ColorTransformKind::None,
0.0,
)
.unwrap_js();
assert!(!out.is_empty(), "{script:?}");
}
}
#[cfg_attr(not(target_arch = "wasm32"), ignore = "JsValue only works on wasm32")]
#[test]
fn gif_bytes_to_frame_strings_returns_one_entry_per_frame() {
use ::image::codecs::gif::{GifEncoder, Repeat};
use ::image::{Delay, Frame, RgbaImage};
let mut buf = Vec::new();
{
let mut encoder = GifEncoder::new(&mut buf);
encoder.set_repeat(Repeat::Infinite).unwrap();
for i in 0..3u8 {
let level = i * 60;
let frame_buf =
RgbaImage::from_pixel(4, 4, ::image::Rgba([level, level, level, 255]));
encoder
.encode_frame(Frame::from_parts(
frame_buf,
0,
0,
Delay::from_numer_denom_ms(30, 1),
))
.unwrap();
}
}
let frames = gif_bytes_to_frame_strings(&buf, 4, true).unwrap_js();
assert_eq!(frames.len(), 3);
}
#[test]
fn colorize_plain_text_produces_ansi_output_for_every_animation_kind() {
let plain = "####\n####\n####\n####";
for kind in [
AnimationKind::Rainbow,
AnimationKind::Wave,
AnimationKind::Pulse,
AnimationKind::Blink,
AnimationKind::Plasma,
AnimationKind::Ripple,
AnimationKind::Gradient,
] {
let out = colorize_plain_text(
plain,
kind,
2,
5.0,
0.3,
0xFF0000,
0x0000FF,
true,
ColorTransformKind::None,
0.0,
);
assert!(out.contains("\x1b[38;2;"), "{kind:?}");
assert_eq!(out.lines().count(), 4);
}
}
#[test]
fn colorize_plain_text_with_transform_still_colors_every_line() {
let plain = "##\n##";
let out = colorize_plain_text(
plain,
AnimationKind::Rainbow,
0,
5.0,
0.0,
0xFFFFFF,
0x000000,
false,
ColorTransformKind::Sepia,
0.0,
);
assert_eq!(out.lines().count(), 2);
assert!(out.contains("\x1b[38;2;"));
}
#[test]
fn unpack_rgb_splits_a_packed_color_correctly() {
assert_eq!(unpack_rgb(0xFF8000), (255, 128, 0));
assert_eq!(unpack_rgb(0x000000), (0, 0, 0));
assert_eq!(unpack_rgb(0xFFFFFF), (255, 255, 255));
}
#[cfg_attr(not(target_arch = "wasm32"), ignore = "JsValue only works on wasm32")]
#[test]
fn banner_lines_wasm_returns_the_expected_row_count() {
let lines = banner_lines_wasm("HI");
assert_eq!(lines.len(), crate::banner::banner_lines("HI").len());
}
#[cfg_attr(not(target_arch = "wasm32"), ignore = "JsValue only works on wasm32")]
#[test]
fn banner_frame_at_returns_one_row_per_glyph_row() {
let frame = banner_frame_at("HI", TextAnimationKind::Rainbow, 0);
assert_eq!(frame.len(), crate::banner::banner_lines("HI").len());
}
#[test]
fn text_frame_at_covers_every_animation_kind_without_panicking() {
for kind in [
TextAnimationKind::Typewriter,
TextAnimationKind::Rainbow,
TextAnimationKind::Wave,
TextAnimationKind::Blink,
TextAnimationKind::Pulse,
] {
let frame = text_frame_at("hi", kind, 1);
assert!(!frame.is_empty(), "{kind:?}");
}
}
#[test]
fn illusion_to_string_covers_every_illusion_kind() {
for kind in [
IllusionKind::CafeWall,
IllusionKind::HermannGrid,
IllusionKind::TwistedCord,
] {
let out = illusion_to_string(
kind,
8,
4,
32,
16,
20,
ColorDepthKind::Ansi256,
DitherMethod::Bayer4,
6,
ColorTransformKind::None,
0.0,
)
.unwrap_js();
assert!(!out.is_empty(), "{kind:?}");
assert!(out.contains('\x1b'));
}
}
#[test]
fn illusion_to_string_plain_depth_has_no_escapes() {
let out = illusion_to_string(
IllusionKind::HermannGrid,
10,
2,
20,
20,
15,
ColorDepthKind::Plain,
DitherMethod::None,
6,
ColorTransformKind::None,
0.0,
)
.unwrap_js();
assert!(!out.contains('\x1b'));
}
#[cfg_attr(not(target_arch = "wasm32"), ignore = "JsValue only works on wasm32")]
#[test]
fn rotating_rings_to_lines_returns_the_requested_frame_count() {
let frames = rotating_rings_to_lines(10, 6, 3, 0.5, 4, '#');
assert_eq!(frames.len(), 4);
}
#[test]
fn color_depth_kind_maps_every_variant() {
assert_eq!(ColorDepthKind::Plain.to_iascii(), None);
assert_eq!(ColorDepthKind::Ansi16.to_iascii(), Some(ColorDepth::Ansi16));
assert_eq!(
ColorDepthKind::Ansi256.to_iascii(),
Some(ColorDepth::Ansi256)
);
assert_eq!(
ColorDepthKind::TrueColor.to_iascii(),
Some(ColorDepth::TrueColor)
);
}
#[test]
fn build_transform_maps_every_kind() {
assert_eq!(
build_transform(ColorTransformKind::None, 0.0),
ColorTransform::None
);
assert_eq!(
build_transform(ColorTransformKind::Grayscale, 0.0),
ColorTransform::Grayscale
);
assert_eq!(
build_transform(ColorTransformKind::Invert, 0.0),
ColorTransform::Invert
);
assert_eq!(
build_transform(ColorTransformKind::Sepia, 0.0),
ColorTransform::Sepia
);
assert_eq!(
build_transform(ColorTransformKind::Brightness, 2.0),
ColorTransform::Brightness(2.0)
);
assert_eq!(
build_transform(ColorTransformKind::Contrast, 0.5),
ColorTransform::Contrast(0.5)
);
assert_eq!(
build_transform(ColorTransformKind::Saturate, 1.5),
ColorTransform::Saturate(1.5)
);
assert_eq!(
build_transform(ColorTransformKind::HueRotate, 90.0),
ColorTransform::HueRotate(90.0)
);
}
}