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}