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}