Skip to main content

rusty_figlet/export/
irc.rs

1//! mIRC `^C` color-code export backend (E012 US2 — FR-006, FR-015, FR-027).
2//!
3//! ## Format
4//!
5//! mIRC encodes colors as a `^C` (0x03) prefix followed by 1-2 decimal
6//! digits selecting the foreground palette index from the 16-color
7//! standard table (0..=15). Optionally a `,BB` suffix selects the
8//! background. Reset is `^O` (0x0F).
9//!
10//! ## Non-printable stripping (FR-015)
11//!
12//! ASCII C0 control bytes (`0x00..=0x1F` except `0x09` tab) and the
13//! C1 range (`0x7F..=0x9F`) are stripped from cell text per FR-015.
14//! UTF-8 multibyte continuation bytes (`0x80..=0xBF`) are PRESERVED
15//! when they're part of a valid UTF-8 sequence — we operate on `&str`,
16//! not `&[u8]`, so the standard library has already validated UTF-8
17//! boundaries before we see the input. The C1 range only intersects
18//! the continuation range, but bytes that appear as the *start* of
19//! a multi-byte sequence won't be in 0x80..=0x9F (UTF-8 leaders are
20//! 0xC2..=0xF4).
21//!
22//! In practical terms: iterating `.chars()` gives us validated
23//! codepoints, and we strip the BMP code-point if `(c as u32) < 0x20
24//! && c != '\t'` OR `c == '\x7F'` OR `(c as u32 >= 0x80 && c as u32 <= 0x9F)`.
25//!
26//! ## Pre-sized writer (FR-027)
27//!
28//! Single-pass: we iterate cells once and emit both color codes and
29//! text bytes in the same loop. `Vec<u8>::with_capacity(w * h * 6)`
30//! covers `^C99,99X` (the longest per-cell encoding).
31//!
32//! ## Warn-on-strip
33//!
34//! The CLI flag `--warn-irc-strip` is wired in Phase 9 (T060). This
35//! module accepts a boolean `warn_on_strip` parameter; when true the
36//! function emits a single deduplicated stderr warning if any byte
37//! was stripped during this call.
38
39use crate::filter::{Cell, Color, NamedColor, RenderGrid};
40
41/// mIRC color reset byte (`^O`).
42const IRC_RESET: u8 = 0x0F;
43/// mIRC color prefix byte (`^C`).
44const IRC_COLOR: u8 = 0x03;
45
46/// Encode `grid` as a sequence of mIRC `^C` color-coded bytes per
47/// FR-006 + FR-015 + FR-027.
48///
49/// `warn_on_strip = true` emits a single stderr warning if any byte
50/// was stripped per the non-printable filter. The strip is silent by
51/// default; the CLI exposes this knob via `--warn-irc-strip` in Phase 9.
52#[must_use]
53pub fn write_irc(grid: &RenderGrid, warn_on_strip: bool) -> Vec<u8> {
54    let w = grid.width as usize;
55    let h = grid.height as usize;
56    let capacity = w.saturating_mul(h).saturating_mul(6).saturating_add(64);
57    let mut out: Vec<u8> = Vec::with_capacity(capacity);
58    let mut stripped_any = false;
59    let mut prev_color: Option<Color> = None;
60
61    for row in &grid.cells {
62        for cell in row {
63            // Emit color change only when the color differs from the
64            // previous cell — keeps output compact and avoids ^C ^C ^C
65            // noise from spans of same-color cells.
66            if prev_color != Some(cell.fg) {
67                out.push(IRC_COLOR);
68                let palette_idx = color_to_mirc_index(cell.fg);
69                push_decimal(&mut out, palette_idx);
70                prev_color = Some(cell.fg);
71            }
72            // Strip + emit the cell glyph.
73            if char_is_irc_safe(cell.ch) {
74                push_utf8(&mut out, cell.ch);
75            } else {
76                stripped_any = true;
77            }
78        }
79        // End of row: reset and emit newline.
80        out.push(IRC_RESET);
81        out.push(b'\n');
82        prev_color = None;
83    }
84
85    if stripped_any && warn_on_strip {
86        emit_strip_warning();
87    }
88
89    out
90}
91
92/// Predicate: is the codepoint emit-safe for mIRC per FR-015?
93///
94/// Returns `false` for ASCII C0 (`0x00..=0x1F` except `0x09` tab),
95/// for DEL (`0x7F`), and for the C1 range (`0x80..=0x9F`).
96/// Multi-byte UTF-8 codepoints above U+009F pass through (CJK, emoji,
97/// RTL scripts — all preserved per spec Edge Cases).
98fn char_is_irc_safe(c: char) -> bool {
99    let cp = c as u32;
100    if c == '\t' {
101        return true;
102    }
103    if cp < 0x20 {
104        return false;
105    }
106    if cp == 0x7F {
107        return false;
108    }
109    if (0x80..=0x9F).contains(&cp) {
110        return false;
111    }
112    true
113}
114
115/// Map a typed [`Color`] to an mIRC 0..=15 palette index.
116///
117/// mIRC has only 16 colors; truecolor/256-color inputs are mapped down
118/// to the closest named color slot. The mapping mirrors the standard
119/// mIRC palette table (white=0, black=1, blue=2, green=3, red=4, ...).
120fn color_to_mirc_index(c: Color) -> u8 {
121    match c {
122        Color::Named(n) => named_to_mirc(n),
123        Color::Index(idx) => {
124            // 256-color → coarse downsample: use the first 16 entries
125            // verbatim, else fall back to white (0).
126            if idx < 16 {
127                named_to_mirc(palette16_to_named(idx))
128            } else {
129                0
130            }
131        }
132        Color::Rgb(_, _, _) => {
133            // Truecolor → mIRC has no 24-bit support; mIRC sees the
134            // color as default white. A full downsample would require
135            // Lab distance — out of scope for v0.3.0.
136            0
137        }
138    }
139}
140
141/// Standard mIRC palette positions per the v1.0 mIRC color spec.
142fn named_to_mirc(n: NamedColor) -> u8 {
143    match n {
144        NamedColor::White => 0,
145        NamedColor::Black => 1,
146        NamedColor::Blue => 2,
147        NamedColor::Green => 3,
148        NamedColor::Red => 4,
149        NamedColor::BrightRed => 4,
150        NamedColor::Magenta => 6,
151        NamedColor::Yellow => 8,
152        NamedColor::BrightYellow => 8,
153        NamedColor::BrightGreen => 9,
154        NamedColor::Cyan => 10,
155        NamedColor::BrightCyan => 11,
156        NamedColor::BrightBlue => 12,
157        NamedColor::BrightMagenta => 13,
158        NamedColor::BrightBlack => 14,
159        NamedColor::BrightWhite => 15,
160    }
161}
162
163fn palette16_to_named(idx: u8) -> NamedColor {
164    match idx {
165        0 => NamedColor::Black,
166        1 => NamedColor::Red,
167        2 => NamedColor::Green,
168        3 => NamedColor::Yellow,
169        4 => NamedColor::Blue,
170        5 => NamedColor::Magenta,
171        6 => NamedColor::Cyan,
172        7 => NamedColor::White,
173        8 => NamedColor::BrightBlack,
174        9 => NamedColor::BrightRed,
175        10 => NamedColor::BrightGreen,
176        11 => NamedColor::BrightYellow,
177        12 => NamedColor::BrightBlue,
178        13 => NamedColor::BrightMagenta,
179        14 => NamedColor::BrightCyan,
180        _ => NamedColor::BrightWhite,
181    }
182}
183
184fn push_decimal(out: &mut Vec<u8>, n: u8) {
185    if n >= 10 {
186        out.push((n / 10) + b'0');
187        out.push((n % 10) + b'0');
188    } else {
189        // mIRC permits both 1- and 2-digit codes; we use 2 digits for
190        // 10..=15 only.
191        out.push(n + b'0');
192    }
193}
194
195fn push_utf8(out: &mut Vec<u8>, c: char) {
196    let mut buf = [0u8; 4];
197    let s = c.encode_utf8(&mut buf);
198    out.extend_from_slice(s.as_bytes());
199}
200
201#[cold]
202#[inline(never)]
203fn emit_strip_warning() {
204    eprintln!("rusty-figlet: IRC export stripped non-printable bytes");
205}
206
207#[allow(dead_code)]
208fn _suppress_unused(_: Cell) {}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::filter::{Cell, Color, NamedColor, RenderGrid};
214
215    #[test]
216    fn empty_grid_returns_empty_bytes() {
217        let grid = RenderGrid::empty();
218        let out = write_irc(&grid, false);
219        assert!(out.is_empty());
220    }
221
222    #[test]
223    fn plain_ascii_row_has_color_prefix_and_reset() {
224        let grid = RenderGrid::from_text_rows(&[String::from("Hi")]);
225        let out = write_irc(&grid, false);
226        assert!(out.contains(&IRC_COLOR));
227        assert!(out.contains(&IRC_RESET));
228        assert!(out.contains(&b'H'));
229        assert!(out.contains(&b'i'));
230    }
231
232    #[test]
233    fn strips_c0_controls_except_tab() {
234        let row = vec![
235            Cell::new('\x00'),
236            Cell::new('\x01'),
237            Cell::new('\t'),
238            Cell::new('A'),
239        ];
240        let grid = RenderGrid::from_rows(vec![row]);
241        let out = write_irc(&grid, false);
242        // Output should contain 'A' and '\t' but not 0x00 or 0x01.
243        assert!(out.contains(&b'A'));
244        assert!(out.contains(&b'\t'));
245        assert!(!out.contains(&0x00));
246        assert!(!out.contains(&0x01));
247    }
248
249    #[test]
250    fn strips_del_byte() {
251        let grid = RenderGrid::from_rows(vec![vec![Cell::new('\x7F'), Cell::new('B')]]);
252        let out = write_irc(&grid, false);
253        assert!(!out.contains(&0x7F));
254        assert!(out.contains(&b'B'));
255    }
256
257    #[test]
258    fn strips_c1_range() {
259        // U+0085 is in C1.
260        let grid = RenderGrid::from_rows(vec![vec![Cell::new('\u{0085}'), Cell::new('C')]]);
261        let out = write_irc(&grid, false);
262        assert!(out.contains(&b'C'));
263        // Output should not contain the C1 byte.
264        assert!(!out.contains(&0x85));
265    }
266
267    #[test]
268    fn preserves_utf8_multibyte_cjk() {
269        let grid = RenderGrid::from_text_rows(&[String::from("中")]);
270        let out = write_irc(&grid, false);
271        // U+4E2D = E4 B8 AD in UTF-8 — all three bytes should be present.
272        assert!(out.contains(&0xE4));
273        assert!(out.contains(&0xB8));
274        assert!(out.contains(&0xAD));
275    }
276
277    #[test]
278    fn preserves_utf8_emoji() {
279        let grid = RenderGrid::from_text_rows(&[String::from("🦀")]);
280        let out = write_irc(&grid, false);
281        // U+1F980 = F0 9F A6 80 in UTF-8.
282        assert!(out.contains(&0xF0));
283        assert!(out.contains(&0x9F));
284    }
285
286    #[test]
287    fn named_color_maps_to_palette_idx() {
288        let cell = Cell {
289            ch: 'X',
290            fg: Color::Named(NamedColor::Red),
291            bg: None,
292            attrs: 0,
293        };
294        let grid = RenderGrid::from_rows(vec![vec![cell]]);
295        let out = write_irc(&grid, false);
296        // After ^C, the palette index should be the digit '4' (Red=4).
297        let prefix_pos = out.iter().position(|&b| b == IRC_COLOR).expect("has ^C");
298        assert_eq!(out[prefix_pos + 1], b'4');
299    }
300
301    #[test]
302    fn truecolor_falls_back_to_white_index() {
303        let cell = Cell {
304            ch: 'Y',
305            fg: Color::Rgb(123, 45, 67),
306            bg: None,
307            attrs: 0,
308        };
309        let grid = RenderGrid::from_rows(vec![vec![cell]]);
310        let out = write_irc(&grid, false);
311        let prefix_pos = out.iter().position(|&b| b == IRC_COLOR).expect("has ^C");
312        assert_eq!(out[prefix_pos + 1], b'0');
313    }
314
315    #[test]
316    fn row_ends_with_reset_and_newline() {
317        let grid = RenderGrid::from_text_rows(&[String::from("A")]);
318        let out = write_irc(&grid, false);
319        assert_eq!(out[out.len() - 2], IRC_RESET);
320        assert_eq!(out[out.len() - 1], b'\n');
321    }
322}