Skip to main content

badterm_core/
sauce.rs

1//! SAUCE, the record at the end of most ANSI art: title, artist, group,
2//! date, the width it was drawn at, its font and whether it uses iCE
3//! colors, plus any comment lines (<https://www.acid.org/info/sauce/sauce.htm>).
4
5use crate::cp437;
6
7#[derive(Clone, Debug, Default, PartialEq, Eq)]
8pub struct Sauce {
9    pub title: String,
10    pub author: String,
11    pub group: String,
12    /// CCYYMMDD.
13    pub date: String,
14    /// Columns (character art), 0 if not given.
15    pub width: u16,
16    /// Lines, 0 if not given.
17    pub lines: u16,
18    /// The blink bit is a bright background ("non-blink mode").
19    pub ice: bool,
20    /// 9-pixel letter spacing asked for.
21    pub nine_dot: bool,
22    /// The font's name, e.g. "IBM VGA" or "Amiga Topaz 2+".
23    pub font: String,
24    pub comments: Vec<String>,
25}
26
27impl Sauce {
28    /// The date as YYYY-MM-DD, if it looks like one.
29    pub fn date_text(&self) -> Option<String> {
30        let d = self.date.trim();
31        (d.len() == 8 && d.bytes().all(|b| b.is_ascii_digit())).then(|| format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..]))
32    }
33
34    /// The CTerm font ID for the font named, if BadTerm has it (0 for the
35    /// IBM fonts).
36    pub fn cterm_font(&self) -> u8 {
37        let f = self.font.to_ascii_lowercase();
38        match () {
39            _ if f.contains("topaz") && f.contains('+') => 40,
40            _ if f.contains("topaz") => 42,
41            _ if f.contains("microknight") && f.contains('+') => 39,
42            _ if f.contains("microknight") => 41,
43            _ if f.contains("noodle") => 37,
44            _ if f.contains("mosoul") || f.contains("mo'soul") => 38,
45            _ => 0,
46        }
47    }
48}
49
50/// Split `data` into the art (without the SAUCE record, its comments, and
51/// the Ctrl-Z before them) and the record, if there is one.
52pub fn split(data: &[u8]) -> (&[u8], Option<Sauce>) {
53    let mut end = data.len();
54    let mut sauce = None;
55    if data.len() >= 128 && data[data.len() - 128..].starts_with(b"SAUCE00") {
56        let r = &data[data.len() - 128..];
57        end = data.len() - 128;
58        let field = |a: usize, b: usize| cp437::decode(&r[a..b]).trim_end_matches(['\0', ' ']).to_string();
59        let u16_at = |a: usize| u16::from_le_bytes([r[a], r[a + 1]]);
60        let (data_type, flags) = (r[94], r[105]);
61        let mut s = Sauce {
62            title: field(7, 42),
63            author: field(42, 62),
64            group: field(62, 82),
65            date: field(82, 90),
66            font: field(106, 128),
67            ..Sauce::default()
68        };
69        // Character and binary-text art have widths and the flags.
70        if matches!(data_type, 1 | 5) {
71            s.width = if data_type == 5 { u16::from(r[95]) * 2 } else { u16_at(96) };
72            s.lines = if data_type == 5 { 0 } else { u16_at(98) };
73            s.ice = flags & 1 != 0;
74            s.nine_dot = (flags >> 1) & 3 == 2;
75        }
76        // Comments: "COMNT" and 64 bytes per line, just before the record.
77        let n = r[104] as usize;
78        let block = 5 + 64 * n;
79        if n > 0 && end >= block && data[end - block..].starts_with(b"COMNT") {
80            s.comments = data[end - block + 5..end]
81                .chunks(64)
82                .map(|c| cp437::decode(c).trim_end_matches(['\0', ' ']).to_string())
83                .collect();
84            end -= block;
85        }
86        sauce = Some(s);
87    }
88    // Ctrl-Z ends the art in files with SAUCE or comments.
89    if let Some(z) = data[..end].iter().position(|&b| b == 0x1A) {
90        end = z;
91    }
92    (&data[..end], sauce)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    pub fn sauce_record(title: &str, author: &str, width: u16, flags: u8, font: &str, comments: u8) -> Vec<u8> {
100        let mut r = vec![0u8; 128];
101        r[..7].copy_from_slice(b"SAUCE00");
102        r[7..7 + title.len()].copy_from_slice(title.as_bytes());
103        r[42..42 + author.len()].copy_from_slice(author.as_bytes());
104        r[82..90].copy_from_slice(b"19960314");
105        r[94] = 1;
106        r[95] = 1;
107        r[96..98].copy_from_slice(&width.to_le_bytes());
108        r[98..100].copy_from_slice(&25u16.to_le_bytes());
109        r[104] = comments;
110        r[105] = flags;
111        r[106..106 + font.len()].copy_from_slice(font.as_bytes());
112        r
113    }
114
115    #[test]
116    fn reads_the_record_and_comments() {
117        let mut file = b"\x1b[1;31mART\x1b[0m".to_vec();
118        let art_len = file.len();
119        file.push(0x1A);
120        file.extend_from_slice(b"COMNT");
121        let mut line = b"Greets to the scene".to_vec();
122        line.resize(64, b' ');
123        file.extend_from_slice(&line);
124        file.extend(sauce_record("Big Logo", "artist", 80, 1 | 4, "Amiga Topaz 2+", 1));
125        let (art, sauce) = split(&file);
126        assert_eq!(art.len(), art_len);
127        let s = sauce.unwrap();
128        assert_eq!((s.title.as_str(), s.author.as_str(), s.width, s.lines), ("Big Logo", "artist", 80, 25));
129        assert!(s.ice && s.nine_dot);
130        assert_eq!(s.cterm_font(), 40);
131        assert_eq!(s.date_text().as_deref(), Some("1996-03-14"));
132        assert_eq!(s.comments, vec!["Greets to the scene".to_string()]);
133        assert_eq!(split(b"plain"), (&b"plain"[..], None));
134    }
135}