use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::error::FigletError;
use crate::header;
const REQUIRED_CODEPOINTS_ASCII: std::ops::RangeInclusive<u32> = 32..=126;
const REQUIRED_CODEPOINTS_GERMAN: &[u32] = &[196, 214, 220, 228, 246, 252, 223];
#[derive(Debug, Clone)]
pub struct FIGfont {
pub hardblank: char,
pub height: u32,
pub baseline: u32,
pub max_length: u32,
pub old_layout: i32,
pub full_layout: u32,
pub print_direction: u32,
pub glyphs: HashMap<u32, Vec<String>>,
pub codetag_count: u32,
}
pub fn parse_bytes(input: &[u8]) -> Result<FIGfont, FigletError> {
let text: String = input.iter().map(|&b| b as char).collect();
let mut lines = text.split('\n');
let header_line = lines
.next()
.ok_or_else(|| parse_err("empty input", 1))?
.trim_end_matches('\r');
let header = parse_header(header_line, 1)?;
let mut current_line: u32 = 1;
for _ in 0..header.comment_lines {
current_line += 1;
if lines.next().is_none() {
return Err(parse_err(
"truncated comment block: comment_lines header value exceeds available lines",
current_line,
));
}
}
let mut glyphs: HashMap<u32, Vec<String>> = HashMap::new();
let mut endmark: Option<char> = None;
for cp in REQUIRED_CODEPOINTS_ASCII.clone() {
let rows = read_glyph(&mut lines, header.height, &mut current_line, &mut endmark)?;
glyphs.insert(cp, rows);
}
let mut buffered: Option<String> = None;
{
let peek_line_no = current_line + 1;
let peeked = next_non_empty(&mut lines, &mut current_line);
if let Some(line) = peeked {
if looks_like_codetag_header(&line) {
buffered = Some(line);
let _ = peek_line_no;
} else {
let mut rows = Vec::with_capacity(header.height as usize);
let stripped =
strip_endmark(&line, header.height == 1, &mut endmark, current_line)?;
rows.push(stripped);
for row in 1..header.height {
current_line += 1;
let raw = lines
.next()
.ok_or_else(|| {
parse_err("short glyph block: hit EOF mid-glyph", current_line)
})?
.trim_end_matches('\r');
let stripped =
strip_endmark(raw, row == header.height - 1, &mut endmark, current_line)?;
rows.push(stripped);
}
glyphs.insert(REQUIRED_CODEPOINTS_GERMAN[0], rows);
for &cp in &REQUIRED_CODEPOINTS_GERMAN[1..] {
let rows =
read_glyph(&mut lines, header.height, &mut current_line, &mut endmark)?;
glyphs.insert(cp, rows);
}
}
}
}
let mut actual_codetag = 0u32;
loop {
let header_text = if let Some(b) = buffered.take() {
b
} else {
match next_non_empty(&mut lines, &mut current_line) {
Some(line) => line,
None => {
if header.codetag_count != 0 && actual_codetag != header.codetag_count {
return Err(parse_err(
&format!(
"codetag_count divergence: header declared {}, parsed {}",
header.codetag_count, actual_codetag
),
current_line,
));
}
for &cp in REQUIRED_CODEPOINTS_GERMAN {
if !glyphs.contains_key(&cp) {
return Err(parse_err(
&format!("missing required German codepoint U+{cp:04X}"),
current_line,
));
}
}
return Ok(FIGfont {
hardblank: header.hardblank,
height: header.height,
baseline: header.baseline,
max_length: header.max_length,
old_layout: header.old_layout,
full_layout: header.full_layout,
print_direction: header.print_direction,
glyphs,
codetag_count: header.codetag_count,
});
}
}
};
let codepoint = parse_codetag_codepoint(&header_text, current_line)?;
let rows = read_glyph(&mut lines, header.height, &mut current_line, &mut endmark)?;
glyphs.insert(codepoint, rows);
actual_codetag += 1;
}
}
fn next_non_empty<'a, I>(lines: &mut I, current_line: &mut u32) -> Option<String>
where
I: Iterator<Item = &'a str>,
{
loop {
*current_line += 1;
let line = lines.next()?;
let trimmed = line.trim_end_matches('\r');
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
}
}
fn looks_like_codetag_header(line: &str) -> bool {
let mut parts = line.splitn(2, char::is_whitespace);
let Some(first) = parts.next() else {
return false;
};
let rest = parts.next();
if rest.is_none() || rest == Some("") {
return false;
}
parse_codetag_codepoint(first, 0).is_ok()
}
struct Header {
hardblank: char,
height: u32,
baseline: u32,
max_length: u32,
old_layout: i32,
comment_lines: u32,
print_direction: u32,
full_layout: u32,
codetag_count: u32,
}
fn parse_header(line: &str, line_no: u32) -> Result<Header, FigletError> {
if !line.starts_with("flf2a") {
return Err(parse_err("bad signature: expected flf2a prefix", line_no));
}
let nh = header::parse_header_line(line, "flf2a".len(), line_no)?;
Ok(Header {
hardblank: nh.hardblank,
height: nh.height,
baseline: nh.baseline,
max_length: nh.max_length,
old_layout: nh.old_layout,
comment_lines: nh.comment_lines,
print_direction: nh.print_direction,
full_layout: nh.full_layout,
codetag_count: nh.codetag_count,
})
}
fn read_glyph<'a, I>(
lines: &mut I,
height: u32,
current_line: &mut u32,
endmark: &mut Option<char>,
) -> Result<Vec<String>, FigletError>
where
I: Iterator<Item = &'a str>,
{
let mut rows = Vec::with_capacity(height as usize);
for row in 0..height {
*current_line += 1;
let raw = lines
.next()
.ok_or_else(|| parse_err("short glyph block: hit EOF mid-glyph", *current_line))?
.trim_end_matches('\r');
if raw.is_empty() {
return Err(parse_err(
"short glyph block: blank line where glyph row expected",
*current_line,
));
}
let stripped = strip_endmark(raw, row == height - 1, endmark, *current_line)?;
rows.push(stripped);
}
Ok(rows)
}
fn strip_endmark(
raw: &str,
last_row: bool,
endmark: &mut Option<char>,
line_no: u32,
) -> Result<String, FigletError> {
let chars: Vec<char> = raw.chars().collect();
if chars.is_empty() {
return Err(parse_err("missing endmark: glyph row is empty", line_no));
}
let candidate = *chars.last().expect("non-empty just checked");
let mark = match *endmark {
Some(m) => m,
None => {
*endmark = Some(candidate);
candidate
}
};
if candidate != mark {
return Err(parse_err(
&format!("missing endmark: row ends with '{candidate}', expected endmark '{mark}'"),
line_no,
));
}
let mut end = chars.len() - 1;
if last_row {
if end == 0 || chars[end - 1] != mark {
return Err(parse_err(
"missing endmark: final glyph row lacks doubled endmark",
line_no,
));
}
end -= 1;
}
Ok(chars[..end].iter().collect())
}
fn parse_codetag_codepoint(line: &str, line_no: u32) -> Result<u32, FigletError> {
let tok = line
.split_whitespace()
.next()
.ok_or_else(|| parse_err("codetag header missing codepoint token", line_no))?;
let body = tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X"));
let (body, negative) = match body {
Some(b) => (b, false),
None => {
if let Some(rest) = tok.strip_prefix('-') {
let rest_body = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X"));
(rest_body.unwrap_or(rest), true)
} else {
(tok, false)
}
}
};
let value = u32::from_str_radix(body, 16).map_err(|_| {
parse_err(
&format!("codetag codepoint not hexadecimal: {tok}"),
line_no,
)
})?;
if negative {
Ok(value.wrapping_neg())
} else {
Ok(value)
}
}
fn parse_err(reason: &str, line: u32) -> FigletError {
FigletError::FontParse {
reason: reason.to_owned(),
line,
}
}
pub static BUNDLED_FONTS: &[(&str, &[u8])] = &[
("standard", include_bytes!("../assets/fonts/standard.flf")),
("slant", include_bytes!("../assets/fonts/slant.flf")),
("small", include_bytes!("../assets/fonts/small.flf")),
("big", include_bytes!("../assets/fonts/big.flf")),
("mini", include_bytes!("../assets/fonts/mini.flf")),
("banner", include_bytes!("../assets/fonts/banner.flf")),
("block", include_bytes!("../assets/fonts/block.flf")),
("bubble", include_bytes!("../assets/fonts/bubble.flf")),
("digital", include_bytes!("../assets/fonts/digital.flf")),
("lean", include_bytes!("../assets/fonts/lean.flf")),
("script", include_bytes!("../assets/fonts/script.flf")),
("shadow", include_bytes!("../assets/fonts/shadow.flf")),
];
pub fn lookup_codepoint(font: &FIGfont, cp: u32) -> Option<&Vec<String>> {
font.glyphs.get(&cp)
}
pub fn resolve_bundled(name: &str) -> Option<&'static [u8]> {
BUNDLED_FONTS
.iter()
.find_map(|(n, bytes)| if *n == name { Some(*bytes) } else { None })
}
pub fn resolve_font(name: &str, extra_dirs: &[PathBuf]) -> Result<Vec<u8>, FigletError> {
let mut searched: Vec<PathBuf> = Vec::new();
let path = Path::new(name);
if path.extension().is_some_and(|ext| ext == "flf") {
searched.push(path.to_path_buf());
if path.is_file() {
return std::fs::read(path).map_err(FigletError::from);
}
}
let bare = name.strip_suffix(".flf").unwrap_or(name);
if let Some(bytes) = resolve_bundled(bare) {
return Ok(bytes.to_vec());
}
for dir in extra_dirs {
for candidate_name in [name.to_owned(), format!("{bare}.flf")] {
let p = dir.join(&candidate_name);
searched.push(p.clone());
if p.is_file() {
return std::fs::read(&p).map_err(FigletError::from);
}
}
}
if let Some(user_dir) = user_data_dir() {
let p = user_dir.join(format!("{bare}.flf"));
searched.push(p.clone());
if p.is_file() {
return std::fs::read(&p).map_err(FigletError::from);
}
}
#[cfg(unix)]
{
let p = PathBuf::from("/usr/share/figlet").join(format!("{bare}.flf"));
searched.push(p.clone());
if p.is_file() {
return std::fs::read(&p).map_err(FigletError::from);
}
}
Err(FigletError::FontNotFound {
name: name.to_owned(),
searched,
})
}
fn user_data_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/figlet"))
}
#[cfg(windows)]
{
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("figlet\\fonts"))
}
#[cfg(not(any(unix, windows)))]
{
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_table_has_twelve_entries() {
assert_eq!(BUNDLED_FONTS.len(), 12);
}
#[test]
fn resolve_bundled_finds_standard() {
assert!(resolve_bundled("standard").is_some());
}
#[test]
fn resolve_bundled_misses_unknown() {
assert!(resolve_bundled("nonexistent").is_none());
}
#[test]
fn parses_each_bundled_font() {
for (name, bytes) in BUNDLED_FONTS {
let font = parse_bytes(bytes).unwrap_or_else(|err| {
panic!("bundled font {name} failed to parse: {err}");
});
assert!(font.height >= 1, "{name} height >= 1");
for cp in 32..=126u32 {
assert!(
font.glyphs.contains_key(&cp),
"{name} missing ASCII codepoint {cp}"
);
}
for &cp in REQUIRED_CODEPOINTS_GERMAN {
assert!(
font.glyphs.contains_key(&cp),
"{name} missing German codepoint {cp}"
);
}
}
}
#[test]
fn rejects_bad_signature() {
let err = parse_bytes(b"NOTflf2a$ 1 1 8 0 0\n").unwrap_err();
match err {
FigletError::FontParse { reason, line } => {
assert!(reason.contains("bad signature"), "{reason}");
assert_eq!(line, 1);
}
other => panic!("expected FontParse, got {other:?}"),
}
}
#[test]
fn rejects_truncated_header() {
let err = parse_bytes(b"flf2a$ 1 1\n").unwrap_err();
match err {
FigletError::FontParse { reason, line } => {
assert!(reason.contains("truncated header"), "{reason}");
assert_eq!(line, 1);
}
other => panic!("expected FontParse, got {other:?}"),
}
}
#[test]
fn rejects_old_layout_out_of_range() {
let err = parse_bytes(b"flf2a$ 1 1 8 64 0\n").unwrap_err();
match err {
FigletError::FontParse { reason, .. } => {
assert!(reason.contains("old_layout"), "{reason}");
}
other => panic!("expected FontParse, got {other:?}"),
}
}
#[test]
fn rejects_old_layout_below_negative_one() {
let err = parse_bytes(b"flf2a$ 1 1 8 -2 0\n").unwrap_err();
match err {
FigletError::FontParse { reason, .. } => {
assert!(reason.contains("old_layout"), "{reason}");
}
other => panic!("expected FontParse, got {other:?}"),
}
}
#[test]
fn rejects_comment_lines_mismatch() {
let err = parse_bytes(b"flf2a$ 1 1 8 0 99\nonly one\n").unwrap_err();
match err {
FigletError::FontParse { reason, .. } => {
assert!(reason.contains("comment"), "{reason}");
}
other => panic!("expected FontParse, got {other:?}"),
}
}
#[test]
fn rejects_short_glyph_block() {
let err = parse_bytes(b"flf2a$ 3 1 8 0 0\nrow1@@\n").unwrap_err();
match err {
FigletError::FontParse { reason, .. } => {
assert!(reason.contains("short glyph block"), "{reason}");
}
other => panic!("expected FontParse, got {other:?}"),
}
}
#[test]
fn rejects_missing_doubled_endmark_on_final_row() {
let err = parse_bytes(b"flf2a$ 1 1 8 0 0\nsingle@\n").unwrap_err();
match err {
FigletError::FontParse { reason, .. } => {
assert!(reason.contains("endmark"), "{reason}");
}
other => panic!("expected FontParse, got {other:?}"),
}
}
#[test]
fn lookup_codepoint_finds_ascii_and_german() {
let font = parse_bytes(BUNDLED_FONTS[0].1).expect("standard parses");
assert!(lookup_codepoint(&font, b'A' as u32).is_some());
assert!(lookup_codepoint(&font, 0x00C4).is_some());
assert!(lookup_codepoint(&font, 0x4E2D).is_none());
}
#[test]
fn parses_codetag_codepoint_as_hex() {
let cp = parse_codetag_codepoint("C4 GERMAN AE", 0).unwrap();
assert_eq!(cp, 0xC4);
let cp = parse_codetag_codepoint("0x20 SPACE", 0).unwrap();
assert_eq!(cp, 0x20);
}
}