Files
trx-rs/src/decoders/trx-ftx/src/common/text.rs
T
sjg bb18d90cbe [refactor](trx-ftx): reorganize into common/, ft8/, ft4/, ft2/ modules
Split flat src/ layout into protocol-oriented directory structure:
- common/: shared types, constants, LDPC/OSD decoders, monitor, message, CRC
- ft8/: FT8-specific sync scoring, likelihood extraction, tone encoding
- ft4/: FT4-specific sync scoring, likelihood extraction, tone encoding
- ft2/: FT2 pipeline, waterfall decode, bitmetrics, downsample, sync
- Top-level: lib.rs (mod declarations) and decoder.rs (public API)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-19 23:51:17 +01:00

435 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Character table lookup and string utility functions for FTx message
//! encoding/decoding.
//!
//! This is a pure Rust port of `ft8_lib/ft8/text.c`.
/// Character table variants used for encoding and decoding FTx messages.
///
/// Each variant defines a different subset of allowed characters.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CharTable {
/// `" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?"` (42 entries)
Full,
/// `" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"` (38 entries)
AlphanumSpaceSlash,
/// `" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (37 entries)
AlphanumSpace,
/// `" ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (27 entries)
LettersSpace,
/// `"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (36 entries)
Alphanum,
/// `"0123456789"` (10 entries)
Numeric,
}
/// Convert an integer index to an ASCII character according to the given
/// character table.
///
/// Returns `'_'` if the index is out of range (should not happen in normal
/// operation).
pub fn charn(mut c: i32, table: CharTable) -> char {
// Tables that include a leading space
if table != CharTable::Alphanum && table != CharTable::Numeric {
if c == 0 {
return ' ';
}
c -= 1;
}
// Digits (unless letters-space table which skips digits)
if table != CharTable::LettersSpace {
if c < 10 {
return char::from(b'0' + c as u8);
}
c -= 10;
}
// Letters (unless numeric table which has no letters)
if table != CharTable::Numeric {
if c < 26 {
return char::from(b'A' + c as u8);
}
c -= 26;
}
// Extra symbols
match table {
CharTable::Full => {
const EXTRAS: [char; 5] = ['+', '-', '.', '/', '?'];
if (c as usize) < EXTRAS.len() {
return EXTRAS[c as usize];
}
}
CharTable::AlphanumSpaceSlash => {
if c == 0 {
return '/';
}
}
_ => {}
}
'_' // unknown character — should never get here
}
/// Look up the index of an ASCII character in the given character table.
///
/// Returns `None` if the character is not present in the table (the C version
/// returns -1).
pub fn nchar(c: char, table: CharTable) -> Option<i32> {
let mut n: i32 = 0;
// Leading space
if table != CharTable::Alphanum && table != CharTable::Numeric {
if c == ' ' {
return Some(n);
}
n += 1;
}
// Digits
if table != CharTable::LettersSpace {
if c.is_ascii_digit() {
return Some(n + (c as i32 - '0' as i32));
}
n += 10;
}
// Letters
if table != CharTable::Numeric {
if c.is_ascii_uppercase() {
return Some(n + (c as i32 - 'A' as i32));
}
n += 26;
}
// Extra symbols
match table {
CharTable::Full => match c {
'+' => return Some(n),
'-' => return Some(n + 1),
'.' => return Some(n + 2),
'/' => return Some(n + 3),
'?' => return Some(n + 4),
_ => {}
},
CharTable::AlphanumSpaceSlash => {
if c == '/' {
return Some(n);
}
}
_ => {}
}
None
}
/// Convert a character to uppercase ASCII. Non-letter characters are returned
/// unchanged.
pub fn to_upper(c: char) -> char {
if c.is_ascii_lowercase() {
char::from(c as u8 - b'a' + b'A')
} else {
c
}
}
/// Format an FTx message string:
/// - replaces lowercase letters with uppercase
/// - collapses consecutive spaces into a single space
pub fn fmtmsg(msg_in: &str) -> String {
let mut out = String::with_capacity(msg_in.len());
let mut last_out: Option<char> = None;
for c in msg_in.chars() {
if c == ' ' && last_out == Some(' ') {
continue;
}
let upper = to_upper(c);
out.push(upper);
last_out = Some(upper);
}
out
}
/// Parse a signed integer from a string slice.
///
/// Handles optional leading `+` or `-` sign, followed by decimal digits.
/// Stops at the first non-digit character (or end of string).
pub fn dd_to_int(s: &str) -> i32 {
let bytes = s.as_bytes();
if bytes.is_empty() {
return 0;
}
let (negative, start) = match bytes[0] {
b'-' => (true, 1),
b'+' => (false, 1),
_ => (false, 0),
};
let mut result: i32 = 0;
for &b in &bytes[start..] {
if !b.is_ascii_digit() {
break;
}
result = result * 10 + (b - b'0') as i32;
}
if negative {
-result
} else {
result
}
}
/// Format an integer into a fixed-width decimal string.
///
/// * `value` the integer value to format
/// * `width` number of digit positions (excluding sign)
/// * `full_sign` if `true`, a `+` is prepended for non-negative values
pub fn int_to_dd(value: i32, width: usize, full_sign: bool) -> String {
let mut out = String::with_capacity(width + 1);
let abs_value = if value < 0 {
out.push('-');
(-value) as u32
} else {
if full_sign {
out.push('+');
}
value as u32
};
if width == 0 {
return out;
}
let mut divisor: u32 = 1;
for _ in 0..width - 1 {
divisor *= 10;
}
let mut remaining = abs_value;
while divisor >= 1 {
let digit = remaining / divisor;
out.push(char::from(b'0' + digit as u8));
remaining -= digit * divisor;
divisor /= 10;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
// -----------------------------------------------------------------------
// charn / nchar round-trip tests
// -----------------------------------------------------------------------
#[test]
fn full_table_round_trip() {
let expected = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?";
for (i, ch) in expected.chars().enumerate() {
assert_eq!(charn(i as i32, CharTable::Full), ch, "charn({i})");
assert_eq!(nchar(ch, CharTable::Full), Some(i as i32), "nchar('{ch}')");
}
}
#[test]
fn alphanum_space_slash_round_trip() {
let expected = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/";
for (i, ch) in expected.chars().enumerate() {
assert_eq!(
charn(i as i32, CharTable::AlphanumSpaceSlash),
ch,
"charn({i})"
);
assert_eq!(
nchar(ch, CharTable::AlphanumSpaceSlash),
Some(i as i32),
"nchar('{ch}')"
);
}
}
#[test]
fn alphanum_space_round_trip() {
let expected = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
for (i, ch) in expected.chars().enumerate() {
assert_eq!(charn(i as i32, CharTable::AlphanumSpace), ch, "charn({i})");
assert_eq!(
nchar(ch, CharTable::AlphanumSpace),
Some(i as i32),
"nchar('{ch}')"
);
}
}
#[test]
fn letters_space_round_trip() {
let expected = " ABCDEFGHIJKLMNOPQRSTUVWXYZ";
for (i, ch) in expected.chars().enumerate() {
assert_eq!(charn(i as i32, CharTable::LettersSpace), ch, "charn({i})");
assert_eq!(
nchar(ch, CharTable::LettersSpace),
Some(i as i32),
"nchar('{ch}')"
);
}
}
#[test]
fn alphanum_round_trip() {
let expected = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
for (i, ch) in expected.chars().enumerate() {
assert_eq!(charn(i as i32, CharTable::Alphanum), ch, "charn({i})");
assert_eq!(
nchar(ch, CharTable::Alphanum),
Some(i as i32),
"nchar('{ch}')"
);
}
}
#[test]
fn numeric_round_trip() {
let expected = "0123456789";
for (i, ch) in expected.chars().enumerate() {
assert_eq!(charn(i as i32, CharTable::Numeric), ch, "charn({i})");
assert_eq!(
nchar(ch, CharTable::Numeric),
Some(i as i32),
"nchar('{ch}')"
);
}
}
#[test]
fn nchar_returns_none_for_unknown() {
assert_eq!(nchar('!', CharTable::Full), None);
assert_eq!(nchar('a', CharTable::Full), None); // lowercase not in table
assert_eq!(nchar(' ', CharTable::Alphanum), None);
assert_eq!(nchar('A', CharTable::Numeric), None);
assert_eq!(nchar('0', CharTable::LettersSpace), None);
}
#[test]
fn charn_returns_underscore_for_out_of_range() {
assert_eq!(charn(42, CharTable::Full), '_');
assert_eq!(charn(38, CharTable::AlphanumSpaceSlash), '_');
assert_eq!(charn(10, CharTable::Numeric), '_');
}
// -----------------------------------------------------------------------
// to_upper
// -----------------------------------------------------------------------
#[test]
fn to_upper_converts_lowercase() {
assert_eq!(to_upper('a'), 'A');
assert_eq!(to_upper('z'), 'Z');
assert_eq!(to_upper('m'), 'M');
}
#[test]
fn to_upper_preserves_non_lower() {
assert_eq!(to_upper('A'), 'A');
assert_eq!(to_upper('5'), '5');
assert_eq!(to_upper(' '), ' ');
assert_eq!(to_upper('/'), '/');
}
// -----------------------------------------------------------------------
// fmtmsg
// -----------------------------------------------------------------------
#[test]
fn fmtmsg_uppercases_and_collapses_spaces() {
assert_eq!(fmtmsg("cq dx de ab1cd"), "CQ DX DE AB1CD");
}
#[test]
fn fmtmsg_preserves_single_spaces() {
assert_eq!(fmtmsg("CQ DX"), "CQ DX");
}
#[test]
fn fmtmsg_empty() {
assert_eq!(fmtmsg(""), "");
}
#[test]
fn fmtmsg_all_spaces() {
assert_eq!(fmtmsg(" "), " ");
}
// -----------------------------------------------------------------------
// dd_to_int
// -----------------------------------------------------------------------
#[test]
fn dd_to_int_positive() {
assert_eq!(dd_to_int("42"), 42);
assert_eq!(dd_to_int("+42"), 42);
}
#[test]
fn dd_to_int_negative() {
assert_eq!(dd_to_int("-7"), -7);
}
#[test]
fn dd_to_int_stops_at_non_digit() {
assert_eq!(dd_to_int("12abc"), 12);
}
#[test]
fn dd_to_int_empty() {
assert_eq!(dd_to_int(""), 0);
}
#[test]
fn dd_to_int_sign_only() {
assert_eq!(dd_to_int("-"), 0);
assert_eq!(dd_to_int("+"), 0);
}
// -----------------------------------------------------------------------
// int_to_dd
// -----------------------------------------------------------------------
#[test]
fn int_to_dd_positive_no_sign() {
assert_eq!(int_to_dd(7, 2, false), "07");
}
#[test]
fn int_to_dd_positive_with_sign() {
assert_eq!(int_to_dd(7, 2, true), "+07");
}
#[test]
fn int_to_dd_negative() {
assert_eq!(int_to_dd(-15, 2, false), "-15");
}
#[test]
fn int_to_dd_zero() {
assert_eq!(int_to_dd(0, 2, false), "00");
assert_eq!(int_to_dd(0, 2, true), "+00");
}
#[test]
fn int_to_dd_width_3() {
assert_eq!(int_to_dd(123, 3, false), "123");
assert_eq!(int_to_dd(5, 3, true), "+005");
}
}