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/// The 28S's characters 128-147 as Unicode, from their glyphs (28S ROM,
65/// each shown by `CHR` and `DISP`); 128 shows blank. Its other codes
66/// above 127 have no glyph of their own.
67pub const HP28_HIGH: [char; 20] = [
68    ' ', '÷', '×', '√', '∫', 'Σ', '▶', 'π', '∂', '≤', '≥', '≠', 'α', '→', '←', 'µ', '¶', '°', '«',
69    '»',
70];
71
72/// 28S bytes as text: ASCII, then [`HP28_HIGH`]; 148-255, which the 28S
73/// shows as a block, become U+FFFD.
74pub fn decode_hp28(bytes: &[u8]) -> String {
75    bytes
76        .iter()
77        .map(|&b| match b {
78            0..=127 => char::from(b),
79            128..=147 => HP28_HIGH[usize::from(b - 128)],
80            _ => char::REPLACEMENT_CHARACTER,
81        })
82        .collect()
83}
84
85/// One HP byte as text.
86pub fn char_of(b: u8) -> String {
87    decode(&[b])
88}
89
90/// Text as HP bytes: ASCII and Latin-1 as themselves, the math and Greek
91/// characters of 128-159 from their Unicode forms (`x̄` is `x` with a
92/// combining macron). `Err` names the first character the set lacks
93/// (C1 controls and anything beyond U+00FF that is not one of 128-159).
94pub fn encode(text: &str) -> Result<Vec<u8>, char> {
95    let mut out = Vec::with_capacity(text.len());
96    let mut chars = text.chars().peekable();
97    while let Some(c) = chars.next() {
98        if c == 'x' && chars.peek() == Some(&'\u{0304}') {
99            chars.next();
100            out.push(0x81);
101            continue;
102        }
103        let mut buf = [0u8; 4];
104        let s: &str = c.encode_utf8(&mut buf);
105        if let Some(i) = HIGH.iter().position(|&h| h == s) {
106            out.push(0x80 + i as u8);
107            continue;
108        }
109        match u32::from(c) {
110            n @ (0..=0x7F | 0xA0..=0xFF) => out.push(n as u8),
111            _ => return Err(c),
112        }
113    }
114    Ok(out)
115}
116
117/// The ASCII trigraphs of characters 128-159, in byte order: the
118/// calculator's translation modes 2 and 3 (`TRANSIO`) write them in ASCII
119/// transfers (wiki: protocols/hp-object-format).
120const HIGH_TRIGRAPHS: [&str; 32] = [
121    "\\<)", "\\x-", "\\.V", "\\v/", "\\.S", "\\GS", "\\|>", "\\pi", "\\.d", "\\<=", "\\>=", "\\=/",
122    "\\Ga", "\\->", "\\<-", "\\|v", "\\|^", "\\Gg", "\\Gd", "\\Ge", "\\Gn", "\\Gh", "\\Gl", "\\Gr",
123    "\\Gs", "\\Gt", "\\Gw", "\\GD", "\\PI", "\\GW", "\\[]", "\\oo",
124];
125
126/// Characters 160-255 with a mnemonic trigraph; the others are `\nnn`.
127const LATIN_TRIGRAPHS: [(u8, &str); 8] = [
128    (171, "\\<<"),
129    (176, "\\^o"),
130    (181, "\\Gm"),
131    (187, "\\>>"),
132    (215, "\\.x"),
133    (216, "\\O/"),
134    (223, "\\Gb"),
135    (247, "\\:-"),
136];
137
138/// [`encode`] for text typed in ASCII, such as a Kermit host command:
139/// the trigraphs (`\->`, `\<<`, `\GS`, `\160`) become their bytes, since
140/// the calculator does not read them in a host command itself. A
141/// backslash that starts no trigraph stays a backslash.
142pub fn encode_command(text: &str) -> Result<Vec<u8>, char> {
143    let mut out = Vec::with_capacity(text.len());
144    let mut rest = text;
145    while let Some(pos) = rest.find('\\') {
146        out.extend(encode(&rest[..pos])?);
147        let tail = &rest[pos..];
148        match trigraph_at(tail) {
149            Some((byte, len)) => {
150                out.push(byte);
151                rest = &tail[len..];
152            }
153            None => {
154                out.push(b'\\');
155                rest = &tail[1..];
156            }
157        }
158    }
159    out.extend(encode(rest)?);
160    Ok(out)
161}
162
163/// Text with the translation codes read as the calculator reads an ASCII
164/// transfer with translation mode 2 or 3: `\\` is one backslash, a
165/// trigraph (`\->`, `\GS`, `\160`) is its character, and a backslash that
166/// starts neither stays a backslash. Everything else, Unicode included,
167/// is unchanged; text without a backslash comes back as it was.
168pub fn from_trigraphs(text: &str) -> String {
169    let mut out = String::with_capacity(text.len());
170    let mut rest = text;
171    while let Some(pos) = rest.find('\\') {
172        out.push_str(&rest[..pos]);
173        let tail = &rest[pos..];
174        if let Some(after) = tail.strip_prefix("\\\\") {
175            out.push('\\');
176            rest = after;
177            continue;
178        }
179        match trigraph_at(tail) {
180            Some((byte, len)) => {
181                out.push_str(&char_of(byte));
182                rest = &tail[len..];
183            }
184            None => {
185                out.push('\\');
186                rest = &tail[1..];
187            }
188        }
189    }
190    out.push_str(rest);
191    out
192}
193
194/// The byte and length of the trigraph that starts `s` (at a backslash),
195/// if any: a mnemonic (`\->`) or a code of 128-255 (`\160`).
196pub fn trigraph_at(s: &str) -> Option<(u8, usize)> {
197    if let Some(head) = s.get(..3) {
198        if let Some(i) = HIGH_TRIGRAPHS.iter().position(|t| *t == head) {
199            return Some((0x80 + i as u8, 3));
200        }
201        if let Some((b, _)) = LATIN_TRIGRAPHS.iter().find(|(_, t)| *t == head) {
202            return Some((*b, 3));
203        }
204    }
205    let digits = s.get(1..4)?;
206    if !digits.bytes().all(|d| d.is_ascii_digit()) {
207        return None;
208    }
209    let n: u16 = digits.parse().ok()?;
210    u8::try_from(n).ok().filter(|b| *b >= 128).map(|b| (b, 4))
211}
212
213/// A character with a mnemonic trigraph: its code, its Unicode text and the
214/// ASCII trigraph that types it.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct NamedChar {
217    /// The byte in the HP character set (128-255).
218    pub code: u8,
219    /// The character as Unicode text (`x̄` is two code points).
220    pub text: String,
221    /// The trigraph, e.g. `\->`.
222    pub trigraph: &'static str,
223}
224
225/// Every character with a mnemonic trigraph (all of 128-159 and some of
226/// 160-255), by code. The other codes 160-255 are typed as `\nnn`, three
227/// decimal digits.
228pub fn named_chars() -> Vec<NamedChar> {
229    let high = (0x80u8..)
230        .zip(HIGH.iter().zip(HIGH_TRIGRAPHS))
231        .map(|(code, (text, trigraph))| NamedChar {
232            code,
233            text: (*text).to_string(),
234            trigraph,
235        });
236    let latin = LATIN_TRIGRAPHS.iter().map(|&(code, trigraph)| NamedChar {
237        code,
238        text: char::from(code).to_string(),
239        trigraph,
240    });
241    high.chain(latin).collect()
242}
243
244/// HP bytes as 7-bit ASCII the way the calculator writes them with
245/// translation mode 3: bytes 128-255 become trigraphs, the rest is
246/// unchanged.
247pub fn to_trigraphs(bytes: &[u8]) -> String {
248    let mut s = String::with_capacity(bytes.len());
249    for &b in bytes {
250        match b {
251            0x80..=0x9F => s.push_str(HIGH_TRIGRAPHS[usize::from(b - 0x80)]),
252            0xA0..=0xFF => match LATIN_TRIGRAPHS.iter().find(|(l, _)| *l == b) {
253                Some((_, t)) => s.push_str(t),
254                None => s.push_str(&format!("\\{b}")),
255            },
256            _ => s.push(char::from(b)),
257        }
258    }
259    s
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    /// What a 48SX sends for `"" 128 255 FOR I I CHR + NEXT` with
267    /// `IOPAR` translation 3 (recorded 2026-10-05).
268    const SX_T3: &str = concat!(
269        "\\<)\\x-\\.V\\v/\\.S\\GS\\|>\\pi\\.d\\<=\\>=\\=/\\Ga\\->\\<-\\|v\\|^\\Gg\\Gd",
270        "\\Ge\\Gn\\Gh\\Gl\\Gr\\Gs\\Gt\\Gw\\GD\\PI\\GW\\[]\\oo\\160\\161\\162\\163",
271        "\\164\\165\\166\\167\\168\\169\\170\\<<\\172\\173\\174\\175\\^o\\177\\178",
272        "\\179\\180\\Gm\\182\\183\\184\\185\\186\\>>\\188\\189\\190\\191\\192\\193",
273        "\\194\\195\\196\\197\\198\\199\\200\\201\\202\\203\\204\\205\\206\\207",
274        "\\208\\209\\210\\211\\212\\213\\214\\.x\\O/\\217\\218\\219\\220\\221\\222",
275        "\\Gb\\224\\225\\226\\227\\228\\229\\230\\231\\232\\233\\234\\235\\236",
276        "\\237\\238\\239\\240\\241\\242\\243\\244\\245\\246\\:-\\248\\249\\250",
277        "\\251\\252\\253\\254\\255",
278    );
279
280    #[test]
281    fn trigraphs_match_the_calculator() {
282        let high: Vec<u8> = (128..=255).collect();
283        assert_eq!(to_trigraphs(&high), SX_T3);
284        assert_eq!(encode_command(SX_T3).unwrap(), high);
285    }
286
287    #[test]
288    fn named_chars_cover_both_tables_and_round_trip() {
289        let named = named_chars();
290        assert_eq!(named.len(), HIGH.len() + LATIN_TRIGRAPHS.len());
291        assert!(named.windows(2).all(|w| w[0].code < w[1].code));
292        for c in &named {
293            assert_eq!(
294                encode_command(c.trigraph).unwrap(),
295                [c.code],
296                "{}",
297                c.trigraph
298            );
299            assert_eq!(encode(&c.text).unwrap(), [c.code], "{}", c.text);
300            assert_eq!(decode(&[c.code]), c.text);
301            assert_eq!(to_trigraphs(&[c.code]), c.trigraph);
302        }
303        assert_eq!(named[3].trigraph, "\\v/");
304        assert_eq!(named[3].text, "√");
305    }
306
307    #[test]
308    fn command_trigraphs() {
309        assert_eq!(
310            encode_command("{ 1 2 } \\->STR").unwrap(),
311            b"{ 1 2 } \x8dSTR"
312        );
313        assert_eq!(encode_command("→STR").unwrap(), b"\x8dSTR");
314        // Not a trigraph: kept.
315        assert_eq!(encode_command("\"a\\b\" \\1").unwrap(), b"\"a\\b\" \\1");
316        assert_eq!(encode_command("\\127 \\256").unwrap(), b"\\127 \\256");
317        assert_eq!(encode_command("\\").unwrap(), b"\\");
318    }
319
320    #[test]
321    fn trigraphs_become_their_bytes() {
322        assert_eq!(
323            encode_command("\\<< 1 \\->LIST \\>> \\GS \\160 \\q").unwrap(),
324            [
325                0xAB, b' ', b'1', b' ', 0x8D, b'L', b'I', b'S', b'T', b' ', 0xBB, b' ', 0x85, b' ',
326                160, b' ', b'\\', b'q'
327            ]
328        );
329        assert_eq!(encode_command("\\127").unwrap(), b"\\127");
330        assert_eq!(encode_command("€"), Err('€'));
331        for (i, t) in HIGH_TRIGRAPHS.iter().enumerate() {
332            assert_eq!(encode_command(t).unwrap(), [0x80 + i as u8]);
333        }
334    }
335
336    #[test]
337    fn from_trigraphs_reads_every_code_as_the_calculator_does() {
338        // Every code of the table, the same bytes as an ASCII transfer.
339        let high: Vec<u8> = (128..=255).collect();
340        assert_eq!(from_trigraphs(SX_T3), decode(&high));
341        assert_eq!(
342            from_trigraphs("'\\GS(I=0,1000,1/I!)' \\->NUM"),
343            "'Σ(I=0,1000,1/I!)' →NUM"
344        );
345        assert_eq!(
346            from_trigraphs("\\v/ \\pi \\<= \\>= \\=/ \\Ga \\<) \\.d \\|> \\oo \\<- \\|v \\|^"),
347            "√ π ≤ ≥ ≠ α ∡ ∂ ▶ ∞ ← ↓ ↑"
348        );
349        assert_eq!(from_trigraphs("\\<< 1 + \\>>"), "« 1 + »");
350        assert_eq!(from_trigraphs("\\x-"), "x\u{0304}");
351        // `\\` is one backslash and starts no code; an unknown code and a
352        // number below 128 stay as they are.
353        assert_eq!(from_trigraphs("\"a\\\\b\""), "\"a\\b\"");
354        assert_eq!(from_trigraphs("\\\\GS"), "\\GS");
355        assert_eq!(from_trigraphs("\\q \\127 \\"), "\\q \\127 \\");
356        // Unicode and text without a backslash are unchanged.
357        assert_eq!(
358            from_trigraphs("« 'Σ(I=1,10,I^2)' →NUM »"),
359            "« 'Σ(I=1,10,I^2)' →NUM »"
360        );
361        assert_eq!(from_trigraphs("\\é\\GS"), "\\éΣ");
362        assert_eq!(from_trigraphs(""), "");
363    }
364
365    #[test]
366    fn encode_is_decode_inverted() {
367        let all: Vec<u8> = (0..=255).collect();
368        assert_eq!(encode(&decode(&all)).unwrap(), all);
369        assert_eq!(
370            encode("« 1 2 + »").unwrap(),
371            [0xAB, 32, 49, 32, 50, 32, 43, 32, 0xBB]
372        );
373        assert_eq!(encode("\u{85}"), Err('\u{85}'));
374        assert_eq!(encode("€"), Err('€'));
375    }
376
377    #[test]
378    fn high_characters() {
379        assert_eq!(decode(b"AB"), "AB");
380        assert_eq!(decode(&[0x8D, 0x85, 0xAB, 0xBB]), "→Σ«»");
381        assert_eq!(decode(&[0x81]), "x\u{0304}");
382    }
383}