Skip to main content

badterm_core/
export.rs

1//! Turning screen contents back into files or clipboard text: plain text,
2//! and ANSI as BBS art viewers and editors expect it (CP437 bytes for `.ans`
3//! files, or Unicode for pasting).
4
5use crate::codepage::CodePage;
6use crate::cp437;
7use crate::palette::ANSI_TO_VGA;
8use crate::screen::{Cell, Color, DEFAULT_BG, DEFAULT_FG, flags};
9
10/// Plain text, one line per row, trailing blanks and blank lines trimmed.
11pub fn text(lines: &[&[Cell]]) -> String {
12    text_in(lines, None)
13}
14
15/// [`text`], from a screen in a code page other than CP437.
16pub fn text_in(lines: &[&[Cell]], codepage: Option<&CodePage>) -> String {
17    let mut out: Vec<String> = lines
18        .iter()
19        .map(|row| row.iter().map(|c| glyph(c.ch, codepage)).collect::<String>().trim_end().to_string())
20        .collect();
21    while out.last().is_some_and(String::is_empty) {
22        out.pop();
23    }
24    let mut s = out.join("\n");
25    if !s.is_empty() {
26        s.push('\n');
27    }
28    s
29}
30
31/// Options for [`ansi`].
32#[derive(Clone, Copy, Debug, Default)]
33pub struct AnsiOptions {
34    /// The art uses iCE colors (blink means bright background): start with
35    /// `CSI ?33h` so viewers that know it switch modes.
36    pub ice: bool,
37    /// Write glyphs as UTF-8 instead of CP437 bytes (for the clipboard).
38    pub unicode: bool,
39    /// The code page the screen is in, if not CP437 (for `unicode`).
40    pub codepage: Option<&'static CodePage>,
41}
42
43/// ANSI with SGR attributes, one CRLF-terminated line per row. Trailing
44/// blanks on each line and trailing blank lines are left out.
45pub fn ansi(lines: &[&[Cell]], opts: AnsiOptions) -> Vec<u8> {
46    let mut out = Vec::new();
47    if opts.ice {
48        out.extend_from_slice(b"\x1b[?33h");
49    }
50    out.extend_from_slice(b"\x1b[0m");
51    let last = lines.iter().rposition(|row| row.iter().any(|c| !blank(c))).map_or(0, |i| i + 1);
52    let default = Cell::default();
53    for row in &lines[..last] {
54        let end = row.iter().rposition(|c| !blank(c)).map_or(0, |i| i + 1);
55        let mut pen = default;
56        for c in &row[..end] {
57            if !same_attrs(c, &pen) {
58                sgr(c, &mut out);
59                pen = *c;
60            }
61            if opts.unicode {
62                let mut buf = [0; 4];
63                out.extend_from_slice(glyph(c.ch, opts.codepage).encode_utf8(&mut buf).as_bytes());
64            } else {
65                out.push(safe_byte(c.ch));
66            }
67        }
68        if !same_attrs(&pen, &default) {
69            out.extend_from_slice(b"\x1b[0m");
70        }
71        out.extend_from_slice(b"\r\n");
72    }
73    out
74}
75
76/// A glyph as Unicode: in the code page, if there is one and it has a
77/// character there (ISO-8859's 0x80-0x9F are controls: CP437's glyph then).
78fn glyph(ch: u8, codepage: Option<&CodePage>) -> char {
79    match codepage.map(|cp| cp.decode(ch)) {
80        // A C64 or Atari screen: every code is a character.
81        Some(c) if codepage.is_some_and(|cp| cp.screen_codes()) => {
82            if c == '\u{a0}' {
83                ' '
84            } else {
85                c
86            }
87        }
88        _ if ch == 0 => ' ',
89        Some(c) if ch >= 0x80 && !c.is_control() && c != '\u{fffd}' => c,
90        _ => cp437::to_unicode(ch),
91    }
92}
93
94/// CP437 glyphs that would act as control codes in a file are written as
95/// spaces: NUL, BEL, BS, TAB, LF, FF, CR, SUB (end of file before a SAUCE
96/// record) and ESC.
97fn safe_byte(ch: u8) -> u8 {
98    match ch {
99        0 | 7 | 8 | 9 | 10 | 12 | 13 | 26 | 27 => b' ',
100        c => c,
101    }
102}
103
104fn blank(c: &Cell) -> bool {
105    c.is_blank() && c.flags & (flags::UNDERLINE | flags::BLINK) == 0
106}
107
108fn same_attrs(a: &Cell, b: &Cell) -> bool {
109    a.fg == b.fg && a.bg == b.bg && a.flags == b.flags
110}
111
112/// Write the attributes of `c` from scratch (`CSI 0;...m`).
113fn sgr(c: &Cell, out: &mut Vec<u8>) {
114    let mut p: Vec<String> = vec!["0".into()];
115    let f = c.flags;
116    // Bright PC colors are written the classic way, as bold, since that is
117    // what BBS art viewers understand.
118    let bright_fg = matches!(c.fg, Color::Pc(i) if i >= 8);
119    for (bit, code) in
120        [(flags::BOLD, "1"), (flags::FAINT, "2"), (flags::UNDERLINE, "4"), (flags::BLINK, "5"), (flags::REVERSE, "7")]
121    {
122        if f & bit != 0 || bit == flags::BOLD && bright_fg {
123            p.push(code.into());
124        }
125    }
126    if f & flags::CONCEAL != 0 {
127        p.push("8".into());
128    }
129    match c.fg {
130        DEFAULT_FG => {}
131        Color::Pc(i) | Color::Ui(i) => p.push(format!("{}", 30 + vga_to_ansi(i & 7))),
132        Color::Xterm(i) => p.push(format!("38;5;{i}")),
133        Color::Rgb(rgb) => p.push(format!("38;2;{};{};{}", rgb >> 16 & 0xFF, rgb >> 8 & 0xFF, rgb & 0xFF)),
134    }
135    match c.bg {
136        DEFAULT_BG => {}
137        Color::Pc(i) | Color::Ui(i) if i >= 8 => p.push(format!("{}", 100 + vga_to_ansi(i & 7))),
138        Color::Pc(i) | Color::Ui(i) => p.push(format!("{}", 40 + vga_to_ansi(i & 7))),
139        Color::Xterm(i) => p.push(format!("48;5;{i}")),
140        Color::Rgb(rgb) => p.push(format!("48;2;{};{};{}", rgb >> 16 & 0xFF, rgb >> 8 & 0xFF, rgb & 0xFF)),
141    }
142    out.extend_from_slice(format!("\x1b[{}m", p.join(";")).as_bytes());
143}
144
145fn vga_to_ansi(i: u8) -> u8 {
146    // The mapping swaps red and blue (and cyan and brown), so it is its own
147    // inverse.
148    ANSI_TO_VGA[i as usize & 7]
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::ansi::Terminal;
155
156    fn rows(t: &Terminal) -> Vec<&[Cell]> {
157        (0..t.screen.rows()).map(|y| t.screen.row(y)).collect()
158    }
159
160    #[test]
161    fn plain_text() {
162        let mut t = Terminal::new(20, 5, 0);
163        t.feed(b"\x1b[1;31mHello\x1b[0m  \r\n\r\n\xc9\xcd\xbb\r\n");
164        assert_eq!(text(&rows(&t)), "Hello\n\n\u{2554}\u{2550}\u{2557}\n");
165    }
166
167    #[test]
168    fn ansi_round_trips_through_the_terminal() {
169        let mut t = Terminal::new(40, 6, 0);
170        t.feed(b"\x1b[1;33;44mBold yellow on blue\x1b[0m plain\r\n");
171        t.feed(b"\x1b[5;31;47mblink\x1b[0m \x1b[38;5;196mxterm\x1b[38;2;1;2;3m rgb\x1b[0m\r\n");
172        t.feed(b"\x1b[92mbright\x1b[0m \x1b[104mbg\x1b[0m \x1b[4munder\x1b[7mrev\x1b[0m\r\n");
173        t.feed(b"\x1b[41m  \x1b[0m\x1b[30C\xdb\x1b[K");
174        let data = ansi(&rows(&t), AnsiOptions::default());
175
176        let mut back = Terminal::new(40, 6, 0);
177        back.feed(&data);
178        for y in 0..6 {
179            for x in 0..40 {
180                let (a, b) = (t.screen.row(y)[x], back.screen.row(y)[x]);
181                assert_eq!(a.ch, b.ch, "glyph at {x},{y}");
182                assert_eq!(a.resolve(false), b.resolve(false), "colors at {x},{y}");
183            }
184        }
185    }
186
187    #[test]
188    fn options_and_safety() {
189        let mut t = Terminal::new(10, 2, 0);
190        t.screen.put(0x1B);
191        t.screen.put(0x01);
192        let raw = ansi(&rows(&t), AnsiOptions { ice: true, unicode: false, codepage: None });
193        assert_eq!(raw, b"\x1b[?33h\x1b[0m \x01\r\n");
194        let uni = ansi(&rows(&t), AnsiOptions { ice: false, unicode: true, codepage: None });
195        assert_eq!(String::from_utf8(uni).unwrap(), "\x1b[0m\u{2190}\u{263A}\r\n");
196        // Nothing at all.
197        assert_eq!(ansi(&rows(&Terminal::new(10, 2, 0)), AnsiOptions::default()), b"\x1b[0m");
198    }
199
200    #[test]
201    fn copying_from_a_code_page() {
202        let mut t = Terminal::new(20, 2, 0);
203        t.feed(&[0x8F, 0xE0, 0xA8, 0xA2, 0xA5, 0xE2, b' ', 0xC9, 0xCD]);
204        let cp866 = crate::codepage::for_font(25);
205        assert_eq!(text_in(&rows(&t), cp866), "Привет ╔═\n");
206        // ISO-8859's controls (0x80-0x9F) are copied as CP437's glyphs.
207        let mut t = Terminal::new(4, 1, 0);
208        t.feed(&[0x81, 0xE9]);
209        assert_eq!(text_in(&rows(&t), crate::codepage::for_font(24)), "üé\n");
210    }
211
212    #[test]
213    fn copying_a_c64_screen() {
214        let mut t = Terminal::new(40, 2, 0);
215        t.encoding = crate::ansi::Encoding::Petscii;
216        t.start_emulation();
217        // "HELLO @" and a spade, in the upper-case set; reversed "OK".
218        t.feed(b"HELLO @\xc1 \x12OK\x92\r");
219        assert_eq!(text_in(&rows(&t), t.text_codepage()), "HELLO @\u{2660} OK\n");
220        // The lower-case set: unshifted letters are small.
221        t.feed(b"\x0eHello");
222        assert_eq!(text_in(&rows(&t), t.text_codepage()).lines().nth(1), Some("hELLO"));
223    }
224}