Skip to main content

rusty_figlet/export/
mod.rs

1//! Multi-format export backends (E012 US2 — FR-005, Phase 7).
2//!
3//! ## Backends
4//!
5//! Each backend is gated behind a distinct `output-<format>` Cargo leaf
6//! per ADR-0006:
7//!
8//! | Format    | Leaf            | Module                      |
9//! |-----------|-----------------|-----------------------------|
10//! | HTML5     | `output-html`   | [`html`]                    |
11//! | mIRC `^C` | `output-irc`    | [`irc`]                     |
12//! | SVG 1.1   | `output-svg`    | [`svg`]                     |
13//! | ANSI 16   | (always)        | (renders via [`crate::color_depth`]) |
14//! | ANSI 256  | `color-256`     | (renders via [`crate::color_depth`]) |
15//! | ANSI 24bit| `color-truecolor` | (renders via [`crate::color_depth`]) |
16//!
17//! ## Dispatch
18//!
19//! [`write_export`] receives a [`crate::filter::RenderGrid`] and an
20//! [`ExportFormat`] and dispatches to the appropriate backend, returning
21//! a `Vec<u8>` with the encoded bytes. When the requested format's leaf
22//! is disabled at compile time, [`FigletError::UnsupportedExportFormat`]
23//! is returned with the full list of available formats so the CLI can
24//! produce a useful diagnostic per FR-016.
25//!
26//! ## Security
27//!
28//! - HTML/SVG: 4-char escape applied to every text-content and double-
29//!   quoted-attribute byte per AD-004 + HINT-004 — see [`html`] module
30//!   docs for the exact set + reasoning.
31//! - IRC: ASCII C0/C1 non-printable bytes stripped per FR-015; UTF-8
32//!   continuation bytes preserved per spec Edge Cases — see [`irc`]
33//!   module docs.
34//! - SVG: NO `<script>`, `<foreignObject>`, `xlink:href`, `<image href=...>`,
35//!   `<use xlink:href=...>` emission. Inline `style="..."` IS emitted
36//!   but contains only typed numeric `#RRGGBB` values — no user bytes
37//!   ever flow into a `style` value per spec Edge Cases.
38
39use crate::error::FigletError;
40use crate::filter::RenderGrid;
41
42#[cfg(feature = "output-html")]
43pub mod html;
44
45#[cfg(feature = "output-irc")]
46pub mod irc;
47
48#[cfg(feature = "output-svg")]
49pub mod svg;
50
51// Helpers shared between the HTML and SVG backends (both consume the
52// 4-char escape table and the typed Color → #RRGGBB conversion).
53// Compiled whenever either leaf is enabled.
54#[cfg(any(feature = "output-html", feature = "output-svg"))]
55mod common;
56
57/// Supported export formats (E012 US2 — FR-005).
58///
59/// The enum is `#[non_exhaustive]` so additive variants (e.g., terminfo,
60/// PNG via a future raster crate) remain non-breaking under SemVer.
61#[non_exhaustive]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum ExportFormat {
64    /// HTML5 `<pre><span style="color:#RRGGBB">...</span></pre>`.
65    Html,
66    /// mIRC `^C` color codes embedded inline with the text.
67    Irc,
68    /// SVG 1.1 `<text>` elements with `fill="#RRGGBB"`.
69    Svg,
70    /// ANSI 24-bit truecolor SGR (`\x1b[38;2;R;G;Bm`).
71    AnsiTrue,
72    /// ANSI 256-color SGR (`\x1b[38;5;Nm`).
73    Ansi256,
74    /// ANSI 16-color named SGR.
75    Ansi16,
76}
77
78impl ExportFormat {
79    /// Canonical lowercase name for CLI parsing / error diagnostics.
80    #[must_use]
81    pub const fn name(&self) -> &'static str {
82        match self {
83            ExportFormat::Html => "html",
84            ExportFormat::Irc => "irc",
85            ExportFormat::Svg => "svg",
86            ExportFormat::AnsiTrue => "ansi-true",
87            ExportFormat::Ansi256 => "ansi-256",
88            ExportFormat::Ansi16 => "ansi-16",
89        }
90    }
91}
92
93/// Dispatch a [`RenderGrid`] to the requested [`ExportFormat`] backend.
94///
95/// Returns [`FigletError::UnsupportedExportFormat`] when the requested
96/// format's leaf is disabled at compile time; the `available` field
97/// enumerates the format names that ARE compiled into this build.
98pub fn write_export(grid: &RenderGrid, fmt: ExportFormat) -> Result<Vec<u8>, FigletError> {
99    // When all backend leaves are disabled the `grid` binding is unused.
100    let _ = grid;
101    match fmt {
102        #[cfg(feature = "output-html")]
103        ExportFormat::Html => Ok(html::write_html(grid).into_bytes()),
104        #[cfg(not(feature = "output-html"))]
105        ExportFormat::Html => Err(unsupported("html")),
106
107        #[cfg(feature = "output-irc")]
108        ExportFormat::Irc => Ok(irc::write_irc(grid, false)),
109        #[cfg(not(feature = "output-irc"))]
110        ExportFormat::Irc => Err(unsupported("irc")),
111
112        #[cfg(feature = "output-svg")]
113        ExportFormat::Svg => Ok(svg::write_svg(grid).into_bytes()),
114        #[cfg(not(feature = "output-svg"))]
115        ExportFormat::Svg => Err(unsupported("svg")),
116
117        // ANSI dispatches are gated by the existing color depth leaves;
118        // implementation lives in `crate::color_depth`. For Phase 7 we
119        // expose only HTML/IRC/SVG. ANSI exports are wired via the
120        // existing `output` module in Phase 9 (T061).
121        ExportFormat::AnsiTrue | ExportFormat::Ansi256 | ExportFormat::Ansi16 => {
122            Err(unsupported(fmt.name()))
123        }
124    }
125}
126
127/// Construct a [`FigletError::UnsupportedExportFormat`] populated with
128/// the list of formats whose leaves are enabled in this build.
129fn unsupported(requested: &str) -> FigletError {
130    let available: Vec<String> = available_format_names();
131    FigletError::UnsupportedExportFormat {
132        requested: requested.to_owned(),
133        available,
134    }
135}
136
137/// Build the list of export format names whose leaves are enabled in this
138/// build. Used by `unsupported` to populate the diagnostic. Split into a
139/// separate function so the per-leaf cfg branches don't trigger the
140/// `vec_init_then_push` lint (different leaves yield different lengths).
141fn available_format_names() -> Vec<String> {
142    #[allow(unused_mut)]
143    let mut v: Vec<String> = Vec::with_capacity(3);
144    #[cfg(feature = "output-html")]
145    {
146        v.push("html".to_owned());
147    }
148    #[cfg(feature = "output-irc")]
149    {
150        v.push("irc".to_owned());
151    }
152    #[cfg(feature = "output-svg")]
153    {
154        v.push("svg".to_owned());
155    }
156    v
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn export_format_names_are_canonical() {
165        assert_eq!(ExportFormat::Html.name(), "html");
166        assert_eq!(ExportFormat::Irc.name(), "irc");
167        assert_eq!(ExportFormat::Svg.name(), "svg");
168    }
169
170    #[test]
171    fn dispatch_returns_unsupported_for_ansi_in_phase7() {
172        let grid = RenderGrid::blank(1, 1);
173        let err = write_export(&grid, ExportFormat::AnsiTrue).unwrap_err();
174        match err {
175            FigletError::UnsupportedExportFormat { requested, .. } => {
176                assert_eq!(requested, "ansi-true");
177            }
178            other => panic!("expected UnsupportedExportFormat, got {other:?}"),
179        }
180    }
181}