use crate::error::FigletError;
const MAX_FILTER_NAME_BYTES: usize = 64;
const FILTER_NAMES: &[&str] = &[
"crop",
"gay",
"metal",
"flip",
"flop",
"rotate180",
"rotateleft",
"rotateright",
"border",
"nothing",
];
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Color {
Named(NamedColor),
Index(u8),
Rgb(u8, u8, u8),
}
impl Default for Color {
fn default() -> Self {
Self::Named(NamedColor::White)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[allow(missing_docs)]
pub enum NamedColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
BrightBlack,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Cell {
pub ch: char,
pub fg: Color,
pub bg: Option<Color>,
pub attrs: u8,
}
impl Cell {
#[must_use]
pub fn new(ch: char) -> Self {
Self {
ch,
fg: Color::default(),
bg: None,
attrs: 0,
}
}
#[must_use]
pub fn blank() -> Self {
Self::new(' ')
}
#[must_use]
pub fn is_blank(&self) -> bool {
self.ch == ' '
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderGrid {
pub cells: Vec<Vec<Cell>>,
pub width: u32,
pub height: u32,
}
impl RenderGrid {
#[must_use]
pub fn empty() -> Self {
Self {
cells: Vec::new(),
width: 0,
height: 0,
}
}
#[must_use]
pub fn blank(width: u32, height: u32) -> Self {
let w = width as usize;
let h = height as usize;
let cells = (0..h).map(|_| vec![Cell::blank(); w]).collect();
Self {
cells,
width,
height,
}
}
#[must_use]
pub fn from_rows(mut rows: Vec<Vec<Cell>>) -> Self {
let width = rows.iter().map(Vec::len).max().unwrap_or(0);
for row in rows.iter_mut() {
if row.len() < width {
row.resize(width, Cell::blank());
}
}
let height = rows.len();
Self {
cells: rows,
width: width as u32,
height: height as u32,
}
}
#[must_use]
pub fn from_text_rows(rows: &[String]) -> Self {
let cells: Vec<Vec<Cell>> = rows
.iter()
.map(|line| line.chars().map(Cell::new).collect())
.collect();
Self::from_rows(cells)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Filter {
Crop,
Gay,
Metal,
Flip,
Flop,
Rotate180,
RotateLeft,
RotateRight,
Border,
Nothing,
}
impl Filter {
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Filter::Crop => "crop",
Filter::Gay => "gay",
Filter::Metal => "metal",
Filter::Flip => "flip",
Filter::Flop => "flop",
Filter::Rotate180 => "rotate180",
Filter::RotateLeft => "rotateleft",
Filter::RotateRight => "rotateright",
Filter::Border => "border",
Filter::Nothing => "nothing",
}
}
fn from_name(name: &str) -> Option<Filter> {
Some(match name {
"crop" => Filter::Crop,
"gay" => Filter::Gay,
"metal" => Filter::Metal,
"flip" => Filter::Flip,
"flop" => Filter::Flop,
"rotate180" => Filter::Rotate180,
"rotateleft" => Filter::RotateLeft,
"rotateright" => Filter::RotateRight,
"border" => Filter::Border,
"nothing" => Filter::Nothing,
_ => return None,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FilterChain {
filters: Vec<Filter>,
}
impl FilterChain {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn push(mut self, filter: Filter) -> Self {
self.filters.push(filter);
self
}
pub fn parse(spec: &str) -> Result<FilterChain, FigletError> {
let mut filters = Vec::new();
if spec.is_empty() {
return Ok(Self { filters });
}
for segment in spec.split(':') {
if segment.is_empty() || segment.len() > MAX_FILTER_NAME_BYTES {
return Err(FigletError::UnknownFilter {
name: segment.to_owned(),
available: FILTER_NAMES.iter().map(|&s| s.to_owned()).collect(),
});
}
match Filter::from_name(segment) {
Some(f) => filters.push(f),
None => {
return Err(FigletError::UnknownFilter {
name: segment.to_owned(),
available: FILTER_NAMES.iter().map(|&s| s.to_owned()).collect(),
});
}
}
}
Ok(Self { filters })
}
#[must_use]
pub fn len(&self) -> usize {
self.filters.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.filters.is_empty()
}
#[must_use]
pub fn filters(&self) -> &[Filter] {
&self.filters
}
pub fn apply(&self, grid: RenderGrid) -> Result<RenderGrid, FigletError> {
let mut current = grid;
for filter in &self.filters {
current = dispatch(*filter, current)?;
}
Ok(current)
}
}
fn dispatch(filter: Filter, grid: RenderGrid) -> Result<RenderGrid, FigletError> {
match filter {
Filter::Nothing => Ok(apply_nothing(grid)),
#[cfg(feature = "filter-crop")]
Filter::Crop => Ok(apply_crop(grid)),
#[cfg(not(feature = "filter-crop"))]
Filter::Crop => Err(filter_disabled("crop")),
#[cfg(feature = "filter-gay")]
Filter::Gay => Ok(apply_gay(grid)),
#[cfg(not(feature = "filter-gay"))]
Filter::Gay => Err(filter_disabled("gay")),
#[cfg(feature = "filter-metal")]
Filter::Metal => Ok(apply_metal(grid)),
#[cfg(not(feature = "filter-metal"))]
Filter::Metal => Err(filter_disabled("metal")),
#[cfg(feature = "filter-flip")]
Filter::Flip => Ok(apply_flip(grid)),
#[cfg(not(feature = "filter-flip"))]
Filter::Flip => Err(filter_disabled("flip")),
#[cfg(feature = "filter-flop")]
Filter::Flop => Ok(apply_flop(grid)),
#[cfg(not(feature = "filter-flop"))]
Filter::Flop => Err(filter_disabled("flop")),
#[cfg(feature = "filter-rotate")]
Filter::Rotate180 => Ok(apply_rotate180(grid)),
#[cfg(not(feature = "filter-rotate"))]
Filter::Rotate180 => Err(filter_disabled("rotate180")),
#[cfg(feature = "filter-rotate")]
Filter::RotateLeft => Ok(apply_rotate_left(grid)),
#[cfg(not(feature = "filter-rotate"))]
Filter::RotateLeft => Err(filter_disabled("rotateleft")),
#[cfg(feature = "filter-rotate")]
Filter::RotateRight => Ok(apply_rotate_right(grid)),
#[cfg(not(feature = "filter-rotate"))]
Filter::RotateRight => Err(filter_disabled("rotateright")),
#[cfg(feature = "filter-border")]
Filter::Border => Ok(apply_border(grid)),
#[cfg(not(feature = "filter-border"))]
Filter::Border => Err(filter_disabled("border")),
}
}
#[allow(dead_code)]
fn filter_disabled(name: &str) -> FigletError {
FigletError::UnknownFilter {
name: name.to_owned(),
available: FILTER_NAMES.iter().map(|&s| s.to_owned()).collect(),
}
}
fn apply_nothing(grid: RenderGrid) -> RenderGrid {
grid
}
#[cfg(feature = "filter-crop")]
fn apply_crop(grid: RenderGrid) -> RenderGrid {
let h = grid.cells.len();
if h == 0 || grid.cells[0].is_empty() {
return RenderGrid::empty();
}
let w = grid.cells[0].len();
let mut top = h;
let mut bottom = 0usize;
let mut left = w;
let mut right = 0usize;
for (y, row) in grid.cells.iter().enumerate() {
for (x, cell) in row.iter().enumerate() {
if !cell.is_blank() {
if y < top {
top = y;
}
if y > bottom {
bottom = y;
}
if x < left {
left = x;
}
if x > right {
right = x;
}
}
}
}
if top == h {
return RenderGrid::empty();
}
let new_h = bottom - top + 1;
let new_w = right - left + 1;
let mut cells: Vec<Vec<Cell>> = Vec::with_capacity(new_h);
for row in grid.cells.iter().skip(top).take(new_h) {
cells.push(row[left..=right].to_vec());
}
RenderGrid {
cells,
width: new_w as u32,
height: new_h as u32,
}
}
#[cfg(feature = "filter-gay")]
fn apply_gay(grid: RenderGrid) -> RenderGrid {
let w = grid.width.max(1);
let mut cells = grid.cells;
for row in cells.iter_mut() {
for (x, cell) in row.iter_mut().enumerate() {
let hue = 360.0_f32 * (x as f32 / w as f32);
let (r, g, b) = hsv_to_rgb(hue, 1.0, 1.0);
cell.fg = Color::Rgb(r, g, b);
}
}
RenderGrid {
cells,
width: grid.width,
height: grid.height,
}
}
#[cfg(feature = "filter-metal")]
fn apply_metal(grid: RenderGrid) -> RenderGrid {
const PALETTE: [NamedColor; 4] = [
NamedColor::Cyan,
NamedColor::Blue,
NamedColor::BrightCyan,
NamedColor::BrightBlue,
];
let mut cells = grid.cells;
for (y, row) in cells.iter_mut().enumerate() {
let c = PALETTE[y % PALETTE.len()];
for cell in row.iter_mut() {
cell.fg = Color::Named(c);
}
}
RenderGrid {
cells,
width: grid.width,
height: grid.height,
}
}
#[cfg(feature = "filter-flip")]
fn apply_flip(grid: RenderGrid) -> RenderGrid {
let mut cells = grid.cells;
for row in cells.iter_mut() {
row.reverse();
}
RenderGrid {
cells,
width: grid.width,
height: grid.height,
}
}
#[cfg(feature = "filter-flop")]
fn apply_flop(grid: RenderGrid) -> RenderGrid {
let mut cells = grid.cells;
cells.reverse();
RenderGrid {
cells,
width: grid.width,
height: grid.height,
}
}
#[cfg(feature = "filter-rotate")]
fn apply_rotate180(grid: RenderGrid) -> RenderGrid {
let mut cells = grid.cells;
cells.reverse();
for row in cells.iter_mut() {
row.reverse();
}
RenderGrid {
cells,
width: grid.width,
height: grid.height,
}
}
#[cfg(feature = "filter-rotate")]
fn apply_rotate_left(grid: RenderGrid) -> RenderGrid {
let w = grid.width as usize;
let h = grid.height as usize;
if w == 0 || h == 0 {
return RenderGrid::empty();
}
let mut new_cells: Vec<Vec<Cell>> = (0..w).map(|_| Vec::with_capacity(h)).collect();
for x in (0..w).rev() {
let row: Vec<Cell> = (0..h).map(|y| grid.cells[y][x]).collect();
new_cells[w - 1 - x] = row;
}
RenderGrid {
cells: new_cells,
width: h as u32,
height: w as u32,
}
}
#[cfg(feature = "filter-rotate")]
fn apply_rotate_right(grid: RenderGrid) -> RenderGrid {
let w = grid.width as usize;
let h = grid.height as usize;
if w == 0 || h == 0 {
return RenderGrid::empty();
}
let mut new_cells: Vec<Vec<Cell>> = (0..w).map(|_| Vec::with_capacity(h)).collect();
for (x, row_out) in new_cells.iter_mut().enumerate().take(w) {
*row_out = (0..h).rev().map(|y| grid.cells[y][x]).collect();
}
RenderGrid {
cells: new_cells,
width: h as u32,
height: w as u32,
}
}
#[cfg(feature = "filter-border")]
fn apply_border(grid: RenderGrid) -> RenderGrid {
let w = grid.width as usize;
let h = grid.height as usize;
let new_w = w + 2;
let new_h = h + 2;
let mut cells: Vec<Vec<Cell>> = Vec::with_capacity(new_h);
let mut top = Vec::with_capacity(new_w);
top.push(Cell::new('┌'));
for _ in 0..w {
top.push(Cell::new('─'));
}
top.push(Cell::new('┐'));
cells.push(top);
for row in grid.cells {
let mut new_row = Vec::with_capacity(new_w);
new_row.push(Cell::new('│'));
new_row.extend(row);
new_row.push(Cell::new('│'));
cells.push(new_row);
}
let mut bottom = Vec::with_capacity(new_w);
bottom.push(Cell::new('└'));
for _ in 0..w {
bottom.push(Cell::new('─'));
}
bottom.push(Cell::new('┘'));
cells.push(bottom);
RenderGrid {
cells,
width: new_w as u32,
height: new_h as u32,
}
}
#[cfg(feature = "filter-gay")]
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let c = v * s;
let h_p = (h % 360.0) / 60.0;
let x = c * (1.0 - (h_p % 2.0 - 1.0).abs());
let m = v - c;
let (r1, g1, b1) = match h_p as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let to_u8 = |f: f32| ((f + m) * 255.0).round().clamp(0.0, 255.0) as u8;
(to_u8(r1), to_u8(g1), to_u8(b1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cell_footprint_is_bounded() {
assert!(
std::mem::size_of::<Cell>() <= 24,
"Cell size {} exceeds AD-011 budget",
std::mem::size_of::<Cell>()
);
}
#[test]
fn parse_empty_chain_is_ok() {
let c = FilterChain::parse("").unwrap();
assert!(c.is_empty());
}
#[test]
fn parse_single_filter() {
let c = FilterChain::parse("crop").unwrap();
assert_eq!(c.filters(), &[Filter::Crop]);
}
#[test]
fn parse_multi_filter_chain() {
let c = FilterChain::parse("crop:flip:border").unwrap();
assert_eq!(c.filters(), &[Filter::Crop, Filter::Flip, Filter::Border]);
}
#[test]
fn parse_empty_segment_is_unknown_filter() {
let err = FilterChain::parse("crop::flip").unwrap_err();
match err {
FigletError::UnknownFilter { name, available } => {
assert_eq!(name, "");
assert_eq!(available.len(), 10);
}
other => panic!("expected UnknownFilter, got {other:?}"),
}
}
#[test]
fn parse_unknown_name_lists_available() {
let err = FilterChain::parse("nosuchfilter").unwrap_err();
match err {
FigletError::UnknownFilter { name, available } => {
assert_eq!(name, "nosuchfilter");
assert!(available.contains(&"crop".to_string()));
assert!(available.contains(&"nothing".to_string()));
}
other => panic!("expected UnknownFilter, got {other:?}"),
}
}
#[test]
fn parse_oversize_name_rejected() {
let big = "a".repeat(MAX_FILTER_NAME_BYTES + 1);
let err = FilterChain::parse(&big).unwrap_err();
assert!(matches!(err, FigletError::UnknownFilter { .. }));
}
#[test]
fn programmatic_push_matches_parse() {
let manual = FilterChain::new().push(Filter::Crop).push(Filter::Flip);
let parsed = FilterChain::parse("crop:flip").unwrap();
assert_eq!(manual, parsed);
}
#[test]
fn empty_chain_apply_is_identity() {
let g = RenderGrid::blank(3, 2);
let chain = FilterChain::new();
let out = chain.apply(g.clone()).unwrap();
assert_eq!(out, g);
}
#[test]
fn nothing_filter_is_identity() {
let g = RenderGrid::blank(3, 2);
let chain = FilterChain::new().push(Filter::Nothing);
let out = chain.apply(g.clone()).unwrap();
assert_eq!(out, g);
}
#[cfg(feature = "filter-crop")]
#[test]
fn crop_trims_blank_border() {
let mut rows = vec![vec![Cell::blank(); 4]; 4];
rows[1][1] = Cell::new('X');
rows[1][2] = Cell::new('Y');
rows[2][1] = Cell::new('Z');
let grid = RenderGrid::from_rows(rows);
let chain = FilterChain::new().push(Filter::Crop);
let out = chain.apply(grid).unwrap();
assert_eq!(out.width, 2);
assert_eq!(out.height, 2);
assert_eq!(out.cells[0][0].ch, 'X');
assert_eq!(out.cells[1][0].ch, 'Z');
}
#[cfg(feature = "filter-crop")]
#[test]
fn crop_all_blank_returns_empty() {
let grid = RenderGrid::blank(4, 4);
let out = FilterChain::new().push(Filter::Crop).apply(grid).unwrap();
assert_eq!(out.width, 0);
assert_eq!(out.height, 0);
}
#[cfg(feature = "filter-flip")]
#[test]
fn flip_reverses_each_row() {
let grid = RenderGrid::from_text_rows(&[String::from("ABCD"), String::from("1234")]);
let out = FilterChain::new().push(Filter::Flip).apply(grid).unwrap();
assert_eq!(out.cells[0][0].ch, 'D');
assert_eq!(out.cells[0][3].ch, 'A');
assert_eq!(out.cells[1][0].ch, '4');
}
#[cfg(feature = "filter-flop")]
#[test]
fn flop_reverses_row_order() {
let grid = RenderGrid::from_text_rows(&[String::from("AAA"), String::from("BBB")]);
let out = FilterChain::new().push(Filter::Flop).apply(grid).unwrap();
assert_eq!(out.cells[0][0].ch, 'B');
assert_eq!(out.cells[1][0].ch, 'A');
}
#[cfg(feature = "filter-rotate")]
#[test]
fn rotate180_inverts() {
let grid = RenderGrid::from_text_rows(&[String::from("AB"), String::from("CD")]);
let out = FilterChain::new()
.push(Filter::Rotate180)
.apply(grid)
.unwrap();
assert_eq!(out.cells[0][0].ch, 'D');
assert_eq!(out.cells[0][1].ch, 'C');
assert_eq!(out.cells[1][0].ch, 'B');
assert_eq!(out.cells[1][1].ch, 'A');
}
#[cfg(feature = "filter-rotate")]
#[test]
fn rotate_left_swaps_dimensions() {
let grid = RenderGrid::from_text_rows(&[String::from("ABC"), String::from("DEF")]);
let out = FilterChain::new()
.push(Filter::RotateLeft)
.apply(grid)
.unwrap();
assert_eq!(out.width, 2);
assert_eq!(out.height, 3);
assert_eq!(out.cells[0][0].ch, 'C');
assert_eq!(out.cells[0][1].ch, 'F');
assert_eq!(out.cells[2][0].ch, 'A');
assert_eq!(out.cells[2][1].ch, 'D');
}
#[cfg(feature = "filter-rotate")]
#[test]
fn rotate_right_swaps_dimensions() {
let grid = RenderGrid::from_text_rows(&[String::from("ABC"), String::from("DEF")]);
let out = FilterChain::new()
.push(Filter::RotateRight)
.apply(grid)
.unwrap();
assert_eq!(out.width, 2);
assert_eq!(out.height, 3);
assert_eq!(out.cells[0][0].ch, 'D');
assert_eq!(out.cells[0][1].ch, 'A');
assert_eq!(out.cells[2][0].ch, 'F');
assert_eq!(out.cells[2][1].ch, 'C');
}
#[cfg(feature = "filter-border")]
#[test]
fn border_adds_one_cell_of_padding() {
let grid = RenderGrid::from_text_rows(&[String::from("XX")]);
let out = FilterChain::new().push(Filter::Border).apply(grid).unwrap();
assert_eq!(out.width, 4);
assert_eq!(out.height, 3);
assert_eq!(out.cells[0][0].ch, '┌');
assert_eq!(out.cells[0][3].ch, '┐');
assert_eq!(out.cells[2][0].ch, '└');
assert_eq!(out.cells[2][3].ch, '┘');
assert_eq!(out.cells[1][1].ch, 'X');
}
#[cfg(feature = "filter-gay")]
#[test]
fn gay_assigns_rgb_per_column() {
let grid = RenderGrid::from_text_rows(&[String::from("ABCD")]);
let out = FilterChain::new().push(Filter::Gay).apply(grid).unwrap();
let c0 = out.cells[0][0].fg;
let c1 = out.cells[0][1].fg;
assert!(matches!(c0, Color::Rgb(..)));
assert!(matches!(c1, Color::Rgb(..)));
assert_ne!(c0, c1);
}
#[cfg(feature = "filter-metal")]
#[test]
fn metal_cycles_palette_per_row() {
let grid = RenderGrid::from_text_rows(&[
String::from("A"),
String::from("B"),
String::from("C"),
String::from("D"),
String::from("E"),
]);
let out = FilterChain::new().push(Filter::Metal).apply(grid).unwrap();
assert_eq!(out.cells[0][0].fg, out.cells[4][0].fg);
assert_ne!(out.cells[0][0].fg, out.cells[1][0].fg);
}
#[cfg(all(feature = "filter-flip", feature = "filter-gay"))]
#[test]
fn chain_order_observable_gay_then_flip() {
let grid = RenderGrid::from_text_rows(&[String::from("ABCD")]);
let a = FilterChain::new()
.push(Filter::Gay)
.push(Filter::Flip)
.apply(grid.clone())
.unwrap();
let b = FilterChain::new()
.push(Filter::Flip)
.push(Filter::Gay)
.apply(grid)
.unwrap();
assert_ne!(a.cells[0][0].fg, b.cells[0][0].fg);
}
}