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}