use crate::error::{FigletError, StrictTarget};
use crate::filter::{Color, FilterChain, NamedColor, RenderGrid};
use crate::{Figlet, FigletBuilder};
pub fn strict_render(
input: &str,
chain: &FilterChain,
target: StrictTarget,
) -> Result<Vec<u8>, FigletError> {
if !matches!(target, StrictTarget::Toilet031) {
return Err(FigletError::StrictCompatViolation {
mode: target,
detail: format!(
"only Toilet031 is implemented as a byte-equal target; received {target:?}"
),
});
}
let figlet = FigletBuilder::new().build()?;
let grid = render_to_grid(&figlet, input)?;
let grid = chain.apply(grid)?;
let grid = enforce_16color_floor(grid);
Ok(serialize_toilet_bytes(&grid))
}
fn render_to_grid(figlet: &Figlet, input: &str) -> Result<RenderGrid, FigletError> {
let banner = figlet.render(input)?;
let rows: Vec<String> = banner.lines().collect();
Ok(RenderGrid::from_text_rows(&rows))
}
fn enforce_16color_floor(mut grid: RenderGrid) -> RenderGrid {
for row in grid.cells.iter_mut() {
for cell in row.iter_mut() {
cell.fg = Color::Named(downgrade_to_16color(cell.fg));
if let Some(bg) = cell.bg {
cell.bg = Some(Color::Named(downgrade_to_16color(bg)));
}
}
}
grid
}
pub fn downgrade_to_16color(color: Color) -> NamedColor {
match color {
Color::Named(n) => n,
Color::Index(idx) => index_to_named(idx),
Color::Rgb(r, g, b) => rgb_to_named(r, g, b),
}
}
fn index_to_named(idx: u8) -> NamedColor {
match idx {
0 => NamedColor::Black,
1 => NamedColor::Red,
2 => NamedColor::Green,
3 => NamedColor::Yellow,
4 => NamedColor::Blue,
5 => NamedColor::Magenta,
6 => NamedColor::Cyan,
7 => NamedColor::White,
8 => NamedColor::BrightBlack,
9 => NamedColor::BrightRed,
10 => NamedColor::BrightGreen,
11 => NamedColor::BrightYellow,
12 => NamedColor::BrightBlue,
13 => NamedColor::BrightMagenta,
14 => NamedColor::BrightCyan,
15 => NamedColor::BrightWhite,
_ => NamedColor::White,
}
}
fn rgb_to_named(r: u8, g: u8, b: u8) -> NamedColor {
let max_channel = r.max(g).max(b);
let bright = max_channel == 255;
let spread = max_channel - r.min(g).min(b);
if spread < 16 {
return if max_channel < 64 {
if bright {
NamedColor::BrightBlack
} else {
NamedColor::Black
}
} else if max_channel >= 192 {
if bright {
NamedColor::BrightWhite
} else {
NamedColor::White
}
} else {
NamedColor::White
};
}
let near_max = |c: u8| max_channel - c < 64;
let r_top = near_max(r);
let g_top = near_max(g);
let b_top = near_max(b);
match (r_top, g_top, b_top) {
(true, true, false) => {
if bright {
NamedColor::BrightYellow
} else {
NamedColor::Yellow
}
}
(true, false, true) => {
if bright {
NamedColor::BrightMagenta
} else {
NamedColor::Magenta
}
}
(false, true, true) => {
if bright {
NamedColor::BrightCyan
} else {
NamedColor::Cyan
}
}
(true, false, false) => {
if bright {
NamedColor::BrightRed
} else {
NamedColor::Red
}
}
(false, true, false) => {
if bright {
NamedColor::BrightGreen
} else {
NamedColor::Green
}
}
(false, false, true) => {
if bright {
NamedColor::BrightBlue
} else {
NamedColor::Blue
}
}
_ => NamedColor::White,
}
}
fn named_to_sgr_fg(n: NamedColor) -> u8 {
match n {
NamedColor::Black => 30,
NamedColor::Red => 31,
NamedColor::Green => 32,
NamedColor::Yellow => 33,
NamedColor::Blue => 34,
NamedColor::Magenta => 35,
NamedColor::Cyan => 36,
NamedColor::White => 37,
NamedColor::BrightBlack => 90,
NamedColor::BrightRed => 91,
NamedColor::BrightGreen => 92,
NamedColor::BrightYellow => 93,
NamedColor::BrightBlue => 94,
NamedColor::BrightMagenta => 95,
NamedColor::BrightCyan => 96,
NamedColor::BrightWhite => 97,
}
}
fn serialize_toilet_bytes(grid: &RenderGrid) -> Vec<u8> {
let w = grid.width as usize;
let h = grid.height as usize;
if w == 0 || h == 0 {
return Vec::new();
}
let capacity = w.saturating_mul(h).saturating_mul(10).saturating_add(64);
let mut out: Vec<u8> = Vec::with_capacity(capacity);
let any_color = grid
.cells
.iter()
.any(|row| row.iter().any(|c| c.fg != Color::Named(NamedColor::White)));
if !any_color {
for row in &grid.cells {
for cell in row {
push_utf8(&mut out, cell.ch);
}
out.push(b'\n');
}
return out;
}
for row in &grid.cells {
let mut prev_fg: Option<NamedColor> = None;
for cell in row {
let fg = match cell.fg {
Color::Named(n) => n,
_ => NamedColor::White,
};
if prev_fg != Some(fg) {
out.extend_from_slice(b"\x1b[");
push_decimal(&mut out, named_to_sgr_fg(fg));
out.push(b'm');
prev_fg = Some(fg);
}
push_utf8(&mut out, cell.ch);
}
out.extend_from_slice(b"\x1b[0m\n");
}
out
}
fn push_decimal(out: &mut Vec<u8>, n: u8) {
if n >= 100 {
out.push((n / 100) + b'0');
out.push(((n / 10) % 10) + b'0');
out.push((n % 10) + b'0');
} else if n >= 10 {
out.push((n / 10) + b'0');
out.push((n % 10) + b'0');
} else {
out.push(n + b'0');
}
}
fn push_utf8(out: &mut Vec<u8>, c: char) {
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
out.extend_from_slice(s.as_bytes());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filter::Filter;
#[test]
fn unmappable_input_returns_strict_compat_violation() {
let chain = FilterChain::new();
let err = strict_render("hi", &chain, StrictTarget::Figlet225)
.expect_err("Figlet225 is not implemented as a strict-render target");
match err {
FigletError::StrictCompatViolation { mode, detail } => {
assert_eq!(
mode,
StrictTarget::Figlet225,
"mode must echo the offending target"
);
assert!(
!detail.is_empty(),
"detail string must explain why the input is unmappable"
);
}
other => panic!("expected StrictCompatViolation, got {other:?}"),
}
}
#[test]
fn downgrade_named_passes_through() {
for n in [
NamedColor::Black,
NamedColor::Red,
NamedColor::BrightCyan,
NamedColor::White,
] {
assert_eq!(downgrade_to_16color(Color::Named(n)), n);
}
}
#[test]
fn downgrade_index_uses_ansi_positions() {
assert_eq!(downgrade_to_16color(Color::Index(0)), NamedColor::Black);
assert_eq!(downgrade_to_16color(Color::Index(1)), NamedColor::Red);
assert_eq!(downgrade_to_16color(Color::Index(7)), NamedColor::White);
assert_eq!(downgrade_to_16color(Color::Index(9)), NamedColor::BrightRed);
assert_eq!(
downgrade_to_16color(Color::Index(15)),
NamedColor::BrightWhite
);
assert_eq!(downgrade_to_16color(Color::Index(196)), NamedColor::White);
}
#[test]
fn downgrade_rgb_pure_red_is_bright_red() {
assert_eq!(
downgrade_to_16color(Color::Rgb(255, 0, 0)),
NamedColor::BrightRed
);
}
#[test]
fn downgrade_rgb_dark_red_is_red() {
assert_eq!(downgrade_to_16color(Color::Rgb(128, 0, 0)), NamedColor::Red);
}
#[test]
fn downgrade_rgb_pure_yellow_is_bright_yellow() {
assert_eq!(
downgrade_to_16color(Color::Rgb(255, 255, 0)),
NamedColor::BrightYellow
);
}
#[test]
fn downgrade_rgb_black_neutral() {
assert_eq!(downgrade_to_16color(Color::Rgb(0, 0, 0)), NamedColor::Black);
}
#[test]
fn downgrade_rgb_white_neutral() {
assert_eq!(
downgrade_to_16color(Color::Rgb(255, 255, 255)),
NamedColor::BrightWhite
);
}
#[test]
fn strict_render_empty_chain_returns_uncolored_output() {
let chain = FilterChain::new();
let bytes = strict_render("hi", &chain, StrictTarget::Toilet031)
.expect("empty chain on standard input must succeed");
assert!(
!bytes.windows(2).any(|w| w == [0x1b, b'[']),
"uncolored output must contain no SGR escape sequences"
);
assert!(
bytes.iter().any(|&b| b.is_ascii_graphic()),
"output must contain rendered glyphs"
);
}
#[test]
fn strict_render_with_color_emits_16color_floor_sgr() {
let chain = FilterChain::new().push(Filter::Gay);
let bytes = strict_render("hi", &chain, StrictTarget::Toilet031)
.expect("gay filter on `hi` must succeed");
assert!(
bytes.windows(2).any(|w| w == [0x1b, b'[']),
"colored output must contain SGR escape sequences"
);
assert!(
!bytes.windows(5).any(|w| w == b"\x1b[38;"),
"16-color floor MUST NOT emit 38;2 (truecolor) or 38;5 (256) escapes"
);
}
}