Skip to main content

rusty_figlet/
lib.rs

1//! # rusty-figlet
2//!
3//! Rust port of cmatsuoka's `figlet(6)` v2.2.5 with an in-house FIGfont 2.0
4//! parser, all six horizontal smush rules + universal, 12 bundled `.flf`
5//! fonts via `include_bytes!`, terminal-width-aware layout, color/rainbow
6//! output, byte-equal Strict-mode upstream compatibility, and a typed
7//! library API.
8//!
9//! ## Library API quick tour
10//!
11//! ```rust
12//! use rusty_figlet::{FigletBuilder, Font};
13//!
14//! let banner = FigletBuilder::new()
15//!     .font(Font::Standard)
16//!     .width(80)
17//!     .build()
18//!     .expect("build")
19//!     .render("Hello")
20//!     .expect("render");
21//!
22//! for line in banner.lines() {
23//!     println!("{line}");
24//! }
25//! ```
26//!
27//! ## Default features
28//!
29//! `default = ["full"]` enables every leaf (the kitchen-sink experience)
30//! plus the CLI binary surface (clap, clap_complete, anstyle, termcolor,
31//! terminal_size). Library consumers should depend on `rusty-figlet` with
32//! `default-features = false` to strip every CLI-only dep so only
33//! `thiserror` and the in-house FIGfont parser are pulled in.
34//!
35//! See the README "Cargo Features" section + ADR-0006 for the full leaf
36//! inventory, preset bundles (`figlet-classic`, `figlet-minimal`,
37//! `figlet-toilet-compat`), and the keep-list workaround.
38//!
39//! ## Error handling
40//!
41//! [`FigletError`] is `#[non_exhaustive]`; downstream pattern matches MUST
42//! include a wildcard `_` arm (per AD-013).
43
44#![deny(missing_docs)]
45#![cfg_attr(docsrs, feature(doc_cfg))]
46
47use std::path::PathBuf;
48use std::sync::OnceLock;
49
50mod error;
51pub use error::{FigletError, StrictTarget};
52
53// The cross-cutting modules below are foundational scaffolds (Phase 2).
54// Each one's public surface is consumed by US1..US7 in later phases;
55// until those wires land, individual symbols look unused to clippy.
56// Module-level allow(dead_code) keeps the foundation green without
57// polluting individual definitions.
58#[allow(dead_code)]
59mod figfont;
60#[allow(dead_code)]
61mod header;
62#[allow(dead_code)]
63mod layout;
64
65#[allow(dead_code)]
66mod mode;
67#[allow(dead_code)]
68mod smush;
69/// TheLetter (`.tlf`) font-format parser (E012 US3 — FR-001).
70///
71/// Gated by the `tlf-parser` Cargo leaf. See [`tlf::parse_tlf`] for the
72/// entry point and [`Figlet::from_tlf`] / [`Figlet::from_tlf_bytes`] for
73/// the high-level `Figlet`-returning API.
74#[cfg(feature = "tlf-parser")]
75#[allow(dead_code)]
76pub mod tlf;
77
78/// `RenderGrid` + `FilterChain` framework (E012 US1/US5 — Phase 4/5).
79///
80/// Public types: [`filter::RenderGrid`], [`filter::Cell`],
81/// [`filter::Color`], [`filter::Filter`], [`filter::FilterChain`].
82/// Individual filter implementations are gated behind their respective
83/// `filter-<name>` leaves (see Cargo.toml). The [`filter::Filter::Nothing`]
84/// identity has no leaf — always available — so an empty chain or an
85/// all-`Nothing` chain compiles on any feature surface.
86pub mod filter;
87
88/// Color depth detection + SGR emission (E012 US4 — Phase 6).
89///
90/// Public types: [`color_depth::ColorDepth`], [`color_depth::resolve_depth`].
91/// The truecolor and 256-color SGR emitters are gated behind the
92/// `color-truecolor` and `color-256` leaves respectively. [`ColorDepth::detect`]
93/// is always available; it reads `COLORTERM` + isatty without any per-render
94/// terminal probe (FR-031 — detection runs once at builder time).
95pub mod color_depth;
96
97pub use color_depth::ColorDepth;
98
99/// Multi-format export backends (E012 US2 — Phase 7).
100///
101/// Public types: [`export::ExportFormat`], [`export::write_export`]. Each
102/// individual backend is gated behind its `output-<format>` leaf:
103/// HTML5 (`output-html`), mIRC `^C` codes (`output-irc`), SVG 1.1
104/// (`output-svg`). The ANSI sub-formats reuse [`ColorDepth`] from
105/// [`color_depth`] for SGR emission.
106pub mod export;
107
108pub use layout::{JustifyFlag, JustifyFlags, LayoutFlag, LayoutFlags};
109
110// -----------------------------------------------------------------------------
111// Feature-gate map (per FR-008 + HINT-004 — module-level gates clustered here).
112// -----------------------------------------------------------------------------
113//   #[cfg(feature = "cli")]              → cli module (clap-derive scaffold)
114//   #[cfg(feature = "color")]            → color + output modules (anstyle + termcolor)
115//   #[cfg(feature = "terminal-width")]   → width module + resolve_width_for re-export
116//   #[cfg(feature = "strict-compat")]    → strict module (hand-rolled upstream parser)
117//
118// `rainbow` is a pure compile-flag leaf (no module of its own — it gates a
119// runtime branch inside src/main.rs). The `completions` leaf likewise gates
120// only the BinSubcommand::Completions dispatch arm in src/main.rs.
121// -----------------------------------------------------------------------------
122
123/// Hand-rolled Strict-mode argv parser (AD-007). Public so the
124/// `rusty-figlet` binary can dispatch to its byte-equal upstream
125/// diagnostics; the SemVer policy on this module's surface matches the
126/// rest of the public library API per FR-050. Gated by the
127/// `strict-compat` leaf (v0.2+).
128#[cfg(feature = "strict-compat")]
129#[allow(dead_code)]
130pub mod strict;
131
132/// Toilet 0.3-1 strict-compat byte-equal renderer (E012 US6 — FR-019, AD-005).
133///
134/// Distinct from [`strict`] (which targets figlet 2.2.5 byte-equal argv
135/// parsing). Public entry point is [`strict_toilet::strict_render`]; see
136/// the module docs for the byte-format contract, color-downgrade rules
137/// (US6 AS#2), and the corpus-driven validation harness under
138/// `tests/strict_toilet_integration.rs`. Gated by the
139/// `toilet-strict-compat` leaf (v0.3+).
140#[cfg(feature = "toilet-strict-compat")]
141pub mod strict_toilet;
142
143#[cfg(feature = "cli")]
144#[allow(dead_code)]
145mod cli;
146/// Color/rainbow helpers (per AD-011 + AD-012 + HINT-006).
147///
148/// Exposed publicly for the `rusty-figlet` binary to consume; library
149/// callers SHOULD NOT depend on this module directly (it lives under the
150/// `color` leaf and is subject to change without a major version bump).
151#[cfg(feature = "color")]
152#[doc(hidden)]
153#[allow(dead_code)]
154pub mod color;
155/// Banner writer (per AD-011).
156///
157/// Exposed publicly for the `rusty-figlet` binary to consume; library
158/// callers SHOULD NOT depend on this module directly. Gated by the
159/// `color` leaf because the writer signature is parameterised over
160/// `termcolor::WriteColor`.
161#[cfg(feature = "color")]
162#[doc(hidden)]
163#[allow(dead_code)]
164pub mod output;
165#[cfg(feature = "terminal-width")]
166#[allow(dead_code)]
167mod width;
168
169/// Re-export of [`width::resolve_width`] for the rusty-figlet binary's
170/// CLI wiring path. Library consumers that need to resolve a width
171/// budget under the same precedence ladder may call this helper
172/// directly. Gated by the `terminal-width` leaf because the underlying
173/// lookup depends on `terminal_size`.
174#[cfg(feature = "terminal-width")]
175pub fn resolve_width_for(
176    explicit_w: Option<u32>,
177    use_t: bool,
178    columns_env: Option<u32>,
179    is_tty: bool,
180    mode: CompatibilityMode,
181) -> u32 {
182    width::resolve_width(explicit_w, use_t, columns_env, is_tty, mode)
183}
184
185/// Re-export of [`layout::resolve_justify`] for the rusty-figlet binary's
186/// CLI wiring path (T103 + T109). Translates a sequence of
187/// [`JustifyFlag`] occurrences into the resolved [`Justify`] value via
188/// last-wins semantics per FR-022.
189pub fn resolve_justify_for(flags: &JustifyFlags) -> Justify {
190    match layout::resolve_justify(flags) {
191        layout::Justify::Center => Justify::Center,
192        layout::Justify::Left => Justify::Left,
193        layout::Justify::Right => Justify::Right,
194        layout::Justify::FontDefault => Justify::FontDefault,
195    }
196}
197
198/// Compatibility mode that governs argv parsing + rendering rules.
199///
200/// In `Default` mode the CLI behaves like a modern Rust-native tool
201/// (UTF-8 input, color flags accepted, ergonomic clap diagnostics). In
202/// `Strict` mode the binary mirrors upstream `figlet 2.2.5` byte-for-byte
203/// (Latin-1 clamped input, color flags rejected, hand-rolled getopt-style
204/// diagnostics) so existing shell scripts that target upstream `figlet`
205/// run unmodified.
206///
207/// Marked `#[non_exhaustive]` so future modes (e.g. `Toilet`) remain a
208/// non-breaking addition.
209///
210/// ```rust
211/// use rusty_figlet::CompatibilityMode;
212///
213/// let mode = CompatibilityMode::default();
214/// assert_eq!(mode, CompatibilityMode::Default);
215/// ```
216#[non_exhaustive]
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
218pub enum CompatibilityMode {
219    /// Modern, Rust-native behavior (UTF-8 input, color enabled, ergonomic
220    /// diagnostics).
221    Default,
222    /// Byte-equal upstream `figlet 2.2.5` behavior (Latin-1 input, color
223    /// flags rejected, getopt-style diagnostics).
224    Strict,
225}
226
227impl Default for CompatibilityMode {
228    fn default() -> Self {
229        Self::Default
230    }
231}
232
233/// Bundled-font selector and external-file escape hatch.
234///
235/// The 12 named variants correspond one-to-one to the bundled `.flf`
236/// assets shipped under `assets/fonts/` (AD-016 + FR-011). The
237/// [`Font::External`] variant covers `-f <path>` and `-d <dir>` resolution
238/// paths for user-supplied `.flf` files.
239///
240/// The enum is intentionally exhaustive: the bundled set is pinned for
241/// v0.1.0 SemVer. Adding a 13th bundled font would be a breaking change
242/// requiring a major bump.
243///
244/// ```rust
245/// use rusty_figlet::{FigletBuilder, Font};
246///
247/// // Pick one of the 12 bundled fonts.
248/// let _ = FigletBuilder::new().font(Font::Slant);
249///
250/// // Or load from disk via the External variant.
251/// let _ = FigletBuilder::new().font(Font::External("/tmp/my.flf".into()));
252/// ```
253#[derive(Debug, Clone, PartialEq, Eq, Hash)]
254pub enum Font {
255    /// `standard.flf` — the default FIGfont, used when no `-f` flag is set.
256    Standard,
257    /// `slant.flf`
258    Slant,
259    /// `small.flf`
260    Small,
261    /// `big.flf`
262    Big,
263    /// `mini.flf`
264    Mini,
265    /// `banner.flf`
266    Banner,
267    /// `block.flf`
268    Block,
269    /// `bubble.flf`
270    Bubble,
271    /// `digital.flf`
272    Digital,
273    /// `lean.flf`
274    Lean,
275    /// `script.flf`
276    Script,
277    /// `shadow.flf`
278    Shadow,
279    /// User-supplied `.flf` file resolved from a filesystem path.
280    External(PathBuf),
281}
282
283impl Font {
284    /// Returns the lowercase, suffix-stripped bundled-font name for the
285    /// 12 named variants. Returns `None` for [`Font::External`].
286    pub(crate) fn bundled_name(&self) -> Option<&'static str> {
287        Some(match self {
288            Font::Standard => "standard",
289            Font::Slant => "slant",
290            Font::Small => "small",
291            Font::Big => "big",
292            Font::Mini => "mini",
293            Font::Banner => "banner",
294            Font::Block => "block",
295            Font::Bubble => "bubble",
296            Font::Digital => "digital",
297            Font::Lean => "lean",
298            Font::Script => "script",
299            Font::Shadow => "shadow",
300            Font::External(_) => return None,
301        })
302    }
303}
304
305impl Default for Font {
306    fn default() -> Self {
307        Self::Standard
308    }
309}
310
311/// Source of the resolved `.flf` bytes that [`FigletBuilder::build`] will
312/// parse. Internal — used to express the "font_bytes wins over font" rule
313/// without leaking the enum to callers.
314#[derive(Debug, Clone)]
315enum FontSource {
316    /// One of the 12 bundled-font variants.
317    Bundled(Font),
318    /// User-supplied path resolved via [`figfont::resolve_font`].
319    External(PathBuf),
320    /// In-memory bytes supplied via [`FigletBuilder::font_bytes`].
321    Bytes(Vec<u8>),
322}
323
324/// Fluent builder for [`Figlet`] renderers.
325///
326/// Construct via [`FigletBuilder::new`] and chain configuration methods
327/// (`#[must_use]`); terminate with [`FigletBuilder::build`] to obtain a
328/// reusable [`Figlet`], or use [`FigletBuilder::render`] as a one-shot.
329///
330/// ```rust
331/// use rusty_figlet::{FigletBuilder, Font};
332///
333/// let figlet = FigletBuilder::new()
334///     .font(Font::Standard)
335///     .width(80)
336///     .build()
337///     .expect("build");
338/// let _banner = figlet.render("X").expect("render");
339/// ```
340#[derive(Debug, Clone)]
341pub struct FigletBuilder {
342    source: FontSource,
343    width: u32,
344    layout_override: Option<LayoutOverride>,
345    layout_flags: LayoutFlags,
346    justify: Option<Justify>,
347    font_dirs: Vec<PathBuf>,
348    color_depth: Option<ColorDepth>,
349}
350
351/// Layout override carried through the builder. Internal — translated
352/// into a concrete `LayoutMode` at `build()` time once the font's default
353/// is known. Retained for backward-compatibility with the per-method
354/// `kerning()` / `full_width()` / `smush()` builders; the
355/// [`FigletBuilder::layout`] path supersedes this for full last-wins
356/// semantics across all six layout-class flags.
357#[derive(Debug, Clone, Copy)]
358enum LayoutOverride {
359    Kerning,
360    FullWidth,
361    ForceSmush,
362}
363
364/// Horizontal justification mode.
365///
366/// ```rust
367/// use rusty_figlet::{FigletBuilder, Justify};
368///
369/// let _ = FigletBuilder::new().justify(Justify::Center);
370/// ```
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
372pub enum Justify {
373    /// Center the rendered banner within the resolved width.
374    Center,
375    /// Left-align the rendered banner.
376    Left,
377    /// Right-align the rendered banner.
378    Right,
379    /// Use the font's print-direction default (LTR fonts default to Left).
380    FontDefault,
381}
382
383impl Default for FigletBuilder {
384    fn default() -> Self {
385        Self::new()
386    }
387}
388
389impl FigletBuilder {
390    /// Construct a builder with all defaults:
391    ///
392    /// - font: [`Font::Standard`] (resolves to `standard.flf`)
393    /// - width: 80 columns
394    /// - layout: font-default
395    /// - justify: font-default
396    #[must_use]
397    pub fn new() -> Self {
398        Self {
399            source: FontSource::Bundled(Font::Standard),
400            width: 80,
401            layout_override: None,
402            layout_flags: LayoutFlags::default(),
403            justify: None,
404            font_dirs: Vec::new(),
405            color_depth: None,
406        }
407    }
408
409    /// Select a font.
410    ///
411    /// When `font` is one of the 12 bundled variants, [`build`](Self::build)
412    /// resolves the embedded `.flf` bytes via `include_bytes!`. When `font`
413    /// is [`Font::External`], the supplied path is resolved at `build()`
414    /// time. Default: [`Font::Standard`].
415    #[must_use]
416    pub fn font(mut self, font: Font) -> Self {
417        self.source = match font {
418            Font::External(path) => FontSource::External(path),
419            other => FontSource::Bundled(other),
420        };
421        self
422    }
423
424    /// Supply raw `.flf` bytes directly (no filesystem access; FR-052 +
425    /// FR-056). Overrides any prior [`font`](Self::font) call.
426    #[must_use]
427    pub fn font_bytes(mut self, bytes: &[u8]) -> Self {
428        self.source = FontSource::Bytes(bytes.to_vec());
429        self
430    }
431
432    /// Add an extra directory to search for [`Font::External`] resolutions
433    /// (CLI `-d <dir>` counterpart per FR-010). Repeatable; directories are
434    /// searched in the order added. Has no effect on bundled or
435    /// [`font_bytes`](Self::font_bytes) sources.
436    #[must_use]
437    pub fn font_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
438        self.font_dirs = dirs;
439        self
440    }
441
442    /// Set the output width budget in columns. Default: 80.
443    #[must_use]
444    pub fn width(mut self, cols: u32) -> Self {
445        self.width = cols;
446        self
447    }
448
449    /// Force horizontal kerning (`-k` CLI counterpart).
450    /// Overrides the font's default layout. Last layout-override wins.
451    #[must_use]
452    pub fn kerning(mut self) -> Self {
453        self.layout_override = Some(LayoutOverride::Kerning);
454        self
455    }
456
457    /// Force full-width layout (`-W` CLI counterpart).
458    /// Overrides the font's default layout. Last layout-override wins.
459    #[must_use]
460    pub fn full_width(mut self) -> Self {
461        self.layout_override = Some(LayoutOverride::FullWidth);
462        self
463    }
464
465    /// Force smushing per the font's smush bits (`-S` CLI counterpart).
466    /// Overrides the font's default layout. Last layout-override wins.
467    #[must_use]
468    pub fn smush(mut self) -> Self {
469        self.layout_override = Some(LayoutOverride::ForceSmush);
470        self
471    }
472
473    /// Apply a full sequence of layout-class flag occurrences with
474    /// last-wins semantics (FR-023). When non-empty, this sequence
475    /// supersedes any per-method [`kerning`](Self::kerning) /
476    /// [`full_width`](Self::full_width) / [`smush`](Self::smush)
477    /// override.
478    ///
479    /// ```rust
480    /// use rusty_figlet::{FigletBuilder, LayoutFlag, LayoutFlags};
481    ///
482    /// let flags = LayoutFlags {
483    ///     flags: vec![LayoutFlag::FullWidth, LayoutFlag::Kerning],
484    /// };
485    /// let _ = FigletBuilder::new().layout(flags);
486    /// ```
487    #[must_use]
488    pub fn layout(mut self, flags: LayoutFlags) -> Self {
489        self.layout_flags = flags;
490        self
491    }
492
493    /// Set the justification mode. Default: font's print-direction default.
494    #[must_use]
495    pub fn justify(mut self, j: Justify) -> Self {
496        self.justify = Some(j);
497        self
498    }
499
500    /// Override the color depth used for SGR emission (E012 US4 — FR-010).
501    ///
502    /// When unset (the default), [`build`](Self::build) calls
503    /// [`ColorDepth::detect`] **once** to populate the cached field on
504    /// [`Figlet`]. Subsequent renders never re-probe the terminal per
505    /// FR-031 — the cache is invalidated only via
506    /// [`Figlet::set_color_depth`] or by rebuilding the renderer.
507    ///
508    /// ```rust
509    /// use rusty_figlet::{ColorDepth, FigletBuilder};
510    ///
511    /// let _ = FigletBuilder::new().color_depth(ColorDepth::Truecolor);
512    /// ```
513    #[must_use]
514    pub fn color_depth(mut self, depth: ColorDepth) -> Self {
515        self.color_depth = Some(depth);
516        self
517    }
518
519    /// Resolve the font, parse the `.flf`, and build a reusable
520    /// [`Figlet`] renderer.
521    pub fn build(self) -> Result<Figlet, FigletError> {
522        let bytes = match self.source {
523            FontSource::Bundled(font) => {
524                let name = font
525                    .bundled_name()
526                    .ok_or(FigletError::Internal("Font::External missed bundled match"))?;
527                let slice =
528                    figfont::resolve_bundled(name).ok_or_else(|| FigletError::FontNotFound {
529                        name: name.to_owned(),
530                        searched: Vec::new(),
531                    })?;
532                slice.to_vec()
533            }
534            FontSource::External(path) => {
535                figfont::resolve_font(path.to_string_lossy().as_ref(), &self.font_dirs)?
536            }
537            FontSource::Bytes(bytes) => bytes,
538        };
539        let font = figfont::parse_bytes(&bytes)?;
540        // FR-031: detection runs ONCE here at builder time. The Figlet
541        // renderer caches the result and exposes set_color_depth() as the
542        // sole invalidation entry point — the render path never probes
543        // the terminal.
544        let color_depth = self.color_depth.unwrap_or_else(ColorDepth::detect);
545        Ok(Figlet {
546            font,
547            width: self.width,
548            layout_override: self.layout_override,
549            layout_flags: self.layout_flags,
550            justify: self.justify.unwrap_or(Justify::FontDefault),
551            color_depth,
552        })
553    }
554
555    /// Terminal convenience equivalent to `self.build()?.render(text)`.
556    pub fn render(self, text: &str) -> Result<Banner, FigletError> {
557        self.build()?.render(text)
558    }
559}
560
561/// A reusable renderer holding a parsed [`Font`] and resolved layout
562/// settings.
563///
564/// Cheap to clone; clone the [`Figlet`] across threads to render many
565/// banners concurrently with the same font configuration.
566///
567/// ```rust
568/// use rusty_figlet::{FigletBuilder, Font};
569///
570/// let figlet = FigletBuilder::new()
571///     .font(Font::Standard)
572///     .build()
573///     .expect("build");
574/// let banner = figlet.render("Hi").expect("render");
575/// assert!(banner.height() >= 1);
576/// ```
577#[derive(Debug, Clone)]
578pub struct Figlet {
579    font: figfont::FIGfont,
580    width: u32,
581    layout_override: Option<LayoutOverride>,
582    layout_flags: LayoutFlags,
583    justify: Justify,
584    color_depth: ColorDepth,
585}
586
587impl Figlet {
588    /// Render `text` into a [`Banner`].
589    ///
590    /// The returned banner exposes a lazy line iterator (per FR-053): row
591    /// buffers are precomputed once during `render()`, and [`Banner::lines`]
592    /// yields one row per `next()` without copying the whole banner.
593    pub fn render(&self, text: &str) -> Result<Banner, FigletError> {
594        let layout = self.resolved_layout();
595        let rows = render_to_rows(&self.font, text, layout, self.width);
596        let rows = apply_justify(rows, self.justify, self.width, self.font.print_direction);
597        let rows = strip_hardblanks(rows, self.font.hardblank);
598        Ok(Banner {
599            rows,
600            height: self.font.height,
601        })
602    }
603
604    /// Translate the captured `layout_override` and/or `layout_flags`
605    /// (CLI `-k`/`-W`/`-S`/`-s`/`-o`/`-m N`) into a concrete
606    /// [`layout::LayoutMode`], falling back to the font's `full_layout`
607    /// default when no override is set.
608    ///
609    /// When [`FigletBuilder::layout`] has been used (non-empty
610    /// `layout_flags`), its sequence wins over any per-method
611    /// `kerning()` / `full_width()` / `smush()` setting; the
612    /// `LayoutResolver` then applies last-wins per FR-023.
613    fn resolved_layout(&self) -> layout::LayoutMode {
614        use layout::{LayoutFlag, LayoutFlags, LayoutResolver};
615        if !self.layout_flags.flags.is_empty() {
616            return LayoutResolver::resolve(&self.font, &self.layout_flags);
617        }
618        let mut flags = LayoutFlags::default();
619        if let Some(ov) = self.layout_override {
620            flags.flags.push(match ov {
621                LayoutOverride::Kerning => LayoutFlag::Kerning,
622                LayoutOverride::FullWidth => LayoutFlag::FullWidth,
623                LayoutOverride::ForceSmush => LayoutFlag::ForceSmush,
624            });
625        }
626        LayoutResolver::resolve(&self.font, &flags)
627    }
628
629    /// Load a `.tlf` font from disk and return a renderable [`Figlet`].
630    ///
631    /// Bounded per spec Edge Cases: zero-byte files, files larger than
632    /// 8 MiB, and symlink loops are rejected before allocation.
633    ///
634    /// Returns [`FigletError::InvalidTlfHeader`] when the magic prefix
635    /// mismatches, [`FigletError::TlfParse`] for later parse failures.
636    /// Gated by the `tlf-parser` Cargo leaf.
637    #[cfg(feature = "tlf-parser")]
638    pub fn from_tlf(path: impl AsRef<std::path::Path>) -> Result<Figlet, FigletError> {
639        let bytes = tlf::read_tlf_file(path.as_ref())?;
640        Figlet::from_tlf_bytes(&bytes)
641    }
642
643    /// Build a [`Figlet`] from raw `.tlf` bytes (no filesystem access).
644    ///
645    /// Mirrors [`Figlet::from_tlf`] but skips the disk-bounded I/O checks.
646    /// Bytes larger than 8 MiB still trigger [`FigletError::TlfParse`].
647    /// Gated by the `tlf-parser` Cargo leaf.
648    #[cfg(feature = "tlf-parser")]
649    pub fn from_tlf_bytes(bytes: &[u8]) -> Result<Figlet, FigletError> {
650        let tlf_font = tlf::parse_tlf(bytes)?;
651        let font = tlf_to_figfont(tlf_font);
652        Ok(Figlet {
653            font,
654            width: 80,
655            layout_override: None,
656            layout_flags: LayoutFlags::default(),
657            justify: Justify::FontDefault,
658            color_depth: ColorDepth::detect(),
659        })
660    }
661
662    /// Cached color depth in use by this renderer (E012 US4 — AD-003).
663    ///
664    /// Set at builder time via [`FigletBuilder::color_depth`] (or
665    /// auto-detected via [`ColorDepth::detect`] when unset) and cached
666    /// onto the renderer for the lifetime of this instance per FR-031.
667    ///
668    /// The render path NEVER re-probes the terminal — invalidation is
669    /// caller-driven only via [`Figlet::set_color_depth`].
670    #[must_use]
671    pub fn color_depth(&self) -> ColorDepth {
672        self.color_depth
673    }
674
675    /// Invalidate the cached color depth (E012 US4 — AD-003 + FR-031).
676    ///
677    /// Replaces the cached value; subsequent renders observe the new
678    /// depth. Callers who wish to re-detect the terminal capability
679    /// should pass [`ColorDepth::detect`].
680    ///
681    /// This is the **only** documented way to invalidate the cache —
682    /// the render path itself never re-probes per FR-031. Calling this
683    /// method does not allocate.
684    pub fn set_color_depth(&mut self, depth: ColorDepth) {
685        self.color_depth = depth;
686    }
687}
688
689/// Convert a parsed [`tlf::TlfFont`] into the existing [`figfont::FIGfont`]
690/// shape so the same render/smush/layout pipeline can serve both formats.
691///
692/// Inline color attributes carried by TLF cells are dropped at conversion
693/// time — full multicolor rendering lands in Phase 6 (color depth) once the
694/// `RenderGrid` / `Cell` types arrive in Phase 4. The conversion is
695/// allocation-bounded by the source byte length per FR-026 (one output
696/// string per row, no per-cell quadratic copies).
697#[cfg(feature = "tlf-parser")]
698fn tlf_to_figfont(tf: tlf::TlfFont) -> figfont::FIGfont {
699    use std::collections::HashMap as Map;
700    let height = tf.header.height;
701    let hardblank = tf.header.hardblank;
702    let baseline = tf.header.baseline;
703    let max_length = tf.header.max_length;
704
705    let mut glyphs: Map<u32, Vec<String>> = Map::with_capacity(tf.glyphs.len());
706    for (cp, g) in tf.glyphs.into_iter() {
707        let rows: Vec<String> = g
708            .rows
709            .into_iter()
710            .map(|row| {
711                let mut s = String::with_capacity(row.cells.len());
712                for c in row.cells {
713                    s.push(c.ch);
714                }
715                s
716            })
717            .collect();
718        glyphs.insert(cp, rows);
719    }
720
721    figfont::FIGfont {
722        hardblank,
723        height,
724        baseline,
725        max_length,
726        old_layout: 0,
727        full_layout: 0,
728        print_direction: 0,
729        glyphs,
730        codetag_count: 0,
731    }
732}
733
734/// Render `text` into `height` row buffers using the resolved layout
735/// mode. Implements the per-row glyph accumulator described in T044
736/// with horizontal smushing per HINT-002 + AD-005 and word-wrap per
737/// HINT-008. Returns a `Vec<String>` of length `font.height`.
738fn render_to_rows(
739    font: &figfont::FIGfont,
740    text: &str,
741    layout: layout::LayoutMode,
742    width: u32,
743) -> Vec<String> {
744    let height = font.height.max(1) as usize;
745    if text.is_empty() {
746        return vec![String::new(); height];
747    }
748
749    // Word-wrap per HINT-008: split on ASCII whitespace, accumulate
750    // words into output lines whose post-smush width does not exceed
751    // `width`. Each line then renders into `height` rows; lines are
752    // separated by blank rows.
753    let words: Vec<&str> = text.split(' ').collect();
754    let target_width = width.max(1) as usize;
755
756    let mut all_rows: Vec<String> = vec![String::new(); height];
757    let mut current_rows: Vec<String> = vec![String::new(); height];
758    let mut current_visual_width: usize = 0;
759    let mut line_started = false;
760
761    for word in &words {
762        // Compute the prospective rows after appending this word (with
763        // a single space-separator glyph when the current line is
764        // already non-empty).
765        let mut probe = current_rows.clone();
766        let mut probe_width = current_visual_width;
767        if line_started {
768            append_codepoint(&mut probe, &mut probe_width, font, ' ' as u32, layout);
769        }
770        append_word(&mut probe, &mut probe_width, font, word, layout);
771
772        if probe_width <= target_width || !line_started {
773            // First word OR fits — commit the probe.
774            // FR-025 + HINT-008: if this is a single word on a fresh
775            // line AND it exceeds the target width, emit a one-time
776            // stderr warning per process. The word is still rendered
777            // at full glyph width (no mid-word break).
778            if !line_started && probe_width > target_width {
779                warn_over_width(word, target_width);
780            }
781            current_rows = probe;
782            current_visual_width = probe_width;
783            line_started = true;
784        } else {
785            // Flush current line, start new one with this word.
786            for (acc, line) in all_rows.iter_mut().zip(current_rows.iter()) {
787                if !acc.is_empty() {
788                    acc.push('\n');
789                }
790                acc.push_str(line);
791            }
792            current_rows = vec![String::new(); height];
793            current_visual_width = 0;
794            append_word(
795                &mut current_rows,
796                &mut current_visual_width,
797                font,
798                word,
799                layout,
800            );
801            // FR-025: a single word that overflows the budget on its
802            // own line also triggers the over-width warning.
803            if current_visual_width > target_width {
804                warn_over_width(word, target_width);
805            }
806        }
807    }
808
809    if line_started {
810        for (acc, line) in all_rows.iter_mut().zip(current_rows.iter()) {
811            if !acc.is_empty() {
812                acc.push('\n');
813            }
814            acc.push_str(line);
815        }
816    }
817
818    // Flatten the all_rows accumulator: each entry may contain N
819    // physical lines separated by `\n` (wrapped lines). For US1's
820    // single-banner-per-render contract we keep them as separate rows
821    // in the resulting Vec<String>: row 0 line 0, row 1 line 0, ...,
822    // row 0 line 1, row 1 line 1, ... Splitting by `\n` and
823    // interleaving handles the wrap case; for the common no-wrap path
824    // there are no `\n` chars and the vector is unchanged.
825    interleave_wrapped(all_rows, height)
826}
827
828fn append_word(
829    rows: &mut [String],
830    visual_width: &mut usize,
831    font: &figfont::FIGfont,
832    word: &str,
833    layout: layout::LayoutMode,
834) {
835    for ch in word.chars() {
836        append_codepoint(rows, visual_width, font, ch as u32, layout);
837    }
838}
839
840fn append_codepoint(
841    rows: &mut [String],
842    visual_width: &mut usize,
843    font: &figfont::FIGfont,
844    cp: u32,
845    layout: layout::LayoutMode,
846) {
847    let glyph = match figfont::lookup_codepoint(font, cp) {
848        Some(g) => g,
849        None => {
850            // HINT-009: substitute codepoint-0 missing-character glyph
851            // if present; else skip the char and emit a one-time stderr
852            // warning. The warning is deduplicated globally via a
853            // process-wide OnceLock so library callers don't pollute
854            // their stderr when the same CJK input is rendered twice.
855            warn_missing_codepoint(cp);
856            match figfont::lookup_codepoint(font, 0) {
857                Some(g) => g,
858                None => return,
859            }
860        }
861    };
862
863    merge_glyph(rows, visual_width, glyph, layout, font.hardblank);
864}
865
866fn merge_glyph(
867    rows: &mut [String],
868    visual_width: &mut usize,
869    glyph: &[String],
870    layout: layout::LayoutMode,
871    hardblank: char,
872) {
873    use layout::LayoutMode;
874
875    // Determine smush behavior per LayoutMode.
876    //
877    // FIGfont 2.0 semantics: bit 64 (RULE_HORIZONTAL_SMUSHING) enables
878    // smushing. The lower 6 bits select the active rules. When ANY of
879    // the lower 6 bits is set, those rules are exhaustive and the
880    // universal-fallback (right-wins) is NOT used. Universal-fallback
881    // applies only when smushing is enabled AND no specific rule bit
882    // is set (the "all six bits clear" case → `UniversalSmush`
883    // LayoutMode).
884    let (rules, allow_smush, allow_kerning_only) = match layout {
885        LayoutMode::FullWidth => (0u8, false, false),
886        LayoutMode::Kerning => (0u8, false, true),
887        LayoutMode::UniversalSmush => (smush::RULE_HORIZONTAL_SMUSHING, true, true),
888        LayoutMode::RuleSmush(bits) => {
889            // Mask off any spurious upper bits so callers can't
890            // accidentally re-enable universal-fallback via bit 64.
891            let only_rule_bits = bits & 0b0011_1111;
892            (only_rule_bits, true, true)
893        }
894        LayoutMode::OverlapOnly => (0u8, false, true),
895    };
896
897    let glyph_chars: Vec<Vec<char>> = glyph.iter().map(|s| s.chars().collect()).collect();
898    let glyph_width = glyph_chars.iter().map(|r| r.len()).max().unwrap_or(0);
899
900    if glyph_width == 0 {
901        return;
902    }
903
904    // FullWidth: no overlap, no smushing; just append.
905    if !allow_smush && !allow_kerning_only {
906        for (i, row) in rows.iter_mut().enumerate() {
907            if let Some(gr) = glyph_chars.get(i) {
908                for &c in gr {
909                    row.push(c);
910                }
911                // Pad short glyph rows out to glyph_width.
912                for _ in gr.len()..glyph_width {
913                    row.push(' ');
914                }
915            } else {
916                for _ in 0..glyph_width {
917                    row.push(' ');
918                }
919            }
920        }
921        *visual_width += glyph_width;
922        return;
923    }
924
925    // Determine the maximum overlap `k` (number of columns by which
926    // the glyph can shift left into the accumulator) such that every
927    // row still produces a legal smush/kerning result.
928    let row_chars: Vec<Vec<char>> = rows.iter().map(|s| s.chars().collect()).collect();
929    let acc_widths: Vec<usize> = row_chars.iter().map(|r| r.len()).collect();
930    let acc_min_width = acc_widths.iter().copied().min().unwrap_or(0);
931
932    let max_possible = acc_min_width.min(glyph_width);
933    let mut overlap = 0usize;
934    // For overlap == 0 we always append directly (legal). For larger
935    // overlaps we test each row.
936    'outer: for k in 1..=max_possible {
937        // Build merged-char arrays for each row at this overlap.
938        let mut row_merges: Vec<Vec<char>> = Vec::with_capacity(rows.len());
939        for (i, acc_row) in row_chars.iter().enumerate() {
940            let glyph_row = glyph_chars.get(i).cloned().unwrap_or_default();
941            // Overlapping columns: acc_row[acc.len()-k+j] vs glyph_row[j].
942            let mut merged = Vec::with_capacity(k);
943            for j in 0..k {
944                let l = acc_row.get(acc_row.len() - k + j).copied().unwrap_or(' ');
945                let r = glyph_row.get(j).copied().unwrap_or(' ');
946                match smush::smush_pair(l, r, rules, hardblank) {
947                    Some(c) => merged.push(c),
948                    None => {
949                        // No smush possible at this column → this overlap
950                        // is illegal. Roll back.
951                        break 'outer;
952                    }
953                }
954            }
955            row_merges.push(merged);
956        }
957        // All rows produced legal merges at this overlap; record and
958        // continue trying larger k.
959        overlap = k;
960        // Cache the merges by stashing them — we'll recompute on commit.
961        let _ = row_merges;
962    }
963
964    // Commit the chosen overlap.
965    for (i, row) in rows.iter_mut().enumerate() {
966        let acc_chars: Vec<char> = row.chars().collect();
967        let glyph_row: Vec<char> = glyph_chars.get(i).cloned().unwrap_or_default();
968        // Trim `overlap` cols off the accumulator and append merged + tail.
969        let keep = acc_chars.len().saturating_sub(overlap);
970        let mut new_row: String = acc_chars[..keep].iter().collect();
971        for j in 0..overlap {
972            let l = acc_chars.get(keep + j).copied().unwrap_or(' ');
973            let r = glyph_row.get(j).copied().unwrap_or(' ');
974            let merged = smush::smush_pair(l, r, rules, hardblank).unwrap_or(r);
975            new_row.push(merged);
976        }
977        for j in overlap..glyph_width {
978            new_row.push(glyph_row.get(j).copied().unwrap_or(' '));
979        }
980        *row = new_row;
981    }
982    *visual_width = visual_width
983        .saturating_add(glyph_width)
984        .saturating_sub(overlap);
985}
986
987fn interleave_wrapped(all_rows: Vec<String>, height: usize) -> Vec<String> {
988    // Each entry in `all_rows` is a `\n`-joined list of physical lines
989    // (one per wrap segment). If no entries contain `\n` the input is
990    // returned verbatim. Otherwise we re-interleave: for each wrap
991    // segment index, emit `height` rows in order.
992    let has_wrap = all_rows.iter().any(|r| r.contains('\n'));
993    if !has_wrap {
994        return all_rows;
995    }
996    let per_row: Vec<Vec<&str>> = all_rows.iter().map(|r| r.split('\n').collect()).collect();
997    let segments = per_row.first().map(Vec::len).unwrap_or(0);
998    let mut out: Vec<String> = Vec::with_capacity(height * segments);
999    for seg in 0..segments {
1000        for row_lines in per_row.iter().take(height) {
1001            let s = row_lines.get(seg).copied().unwrap_or("");
1002            out.push(s.to_owned());
1003        }
1004        // No blank line between wrap segments — upstream figlet word-
1005        // wrap concatenates the height-line blocks back-to-back. Banner
1006        // separators (one blank line between distinct invocations) are
1007        // inserted by the binary's stdin per-line loop instead.
1008    }
1009    out
1010}
1011
1012fn apply_justify(
1013    rows: Vec<String>,
1014    justify: Justify,
1015    width: u32,
1016    print_direction: u32,
1017) -> Vec<String> {
1018    let effective = match justify {
1019        Justify::Center => Justify::Center,
1020        Justify::Left => Justify::Left,
1021        Justify::Right => Justify::Right,
1022        Justify::FontDefault => {
1023            if print_direction == 1 {
1024                Justify::Right
1025            } else {
1026                Justify::Left
1027            }
1028        }
1029    };
1030    let target = width as usize;
1031    rows.into_iter()
1032        .map(|line| match effective {
1033            Justify::Left | Justify::FontDefault => line,
1034            Justify::Center => {
1035                let w = line.chars().count();
1036                if w >= target {
1037                    line
1038                } else {
1039                    let pad = (target - w) / 2;
1040                    let mut out = String::with_capacity(target);
1041                    for _ in 0..pad {
1042                        out.push(' ');
1043                    }
1044                    out.push_str(&line);
1045                    out
1046                }
1047            }
1048            Justify::Right => {
1049                let w = line.chars().count();
1050                if w >= target {
1051                    line
1052                } else {
1053                    let pad = target - w;
1054                    let mut out = String::with_capacity(target);
1055                    for _ in 0..pad {
1056                        out.push(' ');
1057                    }
1058                    out.push_str(&line);
1059                    out
1060                }
1061            }
1062        })
1063        .collect()
1064}
1065
1066fn strip_hardblanks(rows: Vec<String>, hardblank: char) -> Vec<String> {
1067    rows.into_iter()
1068        .map(|line| line.replace(hardblank, " "))
1069        .collect()
1070}
1071
1072/// Clamp UTF-8 input down to Latin-1 (ISO-8859-1) bytes per FR-044.
1073///
1074/// In Strict mode the upstream `figlet(6)` binary treats every input
1075/// byte as a Latin-1 codepoint (bytes 0..=255). This helper mirrors
1076/// that semantics by mapping every input `char` whose value fits in
1077/// `u8` (0..=255) to the equivalent single-byte Latin-1 codepoint and
1078/// substituting multi-byte UTF-8 codepoints with the upstream-
1079/// compatible `?` (0x3F) placeholder. The returned `Vec<u8>` can be
1080/// passed verbatim to the figfont codepoint lookup (which already
1081/// indexes by `u32`, so any byte 0..=255 round-trips cleanly).
1082///
1083/// HINT-009 explicitly excludes Strict mode from the UTF-8 missing-
1084/// glyph fallback path because this clamp precedes lookup. See the
1085/// BREAKING-CHANGE entry in `CHANGELOG.md` for the Default-mode UTF-8
1086/// vs. Strict-mode Latin-1 divergence.
1087pub fn clamp_input_latin1(input: &str) -> Vec<u8> {
1088    let mut out = Vec::with_capacity(input.len());
1089    for ch in input.chars() {
1090        let cp = ch as u32;
1091        if cp <= 0xFF {
1092            out.push(cp as u8);
1093        } else {
1094            // Upstream figlet emits `?` for non-Latin-1 input bytes.
1095            out.push(b'?');
1096        }
1097    }
1098    out
1099}
1100
1101/// Process-wide dedup for the "missing codepoint" stderr warning per
1102/// FR-005 + Clarifications Q6. The first missing codepoint emits a
1103/// warning; subsequent missing codepoints are silently substituted.
1104static MISSING_GLYPH_WARNED: OnceLock<()> = OnceLock::new();
1105
1106fn warn_missing_codepoint(cp: u32) {
1107    if MISSING_GLYPH_WARNED.set(()).is_ok() {
1108        eprintln!(
1109            "rusty-figlet: codepoint U+{cp:04X} missing from font; substituting fallback glyph"
1110        );
1111    }
1112}
1113
1114/// Process-wide dedup for the "over-width word" stderr warning per
1115/// FR-025 + Clarifications Q6 + HINT-008. The first single word wider
1116/// than the resolved `-w` budget emits a warning; subsequent over-width
1117/// words are silently rendered at full glyph width.
1118static OVER_WIDTH_WARNED: OnceLock<()> = OnceLock::new();
1119
1120fn warn_over_width(word: &str, width: usize) {
1121    if OVER_WIDTH_WARNED.set(()).is_ok() {
1122        eprintln!(
1123            "rusty-figlet: '{word}' too wide for width {width}; emitting at full glyph width"
1124        );
1125    }
1126}
1127
1128/// A rendered ASCII-art banner.
1129///
1130/// `Banner` is a lazy line iterator (per FR-053) from the caller's
1131/// perspective: row buffers are computed once during
1132/// [`Figlet::render`], and each call to `next()` on the iterator
1133/// returned by [`Banner::lines`] yields one row.
1134///
1135/// `Banner` also implements [`core::fmt::Display`]; `write!(stdout,
1136/// "{banner}")` drives the same lazy iterator and emits a trailing `\n`
1137/// after the final line.
1138///
1139/// ```rust
1140/// use rusty_figlet::{FigletBuilder, Font};
1141///
1142/// let banner = FigletBuilder::new()
1143///     .font(Font::Standard)
1144///     .build()
1145///     .expect("build")
1146///     .render("X")
1147///     .expect("render");
1148/// // Iterate lazily; each .next() yields exactly one rendered row.
1149/// let mut it = banner.lines();
1150/// let _first = it.next();
1151/// ```
1152#[derive(Debug, Clone)]
1153pub struct Banner {
1154    rows: Vec<String>,
1155    height: u32,
1156}
1157
1158impl Banner {
1159    /// Return a lazy iterator yielding one rendered line per `.next()`.
1160    pub fn lines(&self) -> impl Iterator<Item = String> + '_ {
1161        self.rows.iter().cloned()
1162    }
1163
1164    /// The font's row count (height). Library callers occasionally want
1165    /// to know how many rows a banner contains without iterating.
1166    pub fn height(&self) -> u32 {
1167        self.height
1168    }
1169
1170    /// `true` when the banner produced no rendered rows (empty input).
1171    pub fn is_empty(&self) -> bool {
1172        self.rows.is_empty() || self.rows.iter().all(|r| r.is_empty())
1173    }
1174}
1175
1176impl core::fmt::Display for Banner {
1177    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1178        for line in self.lines() {
1179            writeln!(f, "{line}")?;
1180        }
1181        Ok(())
1182    }
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188    use static_assertions::assert_impl_all;
1189
1190    // SC-009: FigletError is Send + Sync + 'static so it crosses async
1191    // await + thread boundaries. The other public types are Send + Sync
1192    // but intentionally NOT `'static` because they may borrow from
1193    // caller-supplied input (`font_bytes(&[u8])`).
1194    assert_impl_all!(FigletBuilder: Send, Sync);
1195    assert_impl_all!(Figlet: Send, Sync);
1196    assert_impl_all!(Banner: Send, Sync);
1197    assert_impl_all!(FigletError: Send, Sync);
1198
1199    fn _figlet_error_is_static() {
1200        fn assert_static<T: 'static>() {}
1201        assert_static::<FigletError>();
1202    }
1203
1204    #[test]
1205    fn builder_default_font_is_standard() {
1206        let builder = FigletBuilder::new();
1207        match builder.source {
1208            FontSource::Bundled(Font::Standard) => {}
1209            _ => panic!("default font must be Standard"),
1210        }
1211    }
1212}