use std::collections::HashMap;
use std::path::Path;
use crate::error::FigletError;
use crate::header;
pub(crate) const TLF_MAX_FILE_SIZE: usize = 8 * 1024 * 1024;
pub(crate) const TLF_MAGIC: &[u8] = b"tlf2a";
#[derive(Debug, Clone)]
pub struct TlfFont {
pub(crate) header: TlfHeader,
pub(crate) glyphs: HashMap<u32, TlfGlyph>,
pub multicolor: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct TlfHeader {
pub hardblank: char,
pub height: u32,
pub baseline: u32,
pub max_length: u32,
pub comment_lines: u32,
}
#[derive(Debug, Clone)]
pub(crate) struct TlfGlyph {
pub rows: Vec<TlfRow>,
}
#[derive(Debug, Clone)]
pub(crate) struct TlfRow {
pub cells: Vec<TlfCell>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct TlfCell {
pub ch: char,
pub color_attr: Option<u8>,
}
impl TlfFont {
pub fn from_bytes(bytes: &[u8]) -> Result<TlfFont, FigletError> {
parse_tlf(bytes)
}
pub fn height(&self) -> u32 {
self.header.height
}
pub(crate) fn lookup(&self, cp: u32) -> Option<&TlfGlyph> {
self.glyphs.get(&cp)
}
}
pub fn parse_tlf(bytes: &[u8]) -> Result<TlfFont, FigletError> {
if bytes.len() > TLF_MAX_FILE_SIZE {
return Err(FigletError::TlfParse {
reason: format!(
"file size {} exceeds {} byte maximum",
bytes.len(),
TLF_MAX_FILE_SIZE
),
line: 1,
});
}
if bytes.is_empty() {
return Err(FigletError::InvalidTlfHeader { found: Vec::new() });
}
if bytes.len() < TLF_MAGIC.len() || &bytes[..TLF_MAGIC.len()] != TLF_MAGIC {
let take = bytes.len().min(32);
return Err(FigletError::InvalidTlfHeader {
found: bytes[..take].to_vec(),
});
}
let text = match std::str::from_utf8(bytes) {
Ok(s) => s,
Err(e) => {
return Err(FigletError::TlfParse {
reason: format!("malformed UTF-8 at byte offset {}", e.valid_up_to()),
line: 1,
});
}
};
let mut lines = text.split('\n');
let header_line = lines
.next()
.ok_or_else(|| tlf_parse_err("empty input after magic", 1))?
.trim_end_matches('\r');
let nh =
header::parse_header_line(header_line, TLF_MAGIC.len(), 1).map_err(|err| match err {
FigletError::FontParse { reason: _, line: _ } => FigletError::InvalidTlfHeader {
found: header_line.as_bytes().iter().copied().take(32).collect(),
},
other => other,
})?;
let tlf_header = TlfHeader {
hardblank: nh.hardblank,
height: nh.height,
baseline: nh.baseline,
max_length: nh.max_length,
comment_lines: nh.comment_lines,
};
if tlf_header.height == 0 {
return Err(FigletError::InvalidTlfHeader {
found: header_line.as_bytes().iter().copied().take(32).collect(),
});
}
if tlf_header.max_length > 65_536 {
return Err(tlf_parse_err(
&format!(
"max_length {} exceeds 65536 cell-per-row cap",
tlf_header.max_length
),
1,
));
}
let mut current_line: u32 = 1;
for _ in 0..tlf_header.comment_lines {
current_line += 1;
if lines.next().is_none() {
return Err(tlf_parse_err(
"truncated comment block: comment_lines exceeds available lines",
current_line,
));
}
}
let mut glyphs: HashMap<u32, TlfGlyph> = HashMap::new();
let mut endmark: Option<char> = None;
let mut multicolor_seen = false;
for cp in 32u32..=126 {
let g = read_glyph(
&mut lines,
tlf_header.height,
&mut current_line,
&mut endmark,
&mut multicolor_seen,
)?;
glyphs.insert(cp, g);
}
loop {
let header_text = match next_non_empty(&mut lines, &mut current_line) {
Some(line) => line,
None => {
return Ok(TlfFont {
header: tlf_header,
glyphs,
multicolor: multicolor_seen,
});
}
};
let codepoint = parse_codetag_codepoint(&header_text, current_line)?;
let g = read_glyph(
&mut lines,
tlf_header.height,
&mut current_line,
&mut endmark,
&mut multicolor_seen,
)?;
glyphs.insert(codepoint, g);
}
}
fn read_glyph<'a, I>(
lines: &mut I,
height: u32,
current_line: &mut u32,
endmark: &mut Option<char>,
multicolor_seen: &mut bool,
) -> Result<TlfGlyph, 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(|| tlf_parse_err("short glyph block: hit EOF mid-glyph", *current_line))?
.trim_end_matches('\r');
if raw.is_empty() {
return Err(tlf_parse_err(
"short glyph block: blank line where glyph row expected",
*current_line,
));
}
let stripped = strip_endmark_utf8(raw, row == height - 1, endmark, *current_line)?;
let cells = decode_cells(&stripped, multicolor_seen);
rows.push(TlfRow { cells });
}
Ok(TlfGlyph { rows })
}
fn strip_endmark_utf8(
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(tlf_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(tlf_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(tlf_parse_err(
"missing endmark: final glyph row lacks doubled endmark",
line_no,
));
}
end -= 1;
}
Ok(chars[..end].iter().collect())
}
fn decode_cells(s: &str, multicolor_seen: &mut bool) -> Vec<TlfCell> {
let mut out = Vec::with_capacity(s.chars().count());
let mut current_color: Option<u8> = None;
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x0E' {
*multicolor_seen = true;
if let Some(attr_ch) = chars.next() {
current_color = Some((attr_ch as u32 & 0xFF) as u8);
}
continue;
}
out.push(TlfCell {
ch,
color_attr: current_color,
});
}
out
}
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 parse_codetag_codepoint(line: &str, line_no: u32) -> Result<u32, FigletError> {
let tok = line
.split_whitespace()
.next()
.ok_or_else(|| tlf_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(|_| {
tlf_parse_err(
&format!("codetag codepoint not hexadecimal: {tok}"),
line_no,
)
})?;
if negative {
Ok(value.wrapping_neg())
} else {
Ok(value)
}
}
fn tlf_parse_err(reason: &str, line: u32) -> FigletError {
FigletError::TlfParse {
reason: reason.to_owned(),
line,
}
}
pub(crate) fn read_tlf_file(path: &Path) -> Result<Vec<u8>, FigletError> {
let meta = std::fs::metadata(path)?;
if meta.len() == 0 {
return Err(FigletError::TlfParse {
reason: "zero-byte file".to_owned(),
line: 1,
});
}
if meta.len() as usize > TLF_MAX_FILE_SIZE {
return Err(FigletError::TlfParse {
reason: format!(
"file size {} exceeds {} byte maximum",
meta.len(),
TLF_MAX_FILE_SIZE
),
line: 1,
});
}
std::fs::read(path).map_err(FigletError::from)
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_tlf_bytes() -> Vec<u8> {
let mut s = String::from("tlf2a$ 1 1 8 0 0 0 0 0\n");
for cp in 32u32..=126 {
let ch = char::from_u32(cp).unwrap();
s.push(ch);
s.push_str("@@\n");
}
s.into_bytes()
}
#[test]
fn valid_tlf_returns_ok() {
let bytes = minimal_tlf_bytes();
let font = parse_tlf(&bytes).expect("valid tlf parses");
assert_eq!(font.height(), 1);
assert_eq!(font.header.hardblank, '$');
assert!(font.lookup(b'A' as u32).is_some());
assert!(font.lookup(0x4E2D).is_none());
assert!(!font.multicolor);
}
#[test]
fn invalid_magic_returns_invalid_tlf_header() {
let err = parse_tlf(b"flf2a$ 1 1 8 0 0\n").unwrap_err();
match err {
FigletError::InvalidTlfHeader { found } => {
assert_eq!(&found[..5], b"flf2a");
}
other => panic!("expected InvalidTlfHeader, got {other:?}"),
}
}
#[test]
fn empty_input_returns_invalid_tlf_header() {
let err = parse_tlf(b"").unwrap_err();
match err {
FigletError::InvalidTlfHeader { found } => {
assert!(found.is_empty());
}
other => panic!("expected InvalidTlfHeader, got {other:?}"),
}
}
#[test]
fn malformed_header_returns_invalid_tlf_header() {
let err = parse_tlf(b"tlf2a$ notanumber 1 8 0 0\n").unwrap_err();
match err {
FigletError::InvalidTlfHeader { .. } => {}
other => panic!("expected InvalidTlfHeader, got {other:?}"),
}
}
#[test]
fn zero_height_returns_invalid_tlf_header() {
let err = parse_tlf(b"tlf2a$ 0 0 0 0 0\n").unwrap_err();
match err {
FigletError::InvalidTlfHeader { .. } => {}
other => panic!("expected InvalidTlfHeader, got {other:?}"),
}
}
#[test]
fn truncated_glyph_table_returns_tlf_parse_with_line() {
let mut s = String::from("tlf2a$ 2 1 8 0 0\n");
s.push_str(" @\n");
s.push_str(" @@\n");
s.push_str("!@\n");
let err = parse_tlf(s.as_bytes()).unwrap_err();
match err {
FigletError::TlfParse { reason, line } => {
assert!(
reason.contains("short glyph block") || reason.contains("EOF"),
"{reason}"
);
assert!(line > 1, "expected 1-indexed line >1, got {line}");
}
other => panic!("expected TlfParse, got {other:?}"),
}
}
#[test]
fn file_size_exceeded_returns_tlf_parse() {
let oversized = vec![b'A'; 9 * 1024 * 1024];
let err = parse_tlf(&oversized).unwrap_err();
match err {
FigletError::TlfParse { reason, line } => {
assert!(
reason.contains("exceeds") || reason.contains("size"),
"{reason}"
);
assert_eq!(line, 1);
}
other => panic!("expected TlfParse, got {other:?}"),
}
}
#[test]
fn extended_metadata_header_form_accepted() {
let mut s = String::from("tlf2a$ 1 1 8 0 0 0 64 0\n");
for cp in 32u32..=126 {
let ch = char::from_u32(cp).unwrap();
s.push(ch);
s.push_str("@@\n");
}
let font = parse_tlf(s.as_bytes()).expect("extended-form parses");
assert_eq!(font.height(), 1);
}
#[test]
fn multicolor_marker_is_observed() {
let mut s = String::from("tlf2a$ 1 1 8 0 0\n");
s.push_str("\x0E\x04 @@\n"); for cp in 33u32..=126 {
let ch = char::from_u32(cp).unwrap();
s.push(ch);
s.push_str("@@\n");
}
let font = parse_tlf(s.as_bytes()).expect("multicolor parses");
assert!(font.multicolor, "multicolor flag must be set");
let g = font.lookup(b' ' as u32).unwrap();
let first_cell = g.rows[0].cells.iter().find(|c| c.ch == ' ').unwrap();
assert_eq!(first_cell.color_attr, Some(0x04));
}
#[test]
fn rejects_inconsistent_endmark() {
let mut s = String::from("tlf2a$ 1 1 8 0 0\n");
s.push_str(" @@\n");
s.push_str("!##\n");
for cp in 34u32..=126 {
let ch = char::from_u32(cp).unwrap();
s.push(ch);
s.push_str("@@\n");
}
let err = parse_tlf(s.as_bytes()).unwrap_err();
match err {
FigletError::TlfParse { reason, .. } => {
assert!(reason.contains("endmark"), "{reason}");
}
other => panic!("expected TlfParse, got {other:?}"),
}
}
#[test]
fn rejects_missing_doubled_endmark_final_row() {
let err = parse_tlf(b"tlf2a$ 1 1 8 0 0\n single@\n").unwrap_err();
match err {
FigletError::TlfParse { reason, .. } => {
assert!(reason.contains("endmark"), "{reason}");
}
other => panic!("expected TlfParse, got {other:?}"),
}
}
#[test]
fn unicode_glyph_cell_decodes() {
let mut s = String::from("tlf2a$ 1 1 8 0 0\n");
s.push_str("中@@\n");
for cp in 33u32..=126 {
let ch = char::from_u32(cp).unwrap();
s.push(ch);
s.push_str("@@\n");
}
let font = parse_tlf(s.as_bytes()).expect("unicode parses");
let g = font.lookup(b' ' as u32).unwrap();
assert_eq!(g.rows[0].cells[0].ch, '中');
}
#[test]
fn max_length_cap_enforced() {
let err = parse_tlf(b"tlf2a$ 1 1 65537 0 0\n@@\n").unwrap_err();
match err {
FigletError::TlfParse { reason, .. } => {
assert!(
reason.contains("max_length") || reason.contains("cap"),
"{reason}"
);
}
other => panic!("expected TlfParse, got {other:?}"),
}
}
}