Skip to main content

saturnus_objects/
charset.rs

1//! The HP 48/49 character set as Unicode text, and the ASCII trigraphs of
2//! the transfer translation modes. The one table of the workspace:
3//! satx-core's `charset` wraps it.
4//!
5//! Bytes 0-127 are ASCII and 160-255 ISO 8859-1; 128-159 are HP's math and
6//! Greek characters (wiki: protocols/hp-object-format).
7//!
8//! In ASCII transfers with translation mode 2 or 3 (`TRANSIO`) the
9//! calculator writes characters 128-255 as backslash trigraphs: `\->` for
10//! `→`, `\<<` for `«`, `\160` for a no-break space. The tables were
11//! checked against a 48SX GET with `T(3)` of a string holding every
12//! character from 128 to 255 (2026-10-05). The calculator does not read
13//! trigraphs in a host command (`C` packet) itself: `\->LIST` there is a
14//! syntax error, so [`encode_command`] translates them before sending.
15
16/// Characters 128-159 in byte order.
17const HIGH: [&str; 32] = [
18    "∡",
19    "x\u{0304}",
20    "∇",
21    "√",
22    "∫",
23    "Σ",
24    "▶",
25    "π",
26    "∂",
27    "≤",
28    "≥",
29    "≠",
30    "α",
31    "→",
32    "←",
33    "↓",
34    "↑",
35    "γ",
36    "δ",
37    "ε",
38    "η",
39    "θ",
40    "λ",
41    "ρ",
42    "σ",
43    "τ",
44    "ω",
45    "Δ",
46    "Π",
47    "Ω",
48    "■",
49    "∞",
50];
51
52/// HP bytes as text; every byte has a meaning, so this never fails.
53pub fn decode(bytes: &[u8]) -> String {
54    let mut s = String::with_capacity(bytes.len());
55    for &b in bytes {
56        match b {
57            0x80..=0x9F => s.push_str(HIGH[usize::from(b - 0x80)]),
58            _ => s.push(char::from(b)),
59        }
60    }
61    s
62}
63
64/// One HP byte as text.
65pub fn char_of(b: u8) -> String {
66    decode(&[b])
67}
68
69/// Text as HP bytes: ASCII and Latin-1 as themselves, the math and Greek
70/// characters of 128-159 from their Unicode forms (`x̄` is `x` with a
71/// combining macron). `Err` names the first character the set lacks
72/// (C1 controls and anything beyond U+00FF that is not one of 128-159).
73pub fn encode(text: &str) -> Result<Vec<u8>, char> {
74    let mut out = Vec::with_capacity(text.len());
75    let mut chars = text.chars().peekable();
76    while let Some(c) = chars.next() {
77        if c == 'x' && chars.peek() == Some(&'\u{0304}') {
78            chars.next();
79            out.push(0x81);
80            continue;
81        }
82        let mut buf = [0u8; 4];
83        let s: &str = c.encode_utf8(&mut buf);
84        if let Some(i) = HIGH.iter().position(|&h| h == s) {
85            out.push(0x80 + i as u8);
86            continue;
87        }
88        match u32::from(c) {
89            n @ (0..=0x7F | 0xA0..=0xFF) => out.push(n as u8),
90            _ => return Err(c),
91        }
92    }
93    Ok(out)
94}
95
96/// The ASCII trigraphs of characters 128-159, in byte order: the
97/// calculator's translation modes 2 and 3 (`TRANSIO`) write them in ASCII
98/// transfers (wiki: protocols/hp-object-format).
99const HIGH_TRIGRAPHS: [&str; 32] = [
100    "\\<)", "\\x-", "\\.V", "\\v/", "\\.S", "\\GS", "\\|>", "\\pi", "\\.d", "\\<=", "\\>=", "\\=/",
101    "\\Ga", "\\->", "\\<-", "\\|v", "\\|^", "\\Gg", "\\Gd", "\\Ge", "\\Gn", "\\Gh", "\\Gl", "\\Gr",
102    "\\Gs", "\\Gt", "\\Gw", "\\GD", "\\PI", "\\GW", "\\[]", "\\oo",
103];
104
105/// Characters 160-255 with a mnemonic trigraph; the others are `\nnn`.
106const LATIN_TRIGRAPHS: [(u8, &str); 8] = [
107    (171, "\\<<"),
108    (176, "\\^o"),
109    (181, "\\Gm"),
110    (187, "\\>>"),
111    (215, "\\.x"),
112    (216, "\\O/"),
113    (223, "\\Gb"),
114    (247, "\\:-"),
115];
116
117/// [`encode`] for text typed in ASCII, such as a Kermit host command:
118/// the trigraphs (`\->`, `\<<`, `\GS`, `\160`) become their bytes, since
119/// the calculator does not read them in a host command itself. A
120/// backslash that starts no trigraph stays a backslash.
121pub fn encode_command(text: &str) -> Result<Vec<u8>, char> {
122    let mut out = Vec::with_capacity(text.len());
123    let mut rest = text;
124    while let Some(pos) = rest.find('\\') {
125        out.extend(encode(&rest[..pos])?);
126        let tail = &rest[pos..];
127        match trigraph_at(tail) {
128            Some((byte, len)) => {
129                out.push(byte);
130                rest = &tail[len..];
131            }
132            None => {
133                out.push(b'\\');
134                rest = &tail[1..];
135            }
136        }
137    }
138    out.extend(encode(rest)?);
139    Ok(out)
140}
141
142/// The byte and length of the trigraph that starts `s` (at a backslash),
143/// if any: a mnemonic (`\->`) or a code of 128-255 (`\160`).
144pub fn trigraph_at(s: &str) -> Option<(u8, usize)> {
145    if let Some(head) = s.get(..3) {
146        if let Some(i) = HIGH_TRIGRAPHS.iter().position(|t| *t == head) {
147            return Some((0x80 + i as u8, 3));
148        }
149        if let Some((b, _)) = LATIN_TRIGRAPHS.iter().find(|(_, t)| *t == head) {
150            return Some((*b, 3));
151        }
152    }
153    let digits = s.get(1..4)?;
154    if !digits.bytes().all(|d| d.is_ascii_digit()) {
155        return None;
156    }
157    let n: u16 = digits.parse().ok()?;
158    u8::try_from(n).ok().filter(|b| *b >= 128).map(|b| (b, 4))
159}
160
161/// A character with a mnemonic trigraph: its code, its Unicode text and the
162/// ASCII trigraph that types it.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct NamedChar {
165    /// The byte in the HP character set (128-255).
166    pub code: u8,
167    /// The character as Unicode text (`x̄` is two code points).
168    pub text: String,
169    /// The trigraph, e.g. `\->`.
170    pub trigraph: &'static str,
171}
172
173/// Every character with a mnemonic trigraph (all of 128-159 and some of
174/// 160-255), by code. The other codes 160-255 are typed as `\nnn`, three
175/// decimal digits.
176pub fn named_chars() -> Vec<NamedChar> {
177    let high = (0x80u8..)
178        .zip(HIGH.iter().zip(HIGH_TRIGRAPHS))
179        .map(|(code, (text, trigraph))| NamedChar {
180            code,
181            text: (*text).to_string(),
182            trigraph,
183        });
184    let latin = LATIN_TRIGRAPHS.iter().map(|&(code, trigraph)| NamedChar {
185        code,
186        text: char::from(code).to_string(),
187        trigraph,
188    });
189    high.chain(latin).collect()
190}
191
192/// HP bytes as 7-bit ASCII the way the calculator writes them with
193/// translation mode 3: bytes 128-255 become trigraphs, the rest is
194/// unchanged.
195pub fn to_trigraphs(bytes: &[u8]) -> String {
196    let mut s = String::with_capacity(bytes.len());
197    for &b in bytes {
198        match b {
199            0x80..=0x9F => s.push_str(HIGH_TRIGRAPHS[usize::from(b - 0x80)]),
200            0xA0..=0xFF => match LATIN_TRIGRAPHS.iter().find(|(l, _)| *l == b) {
201                Some((_, t)) => s.push_str(t),
202                None => s.push_str(&format!("\\{b}")),
203            },
204            _ => s.push(char::from(b)),
205        }
206    }
207    s
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    /// What a 48SX sends for `"" 128 255 FOR I I CHR + NEXT` with
215    /// `IOPAR` translation 3 (recorded 2026-10-05).
216    const SX_T3: &str = concat!(
217        "\\<)\\x-\\.V\\v/\\.S\\GS\\|>\\pi\\.d\\<=\\>=\\=/\\Ga\\->\\<-\\|v\\|^\\Gg\\Gd",
218        "\\Ge\\Gn\\Gh\\Gl\\Gr\\Gs\\Gt\\Gw\\GD\\PI\\GW\\[]\\oo\\160\\161\\162\\163",
219        "\\164\\165\\166\\167\\168\\169\\170\\<<\\172\\173\\174\\175\\^o\\177\\178",
220        "\\179\\180\\Gm\\182\\183\\184\\185\\186\\>>\\188\\189\\190\\191\\192\\193",
221        "\\194\\195\\196\\197\\198\\199\\200\\201\\202\\203\\204\\205\\206\\207",
222        "\\208\\209\\210\\211\\212\\213\\214\\.x\\O/\\217\\218\\219\\220\\221\\222",
223        "\\Gb\\224\\225\\226\\227\\228\\229\\230\\231\\232\\233\\234\\235\\236",
224        "\\237\\238\\239\\240\\241\\242\\243\\244\\245\\246\\:-\\248\\249\\250",
225        "\\251\\252\\253\\254\\255",
226    );
227
228    #[test]
229    fn trigraphs_match_the_calculator() {
230        let high: Vec<u8> = (128..=255).collect();
231        assert_eq!(to_trigraphs(&high), SX_T3);
232        assert_eq!(encode_command(SX_T3).unwrap(), high);
233    }
234
235    #[test]
236    fn named_chars_cover_both_tables_and_round_trip() {
237        let named = named_chars();
238        assert_eq!(named.len(), HIGH.len() + LATIN_TRIGRAPHS.len());
239        assert!(named.windows(2).all(|w| w[0].code < w[1].code));
240        for c in &named {
241            assert_eq!(
242                encode_command(c.trigraph).unwrap(),
243                [c.code],
244                "{}",
245                c.trigraph
246            );
247            assert_eq!(encode(&c.text).unwrap(), [c.code], "{}", c.text);
248            assert_eq!(decode(&[c.code]), c.text);
249            assert_eq!(to_trigraphs(&[c.code]), c.trigraph);
250        }
251        assert_eq!(named[3].trigraph, "\\v/");
252        assert_eq!(named[3].text, "√");
253    }
254
255    #[test]
256    fn command_trigraphs() {
257        assert_eq!(
258            encode_command("{ 1 2 } \\->STR").unwrap(),
259            b"{ 1 2 } \x8dSTR"
260        );
261        assert_eq!(encode_command("→STR").unwrap(), b"\x8dSTR");
262        // Not a trigraph: kept.
263        assert_eq!(encode_command("\"a\\b\" \\1").unwrap(), b"\"a\\b\" \\1");
264        assert_eq!(encode_command("\\127 \\256").unwrap(), b"\\127 \\256");
265        assert_eq!(encode_command("\\").unwrap(), b"\\");
266    }
267
268    #[test]
269    fn trigraphs_become_their_bytes() {
270        assert_eq!(
271            encode_command("\\<< 1 \\->LIST \\>> \\GS \\160 \\q").unwrap(),
272            [
273                0xAB, b' ', b'1', b' ', 0x8D, b'L', b'I', b'S', b'T', b' ', 0xBB, b' ', 0x85, b' ',
274                160, b' ', b'\\', b'q'
275            ]
276        );
277        assert_eq!(encode_command("\\127").unwrap(), b"\\127");
278        assert_eq!(encode_command("€"), Err('€'));
279        for (i, t) in HIGH_TRIGRAPHS.iter().enumerate() {
280            assert_eq!(encode_command(t).unwrap(), [0x80 + i as u8]);
281        }
282    }
283
284    #[test]
285    fn encode_is_decode_inverted() {
286        let all: Vec<u8> = (0..=255).collect();
287        assert_eq!(encode(&decode(&all)).unwrap(), all);
288        assert_eq!(
289            encode("« 1 2 + »").unwrap(),
290            [0xAB, 32, 49, 32, 50, 32, 43, 32, 0xBB]
291        );
292        assert_eq!(encode("\u{85}"), Err('\u{85}'));
293        assert_eq!(encode("€"), Err('€'));
294    }
295
296    #[test]
297    fn high_characters() {
298        assert_eq!(decode(b"AB"), "AB");
299        assert_eq!(decode(&[0x8D, 0x85, 0xAB, 0xBB]), "→Σ«»");
300        assert_eq!(decode(&[0x81]), "x\u{0304}");
301    }
302}