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;
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 layout;
62#[allow(dead_code)]
63mod mode;
64#[allow(dead_code)]
65mod smush;
66
67pub use layout::{JustifyFlag, JustifyFlags, LayoutFlag, LayoutFlags};
68
69// -----------------------------------------------------------------------------
70// Feature-gate map (per FR-008 + HINT-004 — module-level gates clustered here).
71// -----------------------------------------------------------------------------
72//   #[cfg(feature = "cli")]              → cli module (clap-derive scaffold)
73//   #[cfg(feature = "color")]            → color + output modules (anstyle + termcolor)
74//   #[cfg(feature = "terminal-width")]   → width module + resolve_width_for re-export
75//   #[cfg(feature = "strict-compat")]    → strict module (hand-rolled upstream parser)
76//
77// `rainbow` is a pure compile-flag leaf (no module of its own — it gates a
78// runtime branch inside src/main.rs). The `completions` leaf likewise gates
79// only the BinSubcommand::Completions dispatch arm in src/main.rs.
80// -----------------------------------------------------------------------------
81
82/// Hand-rolled Strict-mode argv parser (AD-007). Public so the
83/// `rusty-figlet` binary can dispatch to its byte-equal upstream
84/// diagnostics; the SemVer policy on this module's surface matches the
85/// rest of the public library API per FR-050. Gated by the
86/// `strict-compat` leaf (v0.2+).
87#[cfg(feature = "strict-compat")]
88#[allow(dead_code)]
89pub mod strict;
90
91#[cfg(feature = "cli")]
92#[allow(dead_code)]
93mod cli;
94/// Color/rainbow helpers (per AD-011 + AD-012 + HINT-006).
95///
96/// Exposed publicly for the `rusty-figlet` binary to consume; library
97/// callers SHOULD NOT depend on this module directly (it lives under the
98/// `color` leaf and is subject to change without a major version bump).
99#[cfg(feature = "color")]
100#[doc(hidden)]
101#[allow(dead_code)]
102pub mod color;
103/// Banner writer (per AD-011).
104///
105/// Exposed publicly for the `rusty-figlet` binary to consume; library
106/// callers SHOULD NOT depend on this module directly. Gated by the
107/// `color` leaf because the writer signature is parameterised over
108/// `termcolor::WriteColor`.
109#[cfg(feature = "color")]
110#[doc(hidden)]
111#[allow(dead_code)]
112pub mod output;
113#[cfg(feature = "terminal-width")]
114#[allow(dead_code)]
115mod width;
116
117/// Re-export of [`width::resolve_width`] for the rusty-figlet binary's
118/// CLI wiring path. Library consumers that need to resolve a width
119/// budget under the same precedence ladder may call this helper
120/// directly. Gated by the `terminal-width` leaf because the underlying
121/// lookup depends on `terminal_size`.
122#[cfg(feature = "terminal-width")]
123pub fn resolve_width_for(
124    explicit_w: Option<u32>,
125    use_t: bool,
126    columns_env: Option<u32>,
127    is_tty: bool,
128    mode: CompatibilityMode,
129) -> u32 {
130    width::resolve_width(explicit_w, use_t, columns_env, is_tty, mode)
131}
132
133/// Re-export of [`layout::resolve_justify`] for the rusty-figlet binary's
134/// CLI wiring path (T103 + T109). Translates a sequence of
135/// [`JustifyFlag`] occurrences into the resolved [`Justify`] value via
136/// last-wins semantics per FR-022.
137pub fn resolve_justify_for(flags: &JustifyFlags) -> Justify {
138    match layout::resolve_justify(flags) {
139        layout::Justify::Center => Justify::Center,
140        layout::Justify::Left => Justify::Left,
141        layout::Justify::Right => Justify::Right,
142        layout::Justify::FontDefault => Justify::FontDefault,
143    }
144}
145
146/// Compatibility mode that governs argv parsing + rendering rules.
147///
148/// In `Default` mode the CLI behaves like a modern Rust-native tool
149/// (UTF-8 input, color flags accepted, ergonomic clap diagnostics). In
150/// `Strict` mode the binary mirrors upstream `figlet 2.2.5` byte-for-byte
151/// (Latin-1 clamped input, color flags rejected, hand-rolled getopt-style
152/// diagnostics) so existing shell scripts that target upstream `figlet`
153/// run unmodified.
154///
155/// Marked `#[non_exhaustive]` so future modes (e.g. `Toilet`) remain a
156/// non-breaking addition.
157///
158/// ```rust
159/// use rusty_figlet::CompatibilityMode;
160///
161/// let mode = CompatibilityMode::default();
162/// assert_eq!(mode, CompatibilityMode::Default);
163/// ```
164#[non_exhaustive]
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166pub enum CompatibilityMode {
167    /// Modern, Rust-native behavior (UTF-8 input, color enabled, ergonomic
168    /// diagnostics).
169    Default,
170    /// Byte-equal upstream `figlet 2.2.5` behavior (Latin-1 input, color
171    /// flags rejected, getopt-style diagnostics).
172    Strict,
173}
174
175impl Default for CompatibilityMode {
176    fn default() -> Self {
177        Self::Default
178    }
179}
180
181/// Bundled-font selector and external-file escape hatch.
182///
183/// The 12 named variants correspond one-to-one to the bundled `.flf`
184/// assets shipped under `assets/fonts/` (AD-016 + FR-011). The
185/// [`Font::External`] variant covers `-f <path>` and `-d <dir>` resolution
186/// paths for user-supplied `.flf` files.
187///
188/// The enum is intentionally exhaustive: the bundled set is pinned for
189/// v0.1.0 SemVer. Adding a 13th bundled font would be a breaking change
190/// requiring a major bump.
191///
192/// ```rust
193/// use rusty_figlet::{FigletBuilder, Font};
194///
195/// // Pick one of the 12 bundled fonts.
196/// let _ = FigletBuilder::new().font(Font::Slant);
197///
198/// // Or load from disk via the External variant.
199/// let _ = FigletBuilder::new().font(Font::External("/tmp/my.flf".into()));
200/// ```
201#[derive(Debug, Clone, PartialEq, Eq, Hash)]
202pub enum Font {
203    /// `standard.flf` — the default FIGfont, used when no `-f` flag is set.
204    Standard,
205    /// `slant.flf`
206    Slant,
207    /// `small.flf`
208    Small,
209    /// `big.flf`
210    Big,
211    /// `mini.flf`
212    Mini,
213    /// `banner.flf`
214    Banner,
215    /// `block.flf`
216    Block,
217    /// `bubble.flf`
218    Bubble,
219    /// `digital.flf`
220    Digital,
221    /// `lean.flf`
222    Lean,
223    /// `script.flf`
224    Script,
225    /// `shadow.flf`
226    Shadow,
227    /// User-supplied `.flf` file resolved from a filesystem path.
228    External(PathBuf),
229}
230
231impl Font {
232    /// Returns the lowercase, suffix-stripped bundled-font name for the
233    /// 12 named variants. Returns `None` for [`Font::External`].
234    pub(crate) fn bundled_name(&self) -> Option<&'static str> {
235        Some(match self {
236            Font::Standard => "standard",
237            Font::Slant => "slant",
238            Font::Small => "small",
239            Font::Big => "big",
240            Font::Mini => "mini",
241            Font::Banner => "banner",
242            Font::Block => "block",
243            Font::Bubble => "bubble",
244            Font::Digital => "digital",
245            Font::Lean => "lean",
246            Font::Script => "script",
247            Font::Shadow => "shadow",
248            Font::External(_) => return None,
249        })
250    }
251}
252
253impl Default for Font {
254    fn default() -> Self {
255        Self::Standard
256    }
257}
258
259/// Source of the resolved `.flf` bytes that [`FigletBuilder::build`] will
260/// parse. Internal — used to express the "font_bytes wins over font" rule
261/// without leaking the enum to callers.
262#[derive(Debug, Clone)]
263enum FontSource {
264    /// One of the 12 bundled-font variants.
265    Bundled(Font),
266    /// User-supplied path resolved via [`figfont::resolve_font`].
267    External(PathBuf),
268    /// In-memory bytes supplied via [`FigletBuilder::font_bytes`].
269    Bytes(Vec<u8>),
270}
271
272/// Fluent builder for [`Figlet`] renderers.
273///
274/// Construct via [`FigletBuilder::new`] and chain configuration methods
275/// (`#[must_use]`); terminate with [`FigletBuilder::build`] to obtain a
276/// reusable [`Figlet`], or use [`FigletBuilder::render`] as a one-shot.
277///
278/// ```rust
279/// use rusty_figlet::{FigletBuilder, Font};
280///
281/// let figlet = FigletBuilder::new()
282///     .font(Font::Standard)
283///     .width(80)
284///     .build()
285///     .expect("build");
286/// let _banner = figlet.render("X").expect("render");
287/// ```
288#[derive(Debug, Clone)]
289pub struct FigletBuilder {
290    source: FontSource,
291    width: u32,
292    layout_override: Option<LayoutOverride>,
293    layout_flags: LayoutFlags,
294    justify: Option<Justify>,
295    font_dirs: Vec<PathBuf>,
296}
297
298/// Layout override carried through the builder. Internal — translated
299/// into a concrete `LayoutMode` at `build()` time once the font's default
300/// is known. Retained for backward-compatibility with the per-method
301/// `kerning()` / `full_width()` / `smush()` builders; the
302/// [`FigletBuilder::layout`] path supersedes this for full last-wins
303/// semantics across all six layout-class flags.
304#[derive(Debug, Clone, Copy)]
305enum LayoutOverride {
306    Kerning,
307    FullWidth,
308    ForceSmush,
309}
310
311/// Horizontal justification mode.
312///
313/// ```rust
314/// use rusty_figlet::{FigletBuilder, Justify};
315///
316/// let _ = FigletBuilder::new().justify(Justify::Center);
317/// ```
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
319pub enum Justify {
320    /// Center the rendered banner within the resolved width.
321    Center,
322    /// Left-align the rendered banner.
323    Left,
324    /// Right-align the rendered banner.
325    Right,
326    /// Use the font's print-direction default (LTR fonts default to Left).
327    FontDefault,
328}
329
330impl Default for FigletBuilder {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336impl FigletBuilder {
337    /// Construct a builder with all defaults:
338    ///
339    /// - font: [`Font::Standard`] (resolves to `standard.flf`)
340    /// - width: 80 columns
341    /// - layout: font-default
342    /// - justify: font-default
343    #[must_use]
344    pub fn new() -> Self {
345        Self {
346            source: FontSource::Bundled(Font::Standard),
347            width: 80,
348            layout_override: None,
349            layout_flags: LayoutFlags::default(),
350            justify: None,
351            font_dirs: Vec::new(),
352        }
353    }
354
355    /// Select a font.
356    ///
357    /// When `font` is one of the 12 bundled variants, [`build`](Self::build)
358    /// resolves the embedded `.flf` bytes via `include_bytes!`. When `font`
359    /// is [`Font::External`], the supplied path is resolved at `build()`
360    /// time. Default: [`Font::Standard`].
361    #[must_use]
362    pub fn font(mut self, font: Font) -> Self {
363        self.source = match font {
364            Font::External(path) => FontSource::External(path),
365            other => FontSource::Bundled(other),
366        };
367        self
368    }
369
370    /// Supply raw `.flf` bytes directly (no filesystem access; FR-052 +
371    /// FR-056). Overrides any prior [`font`](Self::font) call.
372    #[must_use]
373    pub fn font_bytes(mut self, bytes: &[u8]) -> Self {
374        self.source = FontSource::Bytes(bytes.to_vec());
375        self
376    }
377
378    /// Add an extra directory to search for [`Font::External`] resolutions
379    /// (CLI `-d <dir>` counterpart per FR-010). Repeatable; directories are
380    /// searched in the order added. Has no effect on bundled or
381    /// [`font_bytes`](Self::font_bytes) sources.
382    #[must_use]
383    pub fn font_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
384        self.font_dirs = dirs;
385        self
386    }
387
388    /// Set the output width budget in columns. Default: 80.
389    #[must_use]
390    pub fn width(mut self, cols: u32) -> Self {
391        self.width = cols;
392        self
393    }
394
395    /// Force horizontal kerning (`-k` CLI counterpart).
396    /// Overrides the font's default layout. Last layout-override wins.
397    #[must_use]
398    pub fn kerning(mut self) -> Self {
399        self.layout_override = Some(LayoutOverride::Kerning);
400        self
401    }
402
403    /// Force full-width layout (`-W` CLI counterpart).
404    /// Overrides the font's default layout. Last layout-override wins.
405    #[must_use]
406    pub fn full_width(mut self) -> Self {
407        self.layout_override = Some(LayoutOverride::FullWidth);
408        self
409    }
410
411    /// Force smushing per the font's smush bits (`-S` CLI counterpart).
412    /// Overrides the font's default layout. Last layout-override wins.
413    #[must_use]
414    pub fn smush(mut self) -> Self {
415        self.layout_override = Some(LayoutOverride::ForceSmush);
416        self
417    }
418
419    /// Apply a full sequence of layout-class flag occurrences with
420    /// last-wins semantics (FR-023). When non-empty, this sequence
421    /// supersedes any per-method [`kerning`](Self::kerning) /
422    /// [`full_width`](Self::full_width) / [`smush`](Self::smush)
423    /// override.
424    ///
425    /// ```rust
426    /// use rusty_figlet::{FigletBuilder, LayoutFlag, LayoutFlags};
427    ///
428    /// let flags = LayoutFlags {
429    ///     flags: vec![LayoutFlag::FullWidth, LayoutFlag::Kerning],
430    /// };
431    /// let _ = FigletBuilder::new().layout(flags);
432    /// ```
433    #[must_use]
434    pub fn layout(mut self, flags: LayoutFlags) -> Self {
435        self.layout_flags = flags;
436        self
437    }
438
439    /// Set the justification mode. Default: font's print-direction default.
440    #[must_use]
441    pub fn justify(mut self, j: Justify) -> Self {
442        self.justify = Some(j);
443        self
444    }
445
446    /// Resolve the font, parse the `.flf`, and build a reusable
447    /// [`Figlet`] renderer.
448    pub fn build(self) -> Result<Figlet, FigletError> {
449        let bytes = match self.source {
450            FontSource::Bundled(font) => {
451                let name = font
452                    .bundled_name()
453                    .ok_or(FigletError::Internal("Font::External missed bundled match"))?;
454                let slice =
455                    figfont::resolve_bundled(name).ok_or_else(|| FigletError::FontNotFound {
456                        name: name.to_owned(),
457                        searched: Vec::new(),
458                    })?;
459                slice.to_vec()
460            }
461            FontSource::External(path) => {
462                figfont::resolve_font(path.to_string_lossy().as_ref(), &self.font_dirs)?
463            }
464            FontSource::Bytes(bytes) => bytes,
465        };
466        let font = figfont::parse_bytes(&bytes)?;
467        Ok(Figlet {
468            font,
469            width: self.width,
470            layout_override: self.layout_override,
471            layout_flags: self.layout_flags,
472            justify: self.justify.unwrap_or(Justify::FontDefault),
473        })
474    }
475
476    /// Terminal convenience equivalent to `self.build()?.render(text)`.
477    pub fn render(self, text: &str) -> Result<Banner, FigletError> {
478        self.build()?.render(text)
479    }
480}
481
482/// A reusable renderer holding a parsed [`Font`] and resolved layout
483/// settings.
484///
485/// Cheap to clone; clone the [`Figlet`] across threads to render many
486/// banners concurrently with the same font configuration.
487///
488/// ```rust
489/// use rusty_figlet::{FigletBuilder, Font};
490///
491/// let figlet = FigletBuilder::new()
492///     .font(Font::Standard)
493///     .build()
494///     .expect("build");
495/// let banner = figlet.render("Hi").expect("render");
496/// assert!(banner.height() >= 1);
497/// ```
498#[derive(Debug, Clone)]
499pub struct Figlet {
500    font: figfont::FIGfont,
501    width: u32,
502    layout_override: Option<LayoutOverride>,
503    layout_flags: LayoutFlags,
504    justify: Justify,
505}
506
507impl Figlet {
508    /// Render `text` into a [`Banner`].
509    ///
510    /// The returned banner exposes a lazy line iterator (per FR-053): row
511    /// buffers are precomputed once during `render()`, and [`Banner::lines`]
512    /// yields one row per `next()` without copying the whole banner.
513    pub fn render(&self, text: &str) -> Result<Banner, FigletError> {
514        let layout = self.resolved_layout();
515        let rows = render_to_rows(&self.font, text, layout, self.width);
516        let rows = apply_justify(rows, self.justify, self.width, self.font.print_direction);
517        let rows = strip_hardblanks(rows, self.font.hardblank);
518        Ok(Banner {
519            rows,
520            height: self.font.height,
521        })
522    }
523
524    /// Translate the captured `layout_override` and/or `layout_flags`
525    /// (CLI `-k`/`-W`/`-S`/`-s`/`-o`/`-m N`) into a concrete
526    /// [`layout::LayoutMode`], falling back to the font's `full_layout`
527    /// default when no override is set.
528    ///
529    /// When [`FigletBuilder::layout`] has been used (non-empty
530    /// `layout_flags`), its sequence wins over any per-method
531    /// `kerning()` / `full_width()` / `smush()` setting; the
532    /// `LayoutResolver` then applies last-wins per FR-023.
533    fn resolved_layout(&self) -> layout::LayoutMode {
534        use layout::{LayoutFlag, LayoutFlags, LayoutResolver};
535        if !self.layout_flags.flags.is_empty() {
536            return LayoutResolver::resolve(&self.font, &self.layout_flags);
537        }
538        let mut flags = LayoutFlags::default();
539        if let Some(ov) = self.layout_override {
540            flags.flags.push(match ov {
541                LayoutOverride::Kerning => LayoutFlag::Kerning,
542                LayoutOverride::FullWidth => LayoutFlag::FullWidth,
543                LayoutOverride::ForceSmush => LayoutFlag::ForceSmush,
544            });
545        }
546        LayoutResolver::resolve(&self.font, &flags)
547    }
548}
549
550/// Render `text` into `height` row buffers using the resolved layout
551/// mode. Implements the per-row glyph accumulator described in T044
552/// with horizontal smushing per HINT-002 + AD-005 and word-wrap per
553/// HINT-008. Returns a `Vec<String>` of length `font.height`.
554fn render_to_rows(
555    font: &figfont::FIGfont,
556    text: &str,
557    layout: layout::LayoutMode,
558    width: u32,
559) -> Vec<String> {
560    let height = font.height.max(1) as usize;
561    if text.is_empty() {
562        return vec![String::new(); height];
563    }
564
565    // Word-wrap per HINT-008: split on ASCII whitespace, accumulate
566    // words into output lines whose post-smush width does not exceed
567    // `width`. Each line then renders into `height` rows; lines are
568    // separated by blank rows.
569    let words: Vec<&str> = text.split(' ').collect();
570    let target_width = width.max(1) as usize;
571
572    let mut all_rows: Vec<String> = vec![String::new(); height];
573    let mut current_rows: Vec<String> = vec![String::new(); height];
574    let mut current_visual_width: usize = 0;
575    let mut line_started = false;
576
577    for word in &words {
578        // Compute the prospective rows after appending this word (with
579        // a single space-separator glyph when the current line is
580        // already non-empty).
581        let mut probe = current_rows.clone();
582        let mut probe_width = current_visual_width;
583        if line_started {
584            append_codepoint(&mut probe, &mut probe_width, font, ' ' as u32, layout);
585        }
586        append_word(&mut probe, &mut probe_width, font, word, layout);
587
588        if probe_width <= target_width || !line_started {
589            // First word OR fits — commit the probe.
590            // FR-025 + HINT-008: if this is a single word on a fresh
591            // line AND it exceeds the target width, emit a one-time
592            // stderr warning per process. The word is still rendered
593            // at full glyph width (no mid-word break).
594            if !line_started && probe_width > target_width {
595                warn_over_width(word, target_width);
596            }
597            current_rows = probe;
598            current_visual_width = probe_width;
599            line_started = true;
600        } else {
601            // Flush current line, start new one with this word.
602            for (acc, line) in all_rows.iter_mut().zip(current_rows.iter()) {
603                if !acc.is_empty() {
604                    acc.push('\n');
605                }
606                acc.push_str(line);
607            }
608            current_rows = vec![String::new(); height];
609            current_visual_width = 0;
610            append_word(
611                &mut current_rows,
612                &mut current_visual_width,
613                font,
614                word,
615                layout,
616            );
617            // FR-025: a single word that overflows the budget on its
618            // own line also triggers the over-width warning.
619            if current_visual_width > target_width {
620                warn_over_width(word, target_width);
621            }
622        }
623    }
624
625    if line_started {
626        for (acc, line) in all_rows.iter_mut().zip(current_rows.iter()) {
627            if !acc.is_empty() {
628                acc.push('\n');
629            }
630            acc.push_str(line);
631        }
632    }
633
634    // Flatten the all_rows accumulator: each entry may contain N
635    // physical lines separated by `\n` (wrapped lines). For US1's
636    // single-banner-per-render contract we keep them as separate rows
637    // in the resulting Vec<String>: row 0 line 0, row 1 line 0, ...,
638    // row 0 line 1, row 1 line 1, ... Splitting by `\n` and
639    // interleaving handles the wrap case; for the common no-wrap path
640    // there are no `\n` chars and the vector is unchanged.
641    interleave_wrapped(all_rows, height)
642}
643
644fn append_word(
645    rows: &mut [String],
646    visual_width: &mut usize,
647    font: &figfont::FIGfont,
648    word: &str,
649    layout: layout::LayoutMode,
650) {
651    for ch in word.chars() {
652        append_codepoint(rows, visual_width, font, ch as u32, layout);
653    }
654}
655
656fn append_codepoint(
657    rows: &mut [String],
658    visual_width: &mut usize,
659    font: &figfont::FIGfont,
660    cp: u32,
661    layout: layout::LayoutMode,
662) {
663    let glyph = match figfont::lookup_codepoint(font, cp) {
664        Some(g) => g,
665        None => {
666            // HINT-009: substitute codepoint-0 missing-character glyph
667            // if present; else skip the char and emit a one-time stderr
668            // warning. The warning is deduplicated globally via a
669            // process-wide OnceLock so library callers don't pollute
670            // their stderr when the same CJK input is rendered twice.
671            warn_missing_codepoint(cp);
672            match figfont::lookup_codepoint(font, 0) {
673                Some(g) => g,
674                None => return,
675            }
676        }
677    };
678
679    merge_glyph(rows, visual_width, glyph, layout, font.hardblank);
680}
681
682fn merge_glyph(
683    rows: &mut [String],
684    visual_width: &mut usize,
685    glyph: &[String],
686    layout: layout::LayoutMode,
687    hardblank: char,
688) {
689    use layout::LayoutMode;
690
691    // Determine smush behavior per LayoutMode.
692    //
693    // FIGfont 2.0 semantics: bit 64 (RULE_HORIZONTAL_SMUSHING) enables
694    // smushing. The lower 6 bits select the active rules. When ANY of
695    // the lower 6 bits is set, those rules are exhaustive and the
696    // universal-fallback (right-wins) is NOT used. Universal-fallback
697    // applies only when smushing is enabled AND no specific rule bit
698    // is set (the "all six bits clear" case → `UniversalSmush`
699    // LayoutMode).
700    let (rules, allow_smush, allow_kerning_only) = match layout {
701        LayoutMode::FullWidth => (0u8, false, false),
702        LayoutMode::Kerning => (0u8, false, true),
703        LayoutMode::UniversalSmush => (smush::RULE_HORIZONTAL_SMUSHING, true, true),
704        LayoutMode::RuleSmush(bits) => {
705            // Mask off any spurious upper bits so callers can't
706            // accidentally re-enable universal-fallback via bit 64.
707            let only_rule_bits = bits & 0b0011_1111;
708            (only_rule_bits, true, true)
709        }
710        LayoutMode::OverlapOnly => (0u8, false, true),
711    };
712
713    let glyph_chars: Vec<Vec<char>> = glyph.iter().map(|s| s.chars().collect()).collect();
714    let glyph_width = glyph_chars.iter().map(|r| r.len()).max().unwrap_or(0);
715
716    if glyph_width == 0 {
717        return;
718    }
719
720    // FullWidth: no overlap, no smushing; just append.
721    if !allow_smush && !allow_kerning_only {
722        for (i, row) in rows.iter_mut().enumerate() {
723            if let Some(gr) = glyph_chars.get(i) {
724                for &c in gr {
725                    row.push(c);
726                }
727                // Pad short glyph rows out to glyph_width.
728                for _ in gr.len()..glyph_width {
729                    row.push(' ');
730                }
731            } else {
732                for _ in 0..glyph_width {
733                    row.push(' ');
734                }
735            }
736        }
737        *visual_width += glyph_width;
738        return;
739    }
740
741    // Determine the maximum overlap `k` (number of columns by which
742    // the glyph can shift left into the accumulator) such that every
743    // row still produces a legal smush/kerning result.
744    let row_chars: Vec<Vec<char>> = rows.iter().map(|s| s.chars().collect()).collect();
745    let acc_widths: Vec<usize> = row_chars.iter().map(|r| r.len()).collect();
746    let acc_min_width = acc_widths.iter().copied().min().unwrap_or(0);
747
748    let max_possible = acc_min_width.min(glyph_width);
749    let mut overlap = 0usize;
750    // For overlap == 0 we always append directly (legal). For larger
751    // overlaps we test each row.
752    'outer: for k in 1..=max_possible {
753        // Build merged-char arrays for each row at this overlap.
754        let mut row_merges: Vec<Vec<char>> = Vec::with_capacity(rows.len());
755        for (i, acc_row) in row_chars.iter().enumerate() {
756            let glyph_row = glyph_chars.get(i).cloned().unwrap_or_default();
757            // Overlapping columns: acc_row[acc.len()-k+j] vs glyph_row[j].
758            let mut merged = Vec::with_capacity(k);
759            for j in 0..k {
760                let l = acc_row.get(acc_row.len() - k + j).copied().unwrap_or(' ');
761                let r = glyph_row.get(j).copied().unwrap_or(' ');
762                match smush::smush_pair(l, r, rules, hardblank) {
763                    Some(c) => merged.push(c),
764                    None => {
765                        // No smush possible at this column → this overlap
766                        // is illegal. Roll back.
767                        break 'outer;
768                    }
769                }
770            }
771            row_merges.push(merged);
772        }
773        // All rows produced legal merges at this overlap; record and
774        // continue trying larger k.
775        overlap = k;
776        // Cache the merges by stashing them — we'll recompute on commit.
777        let _ = row_merges;
778    }
779
780    // Commit the chosen overlap.
781    for (i, row) in rows.iter_mut().enumerate() {
782        let acc_chars: Vec<char> = row.chars().collect();
783        let glyph_row: Vec<char> = glyph_chars.get(i).cloned().unwrap_or_default();
784        // Trim `overlap` cols off the accumulator and append merged + tail.
785        let keep = acc_chars.len().saturating_sub(overlap);
786        let mut new_row: String = acc_chars[..keep].iter().collect();
787        for j in 0..overlap {
788            let l = acc_chars.get(keep + j).copied().unwrap_or(' ');
789            let r = glyph_row.get(j).copied().unwrap_or(' ');
790            let merged = smush::smush_pair(l, r, rules, hardblank).unwrap_or(r);
791            new_row.push(merged);
792        }
793        for j in overlap..glyph_width {
794            new_row.push(glyph_row.get(j).copied().unwrap_or(' '));
795        }
796        *row = new_row;
797    }
798    *visual_width = visual_width
799        .saturating_add(glyph_width)
800        .saturating_sub(overlap);
801}
802
803fn interleave_wrapped(all_rows: Vec<String>, height: usize) -> Vec<String> {
804    // Each entry in `all_rows` is a `\n`-joined list of physical lines
805    // (one per wrap segment). If no entries contain `\n` the input is
806    // returned verbatim. Otherwise we re-interleave: for each wrap
807    // segment index, emit `height` rows in order.
808    let has_wrap = all_rows.iter().any(|r| r.contains('\n'));
809    if !has_wrap {
810        return all_rows;
811    }
812    let per_row: Vec<Vec<&str>> = all_rows.iter().map(|r| r.split('\n').collect()).collect();
813    let segments = per_row.first().map(Vec::len).unwrap_or(0);
814    let mut out: Vec<String> = Vec::with_capacity(height * segments);
815    for seg in 0..segments {
816        for row_lines in per_row.iter().take(height) {
817            let s = row_lines.get(seg).copied().unwrap_or("");
818            out.push(s.to_owned());
819        }
820        // No blank line between wrap segments — upstream figlet word-
821        // wrap concatenates the height-line blocks back-to-back. Banner
822        // separators (one blank line between distinct invocations) are
823        // inserted by the binary's stdin per-line loop instead.
824    }
825    out
826}
827
828fn apply_justify(
829    rows: Vec<String>,
830    justify: Justify,
831    width: u32,
832    print_direction: u32,
833) -> Vec<String> {
834    let effective = match justify {
835        Justify::Center => Justify::Center,
836        Justify::Left => Justify::Left,
837        Justify::Right => Justify::Right,
838        Justify::FontDefault => {
839            if print_direction == 1 {
840                Justify::Right
841            } else {
842                Justify::Left
843            }
844        }
845    };
846    let target = width as usize;
847    rows.into_iter()
848        .map(|line| match effective {
849            Justify::Left | Justify::FontDefault => line,
850            Justify::Center => {
851                let w = line.chars().count();
852                if w >= target {
853                    line
854                } else {
855                    let pad = (target - w) / 2;
856                    let mut out = String::with_capacity(target);
857                    for _ in 0..pad {
858                        out.push(' ');
859                    }
860                    out.push_str(&line);
861                    out
862                }
863            }
864            Justify::Right => {
865                let w = line.chars().count();
866                if w >= target {
867                    line
868                } else {
869                    let pad = target - w;
870                    let mut out = String::with_capacity(target);
871                    for _ in 0..pad {
872                        out.push(' ');
873                    }
874                    out.push_str(&line);
875                    out
876                }
877            }
878        })
879        .collect()
880}
881
882fn strip_hardblanks(rows: Vec<String>, hardblank: char) -> Vec<String> {
883    rows.into_iter()
884        .map(|line| line.replace(hardblank, " "))
885        .collect()
886}
887
888/// Clamp UTF-8 input down to Latin-1 (ISO-8859-1) bytes per FR-044.
889///
890/// In Strict mode the upstream `figlet(6)` binary treats every input
891/// byte as a Latin-1 codepoint (bytes 0..=255). This helper mirrors
892/// that semantics by mapping every input `char` whose value fits in
893/// `u8` (0..=255) to the equivalent single-byte Latin-1 codepoint and
894/// substituting multi-byte UTF-8 codepoints with the upstream-
895/// compatible `?` (0x3F) placeholder. The returned `Vec<u8>` can be
896/// passed verbatim to the figfont codepoint lookup (which already
897/// indexes by `u32`, so any byte 0..=255 round-trips cleanly).
898///
899/// HINT-009 explicitly excludes Strict mode from the UTF-8 missing-
900/// glyph fallback path because this clamp precedes lookup. See the
901/// BREAKING-CHANGE entry in `CHANGELOG.md` for the Default-mode UTF-8
902/// vs. Strict-mode Latin-1 divergence.
903pub fn clamp_input_latin1(input: &str) -> Vec<u8> {
904    let mut out = Vec::with_capacity(input.len());
905    for ch in input.chars() {
906        let cp = ch as u32;
907        if cp <= 0xFF {
908            out.push(cp as u8);
909        } else {
910            // Upstream figlet emits `?` for non-Latin-1 input bytes.
911            out.push(b'?');
912        }
913    }
914    out
915}
916
917/// Process-wide dedup for the "missing codepoint" stderr warning per
918/// FR-005 + Clarifications Q6. The first missing codepoint emits a
919/// warning; subsequent missing codepoints are silently substituted.
920static MISSING_GLYPH_WARNED: OnceLock<()> = OnceLock::new();
921
922fn warn_missing_codepoint(cp: u32) {
923    if MISSING_GLYPH_WARNED.set(()).is_ok() {
924        eprintln!(
925            "rusty-figlet: codepoint U+{cp:04X} missing from font; substituting fallback glyph"
926        );
927    }
928}
929
930/// Process-wide dedup for the "over-width word" stderr warning per
931/// FR-025 + Clarifications Q6 + HINT-008. The first single word wider
932/// than the resolved `-w` budget emits a warning; subsequent over-width
933/// words are silently rendered at full glyph width.
934static OVER_WIDTH_WARNED: OnceLock<()> = OnceLock::new();
935
936fn warn_over_width(word: &str, width: usize) {
937    if OVER_WIDTH_WARNED.set(()).is_ok() {
938        eprintln!(
939            "rusty-figlet: '{word}' too wide for width {width}; emitting at full glyph width"
940        );
941    }
942}
943
944/// A rendered ASCII-art banner.
945///
946/// `Banner` is a lazy line iterator (per FR-053) from the caller's
947/// perspective: row buffers are computed once during
948/// [`Figlet::render`], and each call to `next()` on the iterator
949/// returned by [`Banner::lines`] yields one row.
950///
951/// `Banner` also implements [`core::fmt::Display`]; `write!(stdout,
952/// "{banner}")` drives the same lazy iterator and emits a trailing `\n`
953/// after the final line.
954///
955/// ```rust
956/// use rusty_figlet::{FigletBuilder, Font};
957///
958/// let banner = FigletBuilder::new()
959///     .font(Font::Standard)
960///     .build()
961///     .expect("build")
962///     .render("X")
963///     .expect("render");
964/// // Iterate lazily; each .next() yields exactly one rendered row.
965/// let mut it = banner.lines();
966/// let _first = it.next();
967/// ```
968#[derive(Debug, Clone)]
969pub struct Banner {
970    rows: Vec<String>,
971    height: u32,
972}
973
974impl Banner {
975    /// Return a lazy iterator yielding one rendered line per `.next()`.
976    pub fn lines(&self) -> impl Iterator<Item = String> + '_ {
977        self.rows.iter().cloned()
978    }
979
980    /// The font's row count (height). Library callers occasionally want
981    /// to know how many rows a banner contains without iterating.
982    pub fn height(&self) -> u32 {
983        self.height
984    }
985
986    /// `true` when the banner produced no rendered rows (empty input).
987    pub fn is_empty(&self) -> bool {
988        self.rows.is_empty() || self.rows.iter().all(|r| r.is_empty())
989    }
990}
991
992impl core::fmt::Display for Banner {
993    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
994        for line in self.lines() {
995            writeln!(f, "{line}")?;
996        }
997        Ok(())
998    }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004    use static_assertions::assert_impl_all;
1005
1006    // SC-009: FigletError is Send + Sync + 'static so it crosses async
1007    // await + thread boundaries. The other public types are Send + Sync
1008    // but intentionally NOT `'static` because they may borrow from
1009    // caller-supplied input (`font_bytes(&[u8])`).
1010    assert_impl_all!(FigletBuilder: Send, Sync);
1011    assert_impl_all!(Figlet: Send, Sync);
1012    assert_impl_all!(Banner: Send, Sync);
1013    assert_impl_all!(FigletError: Send, Sync);
1014
1015    fn _figlet_error_is_static() {
1016        fn assert_static<T: 'static>() {}
1017        assert_static::<FigletError>();
1018    }
1019
1020    #[test]
1021    fn builder_default_font_is_standard() {
1022        let builder = FigletBuilder::new();
1023        match builder.source {
1024            FontSource::Bundled(Font::Standard) => {}
1025            _ => panic!("default font must be Standard"),
1026        }
1027    }
1028}