use fontdue::Font;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CharacterSet {
#[default]
Density,
Blocks,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorMode {
#[default]
None,
Ansi256,
AnsiTruecolor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RenderMode {
pub characters: CharacterSet,
pub colors: ColorMode,
}
impl RenderMode {
pub const ASCII: Self = Self {
characters: CharacterSet::Density,
colors: ColorMode::None,
};
pub const ANSI256: Self = Self {
characters: CharacterSet::Blocks,
colors: ColorMode::Ansi256,
};
pub const ANSI_TRUECOLOR: Self = Self {
characters: CharacterSet::Blocks,
colors: ColorMode::AnsiTruecolor,
};
pub const fn new(characters: CharacterSet, colors: ColorMode) -> Self {
Self { characters, colors }
}
}
pub fn render_char(
font_path: impl AsRef<Path>,
codepoint: char,
width: usize,
height: usize,
) -> Result<Vec<String>, String> {
render_char_with_mode(font_path, codepoint, width, height, RenderMode::default())
}
pub fn render_char_with_mode(
font_path: impl AsRef<Path>,
codepoint: char,
width: usize,
height: usize,
mode: RenderMode,
) -> Result<Vec<String>, String> {
let font_data = std::fs::read(font_path).map_err(|e| format!("Failed to read font: {}", e))?;
let font = Font::from_bytes(font_data.as_slice(), fontdue::FontSettings::default())
.map_err(|e| format!("Failed to parse font: {}", e))?;
let font_size = (width.max(height) as f32) * 1.2;
let (metrics, bitmap) = font.rasterize(codepoint, font_size);
bitmap_to_ascii(&bitmap, metrics.width, metrics.height, width, height, mode)
}
fn bitmap_to_ascii(
bitmap: &[u8],
bitmap_width: usize,
bitmap_height: usize,
target_width: usize,
target_height: usize,
mode: RenderMode,
) -> Result<Vec<String>, String> {
if bitmap.is_empty() {
return Ok(vec![" ".repeat(target_width); target_height]);
}
const DENSITY_RAMP: &[char] = &[
' ', '.', '\'', '`', '^', '"', ',', ':', ';', 'I', 'l', '!', 'i', '>', '<', '~', '+', '_',
'-', '?', ']', '[', '}', '{', '1', ')', '(', '|', '\\', '/', 't', 'f', 'j', 'r', 'x', 'n',
'u', 'v', 'c', 'z', 'X', 'Y', 'U', 'J', 'C', 'L', 'Q', '0', 'O', 'Z', 'm', 'w', 'q', 'p',
'd', 'b', 'k', 'h', 'a', 'o', '*', '#', 'M', 'W', '&', '8', '%', 'B', '@', '$',
];
let mut result = Vec::with_capacity(target_height);
for row in 0..target_height {
let mut line = String::new();
for col in 0..target_width {
let bmp_x = (col * bitmap_width) / target_width.max(1);
let bmp_y = (row * bitmap_height) / target_height.max(1);
let idx = bmp_y * bitmap_width + bmp_x;
let pixel = if idx < bitmap.len() { bitmap[idx] } else { 0 };
const BLOCK_RAMP: &[char] = &[
' ', '░', '▒', '▓', '█', ];
let ch = match mode.characters {
CharacterSet::Density => {
let density_idx = ((pixel as usize) * (DENSITY_RAMP.len() - 1)) / 255;
DENSITY_RAMP[density_idx]
}
CharacterSet::Blocks => {
let block_idx = ((pixel as usize) * (BLOCK_RAMP.len() - 1)) / 255;
BLOCK_RAMP[block_idx]
}
};
match mode.colors {
ColorMode::None => {
line.push(ch);
}
ColorMode::Ansi256 => {
let gray_idx = if pixel == 0 {
232 } else {
235 + ((pixel as usize * 15) / 255)
};
line.push_str(&format!("\x1b[38;5;{}m{}\x1b[0m", gray_idx, ch));
}
ColorMode::AnsiTruecolor => {
let adjusted_brightness = if pixel == 0 {
0 } else {
60 + ((pixel as usize * 140) / 255)
};
line.push_str(&format!(
"\x1b[38;2;{};{};{}m{}\x1b[0m",
adjusted_brightness, adjusted_brightness, adjusted_brightness, ch
));
}
}
}
result.push(line);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bitmap_to_ascii_empty() {
let result = bitmap_to_ascii(&[], 0, 0, 5, 3, RenderMode::default()).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0], " ");
}
#[test]
fn test_bitmap_to_ascii_simple() {
let bitmap = vec![255, 0, 0, 255];
let result = bitmap_to_ascii(&bitmap, 2, 2, 2, 2, RenderMode::default()).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].chars().next().unwrap(), '$');
assert_eq!(result[0].chars().nth(1).unwrap(), ' ');
assert_eq!(result[1].chars().next().unwrap(), ' ');
assert_eq!(result[1].chars().nth(1).unwrap(), '$');
}
#[test]
fn test_render_hiragana_a() {
let font_path = "fonts/noto_sans_jp/NotoSansJP-VariableFont_wght.ttf";
if !std::path::Path::new(font_path).exists() {
eprintln!("Skipping test - font not found at {}", font_path);
return;
}
let result = render_char(font_path, 'あ', 20, 20);
assert!(result.is_ok(), "Failed to render: {:?}", result.err());
let lines = result.unwrap();
assert_eq!(lines.len(), 20, "Expected 20 lines of output");
for line in &lines {
assert_eq!(line.len(), 20, "Line width should be 20 chars");
}
let total_chars: String = lines.join("");
let non_space_count = total_chars.chars().filter(|c| *c != ' ').count();
assert!(
non_space_count > 0,
"Should have some visible character data"
);
println!("\nRendered 'あ' (hiragana a) at 20x20:");
for line in &lines {
println!("{}", line);
}
}
#[test]
fn test_render_katakana_ka() {
let font_path = "fonts/noto_sans_jp/NotoSansJP-VariableFont_wght.ttf";
if !std::path::Path::new(font_path).exists() {
eprintln!("Skipping test - font not found at {}", font_path);
return;
}
let result = render_char(font_path, 'カ', 15, 15);
assert!(result.is_ok());
let lines = result.unwrap();
assert_eq!(lines.len(), 15);
println!("\nRendered 'カ' (katakana ka) at 15x15:");
for line in &lines {
println!("{}", line);
}
}
}