Skip to main content

rusty_figlet/
tlf.rs

1//! TheLetter (`.tlf`) font-format parser (E012 US3 — FR-001).
2//!
3//! Toilet's native font format extends FIGfont 2.0 with three additive bits:
4//!
5//! 1. Magic header `tlf2a` (5 ASCII bytes) — sometimes followed by `$`
6//!    indicating extended metadata (e.g. `tlf2a$ 4 3 8 0 16 0 64 0`).
7//! 2. UTF-8 multi-column glyph cells (figlet 2.2.5 is single-byte/Latin-1).
8//! 3. Inline color/style markers per cell so fonts ship pre-colored.
9//!
10//! Header numeric fields are FIGfont-shaped (height/baseline/max_length/
11//! old_layout/comment_lines/...) so the same [`crate::header`] reader is
12//! reused. Glyph rows reuse FIGfont endmark/double-endmark conventions but
13//! allow Unicode codepoints inside cells.
14//!
15//! The parser is hand-rolled and pure-Rust per FR-024 (no C linkage). The
16//! clean-room derivation rationale is recorded in `docs/tlf-derivation.md`.
17//!
18//! ## Feature gate
19//!
20//! This module is gated by the `tlf-parser` Cargo leaf. v0.2.x users will
21//! not see it; v0.3.0 enables it under `default = ["full"]` and the
22//! `figlet-toilet-compat` preset bundle.
23
24use std::collections::HashMap;
25use std::path::Path;
26
27use crate::error::FigletError;
28use crate::header;
29
30/// Hard upper bound on TLF file size — per spec Edge Cases, files >8 MiB
31/// are rejected with `FigletError::TlfParse`. The bound applies to the
32/// raw byte slice and to disk reads.
33pub(crate) const TLF_MAX_FILE_SIZE: usize = 8 * 1024 * 1024;
34
35/// Magic prefix shared by all `.tlf` files. The optional trailing `$`
36/// (extended-metadata form) is consumed as the hardblank by the header
37/// reader, so the magic itself is exactly these 5 bytes.
38pub(crate) const TLF_MAGIC: &[u8] = b"tlf2a";
39
40/// Parsed TLF font.
41///
42/// Glyphs are keyed by Unicode codepoint (`u32`) — same shape as the FLF
43/// parser's [`crate::figfont::FIGfont::glyphs`] so downstream renderers can
44/// treat the two interchangeably once a TLF is loaded.
45#[derive(Debug, Clone)]
46pub struct TlfFont {
47    /// Numeric header fields (height, baseline, max_length, etc.) shared
48    /// with FLF via [`crate::header::NumericHeader`].
49    pub(crate) header: TlfHeader,
50    /// Map from codepoint → glyph rows (one entry per row, endmarks stripped).
51    pub(crate) glyphs: HashMap<u32, TlfGlyph>,
52    /// `true` when any glyph carried an inline color/style marker.
53    pub multicolor: bool,
54}
55
56/// TLF header — same shape as FIGfont but with a constant magic.
57///
58/// `version` is reserved for future TLF revisions; v1 of the format
59/// (`tlf2a`) uses `1`.
60#[derive(Debug, Clone)]
61pub(crate) struct TlfHeader {
62    /// Hardblank character (typically `$`).
63    pub hardblank: char,
64    /// Height in rows of every glyph.
65    pub height: u32,
66    /// Baseline row from the top.
67    pub baseline: u32,
68    /// Maximum width of any glyph in this font.
69    pub max_length: u32,
70    /// Comment-block line count (lines after the header line).
71    pub comment_lines: u32,
72}
73
74/// Single TLF glyph: a stack of rows of cells.
75#[derive(Debug, Clone)]
76pub(crate) struct TlfGlyph {
77    /// One row per glyph row (length == header.height).
78    pub rows: Vec<TlfRow>,
79}
80
81/// One row of TLF cells (UTF-8 multi-column characters with optional color).
82#[derive(Debug, Clone)]
83pub(crate) struct TlfRow {
84    /// Cells of this row in left-to-right order.
85    pub cells: Vec<TlfCell>,
86}
87
88/// A single TLF cell carrying one Unicode character + optional color attr.
89///
90/// `color_attr` is `None` for plain-text fonts (most TLFs in practice) and
91/// `Some(byte)` for multicolor cells whose interpretation matches libcaca's
92/// internal palette index.
93#[derive(Debug, Clone, Copy)]
94pub(crate) struct TlfCell {
95    /// Unicode character displayed in this cell.
96    pub ch: char,
97    /// Optional inline color/style marker (libcaca attribute byte).
98    pub color_attr: Option<u8>,
99}
100
101impl TlfFont {
102    /// Parse a TLF font from raw bytes.
103    ///
104    /// Validates the `tlf2a` magic at byte 0, delegates numeric-header
105    /// parsing to [`crate::header::parse_header_line`] (sharing FR-028 O(1)
106    /// error-cost contract with the FLF parser), then walks the glyph table.
107    ///
108    /// Both `tlf2a` and `tlf2a$` extended-metadata header forms are accepted
109    /// per research.md §TLF Font Format.
110    pub fn from_bytes(bytes: &[u8]) -> Result<TlfFont, FigletError> {
111        parse_tlf(bytes)
112    }
113
114    /// Header height in rows.
115    pub fn height(&self) -> u32 {
116        self.header.height
117    }
118
119    /// Lookup a codepoint's glyph rows; returns `None` for codepoints absent
120    /// from the font.
121    pub(crate) fn lookup(&self, cp: u32) -> Option<&TlfGlyph> {
122        self.glyphs.get(&cp)
123    }
124}
125
126/// Parse a TLF byte slice into a [`TlfFont`].
127///
128/// Errors raised:
129/// - [`FigletError::InvalidTlfHeader`] — magic mismatch.
130/// - [`FigletError::TlfParse`] — any later parse failure with 1-indexed line.
131///
132/// Per FR-026 the parser's working set is bounded by the source byte length
133/// (no per-glyph quadratic copies). Per spec Edge Cases, files larger than
134/// 8 MiB are rejected up front.
135pub fn parse_tlf(bytes: &[u8]) -> Result<TlfFont, FigletError> {
136    // File-size cap per spec Edge Cases.
137    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    // Zero-byte files are immediately rejected as invalid magic.
149    if bytes.is_empty() {
150        return Err(FigletError::InvalidTlfHeader { found: Vec::new() });
151    }
152
153    // Validate magic at byte 0 — extract up to 32 bytes for the diagnostic
154    // (spec Security Posture: cap echoed bytes to prevent log spam from
155    // adversarial inputs).
156    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    // TLF is UTF-8 per research.md §TLF Font Format (figlet 2.2.5 is
164    // single-byte/Latin-1 by default; TLF extends FIGfont 2.0 with UTF-8
165    // multi-column Unicode glyph support). Decode strictly here so multi-
166    // column glyph rows preserve grapheme integrity. Invalid UTF-8 in any
167    // byte produces a TlfParse error per FR-016 + FR-028.
168    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    // Delegate numeric parsing — magic_len = TLF_MAGIC.len() = 5.
184    let nh =
185        header::parse_header_line(header_line, TLF_MAGIC.len(), 1).map_err(|err| match err {
186            // Convert FontParse to InvalidTlfHeader for malformed numeric fields
187            // per FR-016 + FR-028 — the spec demands a TLF-specific variant when
188            // the header is structurally invalid.
189            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    // Hard cap on per-row cell count (spec Edge Cases): refuse > 64 KiB cells
210    // per row. We apply this against `max_length` as the declared upper bound.
211    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    // Skip comment_lines.
222    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    // Now decode glyph table. TLF uses FIGfont endmark conventions but cells
234    // may contain Unicode multi-column characters and inline color markers.
235    // Per-row allocations are bounded by the file byte length (FR-026): we
236    // never copy more bytes into glyph storage than appear in the source.
237    let mut glyphs: HashMap<u32, TlfGlyph> = HashMap::new();
238    let mut endmark: Option<char> = None;
239    let mut multicolor_seen = false;
240
241    // ASCII 32..=126 are required; remaining codepoints (German chars,
242    // codetag blocks) follow inline or via codetag headers.
243    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    // Optional codetag stream — read until EOF.
255    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
278/// Read exactly `height` glyph rows, stripping endmarks per FIGfont 2.0
279/// conventions and decoding multicolor cell markers.
280fn 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
310/// Strip the FIGfont 2.0 endmark from a UTF-8 glyph row. Same conventions
311/// as FLF (single endmark on rows 0..height-1, doubled on the final row).
312fn 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
352/// Decode a stripped-endmark UTF-8 glyph row into [`TlfCell`]s.
353///
354/// Inline color markers (libcaca multicolor) follow the convention
355/// `\x0E<attr_byte>` where `\x0E` (Shift Out, SO) introduces a one-byte
356/// attribute that applies to the cells until the next marker. Plain TLFs
357/// contain no SO bytes and decode 1:1 character → cell.
358///
359/// Per FR-026 working set is bounded by input length — each input char
360/// yields at most one output cell.
361fn 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 marker: next char's low byte is the attr.
368            *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
382/// Skip blank lines, returning the first non-empty trimmed line.
383fn 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
397/// Parse the first whitespace-separated token of a codetag header line as
398/// a hex codepoint (matches FLF conventions; HINT-001 rejects decimal).
399fn 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
436/// Read a `.tlf` file from disk with the file-system-adversarial bounds
437/// required by spec Edge Cases: zero-byte files, files >8 MiB, and symlink
438/// loops are rejected before allocation.
439///
440/// Used by [`crate::Figlet::from_tlf`].
441pub(crate) fn read_tlf_file(path: &Path) -> Result<Vec<u8>, FigletError> {
442    // Reject symlink loops + zero-byte files via the OS metadata first.
443    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    /// Construct a syntactically-valid minimal TLF byte slice for testing.
468    ///
469    /// height=1, comment_lines=0, ASCII 32..=126 glyphs each containing
470    /// the literal codepoint character followed by `@@` (single + doubled
471    /// endmark on the same/only row).
472    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            // height==1 means the single row IS the last row → doubled endmark.
477            // Glyph char + `@@` (last_row doubled endmark).
478            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        // ASCII 'A' (0x41) must be present.
491        assert!(font.lookup(b'A' as u32).is_some());
492        // CJK codepoint must miss.
493        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        // Magic correct but numeric fields garbage.
522        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        // Header declares height=2 but only ASCII 32 has 2 rows; ASCII 33
541        // is short by 1 row → fails mid-glyph.
542        let mut s = String::from("tlf2a$ 2 1 8 0 0\n");
543        // ASCII 32 (space): two rows, last doubled endmark.
544        s.push_str(" @\n");
545        s.push_str(" @@\n");
546        // ASCII 33 (!): one row only, then EOF.
547        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        // Construct an artificially oversized buffer: 9 MiB.
564        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        // The `tlf2a$ 4 3 8 0 16 0 64 0` extended form (per research.md):
581        // hardblank is `$`, then 8 numeric fields. comment_lines=16 means
582        // we need 16 comment lines before glyphs.
583        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        // Inject an `\x0E\x04` SO marker into ASCII 32's row.
596        let mut s = String::from("tlf2a$ 1 1 8 0 0\n");
597        s.push_str("\x0E\x04 @@\n"); // SO + attr 0x04 + space glyph + endmarks
598        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        // First glyph row uses `@`; second glyph row uses `#` → mismatch.
613        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        // height=1 → single row IS final row, needs doubled endmark.
633        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        // Use a CJK char inside a glyph row.
645        let mut s = String::from("tlf2a$ 1 1 8 0 0\n");
646        // ASCII 32 row uses Chinese char `中` + doubled endmark.
647        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        // max_length=65537 → above cap.
661        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}