1use std::collections::HashMap;
25use std::path::Path;
26
27use crate::error::FigletError;
28use crate::header;
29
30pub(crate) const TLF_MAX_FILE_SIZE: usize = 8 * 1024 * 1024;
34
35pub(crate) const TLF_MAGIC: &[u8] = b"tlf2a";
39
40#[derive(Debug, Clone)]
46pub struct TlfFont {
47 pub(crate) header: TlfHeader,
50 pub(crate) glyphs: HashMap<u32, TlfGlyph>,
52 pub multicolor: bool,
54}
55
56#[derive(Debug, Clone)]
61pub(crate) struct TlfHeader {
62 pub hardblank: char,
64 pub height: u32,
66 pub baseline: u32,
68 pub max_length: u32,
70 pub comment_lines: u32,
72}
73
74#[derive(Debug, Clone)]
76pub(crate) struct TlfGlyph {
77 pub rows: Vec<TlfRow>,
79}
80
81#[derive(Debug, Clone)]
83pub(crate) struct TlfRow {
84 pub cells: Vec<TlfCell>,
86}
87
88#[derive(Debug, Clone, Copy)]
94pub(crate) struct TlfCell {
95 pub ch: char,
97 pub color_attr: Option<u8>,
99}
100
101impl TlfFont {
102 pub fn from_bytes(bytes: &[u8]) -> Result<TlfFont, FigletError> {
111 parse_tlf(bytes)
112 }
113
114 pub fn height(&self) -> u32 {
116 self.header.height
117 }
118
119 pub(crate) fn lookup(&self, cp: u32) -> Option<&TlfGlyph> {
122 self.glyphs.get(&cp)
123 }
124}
125
126pub fn parse_tlf(bytes: &[u8]) -> Result<TlfFont, FigletError> {
136 if bytes.len() > TLF_MAX_FILE_SIZE {
138 return Err(FigletError::TlfParse {
139 reason: format!(
140 "file size {} exceeds {} byte maximum",
141 bytes.len(),
142 TLF_MAX_FILE_SIZE
143 ),
144 line: 1,
145 });
146 }
147
148 if bytes.is_empty() {
150 return Err(FigletError::InvalidTlfHeader { found: Vec::new() });
151 }
152
153 if bytes.len() < TLF_MAGIC.len() || &bytes[..TLF_MAGIC.len()] != TLF_MAGIC {
157 let take = bytes.len().min(32);
158 return Err(FigletError::InvalidTlfHeader {
159 found: bytes[..take].to_vec(),
160 });
161 }
162
163 let text = match std::str::from_utf8(bytes) {
169 Ok(s) => s,
170 Err(e) => {
171 return Err(FigletError::TlfParse {
172 reason: format!("malformed UTF-8 at byte offset {}", e.valid_up_to()),
173 line: 1,
174 });
175 }
176 };
177 let mut lines = text.split('\n');
178 let header_line = lines
179 .next()
180 .ok_or_else(|| tlf_parse_err("empty input after magic", 1))?
181 .trim_end_matches('\r');
182
183 let nh =
185 header::parse_header_line(header_line, TLF_MAGIC.len(), 1).map_err(|err| match err {
186 FigletError::FontParse { reason: _, line: _ } => FigletError::InvalidTlfHeader {
190 found: header_line.as_bytes().iter().copied().take(32).collect(),
191 },
192 other => other,
193 })?;
194
195 let tlf_header = TlfHeader {
196 hardblank: nh.hardblank,
197 height: nh.height,
198 baseline: nh.baseline,
199 max_length: nh.max_length,
200 comment_lines: nh.comment_lines,
201 };
202
203 if tlf_header.height == 0 {
204 return Err(FigletError::InvalidTlfHeader {
205 found: header_line.as_bytes().iter().copied().take(32).collect(),
206 });
207 }
208
209 if tlf_header.max_length > 65_536 {
212 return Err(tlf_parse_err(
213 &format!(
214 "max_length {} exceeds 65536 cell-per-row cap",
215 tlf_header.max_length
216 ),
217 1,
218 ));
219 }
220
221 let mut current_line: u32 = 1;
223 for _ in 0..tlf_header.comment_lines {
224 current_line += 1;
225 if lines.next().is_none() {
226 return Err(tlf_parse_err(
227 "truncated comment block: comment_lines exceeds available lines",
228 current_line,
229 ));
230 }
231 }
232
233 let mut glyphs: HashMap<u32, TlfGlyph> = HashMap::new();
238 let mut endmark: Option<char> = None;
239 let mut multicolor_seen = false;
240
241 for cp in 32u32..=126 {
244 let g = read_glyph(
245 &mut lines,
246 tlf_header.height,
247 &mut current_line,
248 &mut endmark,
249 &mut multicolor_seen,
250 )?;
251 glyphs.insert(cp, g);
252 }
253
254 loop {
256 let header_text = match next_non_empty(&mut lines, &mut current_line) {
257 Some(line) => line,
258 None => {
259 return Ok(TlfFont {
260 header: tlf_header,
261 glyphs,
262 multicolor: multicolor_seen,
263 });
264 }
265 };
266 let codepoint = parse_codetag_codepoint(&header_text, current_line)?;
267 let g = read_glyph(
268 &mut lines,
269 tlf_header.height,
270 &mut current_line,
271 &mut endmark,
272 &mut multicolor_seen,
273 )?;
274 glyphs.insert(codepoint, g);
275 }
276}
277
278fn read_glyph<'a, I>(
281 lines: &mut I,
282 height: u32,
283 current_line: &mut u32,
284 endmark: &mut Option<char>,
285 multicolor_seen: &mut bool,
286) -> Result<TlfGlyph, FigletError>
287where
288 I: Iterator<Item = &'a str>,
289{
290 let mut rows = Vec::with_capacity(height as usize);
291 for row in 0..height {
292 *current_line += 1;
293 let raw = lines
294 .next()
295 .ok_or_else(|| tlf_parse_err("short glyph block: hit EOF mid-glyph", *current_line))?
296 .trim_end_matches('\r');
297 if raw.is_empty() {
298 return Err(tlf_parse_err(
299 "short glyph block: blank line where glyph row expected",
300 *current_line,
301 ));
302 }
303 let stripped = strip_endmark_utf8(raw, row == height - 1, endmark, *current_line)?;
304 let cells = decode_cells(&stripped, multicolor_seen);
305 rows.push(TlfRow { cells });
306 }
307 Ok(TlfGlyph { rows })
308}
309
310fn strip_endmark_utf8(
313 raw: &str,
314 last_row: bool,
315 endmark: &mut Option<char>,
316 line_no: u32,
317) -> Result<String, FigletError> {
318 let chars: Vec<char> = raw.chars().collect();
319 if chars.is_empty() {
320 return Err(tlf_parse_err(
321 "missing endmark: glyph row is empty",
322 line_no,
323 ));
324 }
325 let candidate = *chars.last().expect("non-empty just checked");
326 let mark = match *endmark {
327 Some(m) => m,
328 None => {
329 *endmark = Some(candidate);
330 candidate
331 }
332 };
333 if candidate != mark {
334 return Err(tlf_parse_err(
335 &format!("missing endmark: row ends with '{candidate}', expected endmark '{mark}'"),
336 line_no,
337 ));
338 }
339 let mut end = chars.len() - 1;
340 if last_row {
341 if end == 0 || chars[end - 1] != mark {
342 return Err(tlf_parse_err(
343 "missing endmark: final glyph row lacks doubled endmark",
344 line_no,
345 ));
346 }
347 end -= 1;
348 }
349 Ok(chars[..end].iter().collect())
350}
351
352fn decode_cells(s: &str, multicolor_seen: &mut bool) -> Vec<TlfCell> {
362 let mut out = Vec::with_capacity(s.chars().count());
363 let mut current_color: Option<u8> = None;
364 let mut chars = s.chars().peekable();
365 while let Some(ch) = chars.next() {
366 if ch == '\x0E' {
367 *multicolor_seen = true;
369 if let Some(attr_ch) = chars.next() {
370 current_color = Some((attr_ch as u32 & 0xFF) as u8);
371 }
372 continue;
373 }
374 out.push(TlfCell {
375 ch,
376 color_attr: current_color,
377 });
378 }
379 out
380}
381
382fn next_non_empty<'a, I>(lines: &mut I, current_line: &mut u32) -> Option<String>
384where
385 I: Iterator<Item = &'a str>,
386{
387 loop {
388 *current_line += 1;
389 let line = lines.next()?;
390 let trimmed = line.trim_end_matches('\r');
391 if !trimmed.is_empty() {
392 return Some(trimmed.to_owned());
393 }
394 }
395}
396
397fn parse_codetag_codepoint(line: &str, line_no: u32) -> Result<u32, FigletError> {
400 let tok = line
401 .split_whitespace()
402 .next()
403 .ok_or_else(|| tlf_parse_err("codetag header missing codepoint token", line_no))?;
404 let body = tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X"));
405 let (body, negative) = match body {
406 Some(b) => (b, false),
407 None => {
408 if let Some(rest) = tok.strip_prefix('-') {
409 let rest_body = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X"));
410 (rest_body.unwrap_or(rest), true)
411 } else {
412 (tok, false)
413 }
414 }
415 };
416 let value = u32::from_str_radix(body, 16).map_err(|_| {
417 tlf_parse_err(
418 &format!("codetag codepoint not hexadecimal: {tok}"),
419 line_no,
420 )
421 })?;
422 if negative {
423 Ok(value.wrapping_neg())
424 } else {
425 Ok(value)
426 }
427}
428
429fn tlf_parse_err(reason: &str, line: u32) -> FigletError {
430 FigletError::TlfParse {
431 reason: reason.to_owned(),
432 line,
433 }
434}
435
436pub(crate) fn read_tlf_file(path: &Path) -> Result<Vec<u8>, FigletError> {
442 let meta = std::fs::metadata(path)?;
444 if meta.len() == 0 {
445 return Err(FigletError::TlfParse {
446 reason: "zero-byte file".to_owned(),
447 line: 1,
448 });
449 }
450 if meta.len() as usize > TLF_MAX_FILE_SIZE {
451 return Err(FigletError::TlfParse {
452 reason: format!(
453 "file size {} exceeds {} byte maximum",
454 meta.len(),
455 TLF_MAX_FILE_SIZE
456 ),
457 line: 1,
458 });
459 }
460 std::fs::read(path).map_err(FigletError::from)
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 fn minimal_tlf_bytes() -> Vec<u8> {
473 let mut s = String::from("tlf2a$ 1 1 8 0 0 0 0 0\n");
474 for cp in 32u32..=126 {
475 let ch = char::from_u32(cp).unwrap();
476 s.push(ch);
479 s.push_str("@@\n");
480 }
481 s.into_bytes()
482 }
483
484 #[test]
485 fn valid_tlf_returns_ok() {
486 let bytes = minimal_tlf_bytes();
487 let font = parse_tlf(&bytes).expect("valid tlf parses");
488 assert_eq!(font.height(), 1);
489 assert_eq!(font.header.hardblank, '$');
490 assert!(font.lookup(b'A' as u32).is_some());
492 assert!(font.lookup(0x4E2D).is_none());
494 assert!(!font.multicolor);
495 }
496
497 #[test]
498 fn invalid_magic_returns_invalid_tlf_header() {
499 let err = parse_tlf(b"flf2a$ 1 1 8 0 0\n").unwrap_err();
500 match err {
501 FigletError::InvalidTlfHeader { found } => {
502 assert_eq!(&found[..5], b"flf2a");
503 }
504 other => panic!("expected InvalidTlfHeader, got {other:?}"),
505 }
506 }
507
508 #[test]
509 fn empty_input_returns_invalid_tlf_header() {
510 let err = parse_tlf(b"").unwrap_err();
511 match err {
512 FigletError::InvalidTlfHeader { found } => {
513 assert!(found.is_empty());
514 }
515 other => panic!("expected InvalidTlfHeader, got {other:?}"),
516 }
517 }
518
519 #[test]
520 fn malformed_header_returns_invalid_tlf_header() {
521 let err = parse_tlf(b"tlf2a$ notanumber 1 8 0 0\n").unwrap_err();
523 match err {
524 FigletError::InvalidTlfHeader { .. } => {}
525 other => panic!("expected InvalidTlfHeader, got {other:?}"),
526 }
527 }
528
529 #[test]
530 fn zero_height_returns_invalid_tlf_header() {
531 let err = parse_tlf(b"tlf2a$ 0 0 0 0 0\n").unwrap_err();
532 match err {
533 FigletError::InvalidTlfHeader { .. } => {}
534 other => panic!("expected InvalidTlfHeader, got {other:?}"),
535 }
536 }
537
538 #[test]
539 fn truncated_glyph_table_returns_tlf_parse_with_line() {
540 let mut s = String::from("tlf2a$ 2 1 8 0 0\n");
543 s.push_str(" @\n");
545 s.push_str(" @@\n");
546 s.push_str("!@\n");
548 let err = parse_tlf(s.as_bytes()).unwrap_err();
549 match err {
550 FigletError::TlfParse { reason, line } => {
551 assert!(
552 reason.contains("short glyph block") || reason.contains("EOF"),
553 "{reason}"
554 );
555 assert!(line > 1, "expected 1-indexed line >1, got {line}");
556 }
557 other => panic!("expected TlfParse, got {other:?}"),
558 }
559 }
560
561 #[test]
562 fn file_size_exceeded_returns_tlf_parse() {
563 let oversized = vec![b'A'; 9 * 1024 * 1024];
565 let err = parse_tlf(&oversized).unwrap_err();
566 match err {
567 FigletError::TlfParse { reason, line } => {
568 assert!(
569 reason.contains("exceeds") || reason.contains("size"),
570 "{reason}"
571 );
572 assert_eq!(line, 1);
573 }
574 other => panic!("expected TlfParse, got {other:?}"),
575 }
576 }
577
578 #[test]
579 fn extended_metadata_header_form_accepted() {
580 let mut s = String::from("tlf2a$ 1 1 8 0 0 0 64 0\n");
584 for cp in 32u32..=126 {
585 let ch = char::from_u32(cp).unwrap();
586 s.push(ch);
587 s.push_str("@@\n");
588 }
589 let font = parse_tlf(s.as_bytes()).expect("extended-form parses");
590 assert_eq!(font.height(), 1);
591 }
592
593 #[test]
594 fn multicolor_marker_is_observed() {
595 let mut s = String::from("tlf2a$ 1 1 8 0 0\n");
597 s.push_str("\x0E\x04 @@\n"); for cp in 33u32..=126 {
599 let ch = char::from_u32(cp).unwrap();
600 s.push(ch);
601 s.push_str("@@\n");
602 }
603 let font = parse_tlf(s.as_bytes()).expect("multicolor parses");
604 assert!(font.multicolor, "multicolor flag must be set");
605 let g = font.lookup(b' ' as u32).unwrap();
606 let first_cell = g.rows[0].cells.iter().find(|c| c.ch == ' ').unwrap();
607 assert_eq!(first_cell.color_attr, Some(0x04));
608 }
609
610 #[test]
611 fn rejects_inconsistent_endmark() {
612 let mut s = String::from("tlf2a$ 1 1 8 0 0\n");
614 s.push_str(" @@\n");
615 s.push_str("!##\n");
616 for cp in 34u32..=126 {
617 let ch = char::from_u32(cp).unwrap();
618 s.push(ch);
619 s.push_str("@@\n");
620 }
621 let err = parse_tlf(s.as_bytes()).unwrap_err();
622 match err {
623 FigletError::TlfParse { reason, .. } => {
624 assert!(reason.contains("endmark"), "{reason}");
625 }
626 other => panic!("expected TlfParse, got {other:?}"),
627 }
628 }
629
630 #[test]
631 fn rejects_missing_doubled_endmark_final_row() {
632 let err = parse_tlf(b"tlf2a$ 1 1 8 0 0\n single@\n").unwrap_err();
634 match err {
635 FigletError::TlfParse { reason, .. } => {
636 assert!(reason.contains("endmark"), "{reason}");
637 }
638 other => panic!("expected TlfParse, got {other:?}"),
639 }
640 }
641
642 #[test]
643 fn unicode_glyph_cell_decodes() {
644 let mut s = String::from("tlf2a$ 1 1 8 0 0\n");
646 s.push_str("中@@\n");
648 for cp in 33u32..=126 {
649 let ch = char::from_u32(cp).unwrap();
650 s.push(ch);
651 s.push_str("@@\n");
652 }
653 let font = parse_tlf(s.as_bytes()).expect("unicode parses");
654 let g = font.lookup(b' ' as u32).unwrap();
655 assert_eq!(g.rows[0].cells[0].ch, '中');
656 }
657
658 #[test]
659 fn max_length_cap_enforced() {
660 let err = parse_tlf(b"tlf2a$ 1 1 65537 0 0\n@@\n").unwrap_err();
662 match err {
663 FigletError::TlfParse { reason, .. } => {
664 assert!(
665 reason.contains("max_length") || reason.contains("cap"),
666 "{reason}"
667 );
668 }
669 other => panic!("expected TlfParse, got {other:?}"),
670 }
671 }
672}