use alloc::string::String;
use alloc::vec::Vec;
#[must_use]
pub fn decode_hamming_8_4(byte: u8) -> Option<u8> {
let candidate =
(byte >> 1) & 1 | ((byte >> 3) & 1) << 1 | ((byte >> 5) & 1) << 2 | ((byte >> 7) & 1) << 3;
if encode_hamming_8_4(candidate) == byte {
return Some(candidate);
}
for bit in 0..8u8 {
let flipped = byte ^ (1 << bit);
let candidate = (flipped >> 1) & 1
| ((flipped >> 3) & 1) << 1
| ((flipped >> 5) & 1) << 2
| ((flipped >> 7) & 1) << 3;
if encode_hamming_8_4(candidate) == flipped {
return Some(candidate);
}
}
None
}
#[must_use]
pub fn encode_hamming_8_4(nibble: u8) -> u8 {
let d1 = nibble & 1;
let d2 = (nibble >> 1) & 1;
let d3 = (nibble >> 2) & 1;
let d4 = (nibble >> 3) & 1;
let p1 = 1 ^ d1 ^ d3 ^ d4;
let p2 = 1 ^ d1 ^ d2 ^ d4;
let p3 = 1 ^ d1 ^ d2 ^ d3;
let p4 = 1 ^ p1 ^ d1 ^ p2 ^ d2 ^ p3 ^ d3 ^ d4;
p1 | (d1 << 1) | (p2 << 2) | (d2 << 3) | (p3 << 4) | (d3 << 5) | (p4 << 6) | (d4 << 7)
}
#[must_use]
pub fn decode_odd_parity(byte: u8) -> Option<u8> {
if byte.count_ones() % 2 == 1 {
Some(byte & 0x7F)
} else {
None
}
}
#[must_use]
pub fn encode_odd_parity(data7: u8) -> u8 {
let d = data7 & 0x7F;
if d.count_ones() % 2 == 0 { d | 0x80 } else { d }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum NationalOption {
English,
German,
SwedishFinnishHungarian,
Italian,
French,
PortugueseSpanish,
CzechSlovak,
Reserved(u8),
}
impl NationalOption {
#[must_use]
pub fn from_bits(v: u8) -> Self {
match v & 0x7 {
0 => NationalOption::English,
1 => NationalOption::German,
2 => NationalOption::SwedishFinnishHungarian,
3 => NationalOption::Italian,
4 => NationalOption::French,
5 => NationalOption::PortugueseSpanish,
6 => NationalOption::CzechSlovak,
other => NationalOption::Reserved(other),
}
}
#[must_use]
pub fn name(&self) -> &'static str {
match self {
NationalOption::English => "English",
NationalOption::German => "German",
NationalOption::SwedishFinnishHungarian => "Swedish/Finnish/Hungarian",
NationalOption::Italian => "Italian",
NationalOption::French => "French",
NationalOption::PortugueseSpanish => "Portuguese/Spanish",
NationalOption::CzechSlovak => "Czech/Slovak",
NationalOption::Reserved(_) => "reserved",
}
}
}
broadcast_common::impl_spec_display!(NationalOption, Reserved);
#[must_use]
pub fn latin_g0_char(code: u8, option: NationalOption) -> char {
let code = code & 0x7F;
if code < 0x20 {
return ' ';
}
if code == 0x7F {
return '\u{2588}';
}
if option == NationalOption::English {
if let Some(c) = english_substitution(code) {
return c;
}
}
code as char
}
fn english_substitution(code: u8) -> Option<char> {
Some(match code {
0x23 => '£', 0x24 => '$', 0x40 => '@', 0x5B => '←', 0x5C => '½', 0x5D => '→', 0x5E => '↑', 0x5F => '#', 0x60 => '―', 0x7B => '¼', 0x7C => '‖', 0x7D => '¾', 0x7E => '÷', _ => return None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PacketAddress {
pub magazine: u8,
pub row: u8,
}
#[must_use]
pub fn decode_packet_address(b4: u8, b5: u8) -> Option<PacketAddress> {
let n4 = decode_hamming_8_4(b4)?;
let n5 = decode_hamming_8_4(b5)?;
let mag_field = n4 & 0x7;
let magazine = if mag_field == 0 { 8 } else { mag_field };
let y0 = (n4 >> 3) & 1;
let row = y0 | (n5 << 1);
Some(PacketAddress { magazine, row })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PageHeader {
pub page: u8,
pub s1: u8,
pub s2: u8,
pub s3: u8,
pub s4: u8,
pub erase_page: bool,
pub newsflash: bool,
pub subtitle: bool,
pub suppress_header: bool,
pub update_indicator: bool,
pub interrupted_sequence: bool,
pub inhibit_display: bool,
pub magazine_serial: bool,
pub national_option: NationalOption,
}
impl PageHeader {
#[must_use]
pub fn parse(txt_data_block: &[u8; 42]) -> Option<PageHeader> {
let page_units = decode_hamming_8_4(txt_data_block[2])?;
let page_tens = decode_hamming_8_4(txt_data_block[3])?;
let s1 = decode_hamming_8_4(txt_data_block[4])?;
let n_s2_c4 = decode_hamming_8_4(txt_data_block[5])?;
let s3 = decode_hamming_8_4(txt_data_block[6])?;
let n_s4_c5_c6 = decode_hamming_8_4(txt_data_block[7])?;
let n_c7_c10 = decode_hamming_8_4(txt_data_block[8])?;
let n_c11_c14 = decode_hamming_8_4(txt_data_block[9])?;
let s2 = n_s2_c4 & 0x7;
let c4 = (n_s2_c4 >> 3) & 1;
let s4 = n_s4_c5_c6 & 0x3;
let c5 = (n_s4_c5_c6 >> 2) & 1;
let c6 = (n_s4_c5_c6 >> 3) & 1;
let c7 = n_c7_c10 & 1;
let c8 = (n_c7_c10 >> 1) & 1;
let c9 = (n_c7_c10 >> 2) & 1;
let c10 = (n_c7_c10 >> 3) & 1;
let c11 = n_c11_c14 & 1;
let c12 = (n_c11_c14 >> 1) & 1;
let c13 = (n_c11_c14 >> 2) & 1;
let c14 = (n_c11_c14 >> 3) & 1;
Some(PageHeader {
page: (page_tens << 4) | page_units,
s1,
s2,
s3,
s4,
erase_page: c4 != 0,
newsflash: c5 != 0,
subtitle: c6 != 0,
suppress_header: c7 != 0,
update_indicator: c8 != 0,
interrupted_sequence: c9 != 0,
inhibit_display: c10 != 0,
magazine_serial: c11 != 0,
national_option: NationalOption::from_bits((c12 << 2) | (c13 << 1) | c14),
})
}
}
fn decode_row_text(txt_data_block: &[u8; 42], option: NationalOption) -> String {
let mut s = String::with_capacity(40);
for &b in &txt_data_block[2..42] {
match decode_odd_parity(b) {
Some(code) => s.push(latin_g0_char(code, option)),
None => s.push('\u{FFFD}'),
}
}
s
}
pub(crate) struct PageAssembler {
magazine: u8,
page: u8,
active: bool,
inhibited: bool,
national_option: NationalOption,
rows: [String; 24],
}
impl PageAssembler {
pub(crate) fn new(magazine: u8, page: u8) -> Self {
PageAssembler {
magazine,
page,
active: false,
inhibited: false,
national_option: NationalOption::English,
rows: core::array::from_fn(|_| String::new()),
}
}
pub(crate) fn push(&mut self, field: &dvb_vbi::TeletextDataField) {
let block = &field.txt_data_block;
let Some(addr) = decode_packet_address(block[0], block[1]) else {
return;
};
if addr.magazine != self.magazine {
return;
}
if addr.row == 0 {
let Some(header) = PageHeader::parse(block) else {
return;
};
self.active = header.page == self.page;
if self.active {
if header.erase_page {
for row in &mut self.rows {
row.clear();
}
}
self.inhibited = header.inhibit_display;
self.national_option = header.national_option;
}
return;
}
if !self.active || addr.row > 24 {
return;
}
self.rows[(addr.row - 1) as usize] = decode_row_text(block, self.national_option);
}
pub(crate) fn display_text(&self) -> String {
if self.inhibited {
return String::new();
}
let lines: Vec<&str> = self
.rows
.iter()
.map(|r| r.trim_end())
.filter(|r| !r.is_empty())
.collect();
lines.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hamming_8_4_round_trips_all_16_nibbles() {
for nibble in 0u8..16 {
let byte = encode_hamming_8_4(nibble);
assert_eq!(
decode_hamming_8_4(byte),
Some(nibble),
"nibble {nibble:#X} round-trip"
);
}
}
#[test]
fn hamming_8_4_corrects_every_single_bit_error() {
for nibble in 0u8..16 {
let byte = encode_hamming_8_4(nibble);
for bit in 0..8u8 {
let corrupted = byte ^ (1 << bit);
assert_eq!(
decode_hamming_8_4(corrupted),
Some(nibble),
"nibble {nibble:#X}, single-bit error at bit {bit} must be corrected"
);
}
}
}
#[test]
fn hamming_8_4_rejects_double_bit_errors() {
let byte = encode_hamming_8_4(0b0110);
let corrupted = byte ^ 0b0000_0011; assert_eq!(
decode_hamming_8_4(corrupted),
None,
"a double-bit error must be rejected, not miscorrected"
);
}
#[test]
fn hamming_manual_cross_check_against_spec_encoding_equations() {
assert_eq!(encode_hamming_8_4(0b0110), 0x38);
assert_eq!(decode_hamming_8_4(0x38), Some(0b0110));
}
#[test]
fn odd_parity_round_trips_all_128_values() {
for data7 in 0u8..128 {
let byte = encode_odd_parity(data7);
assert_eq!(
byte.count_ones() % 2,
1,
"encoded byte must have odd parity"
);
assert_eq!(decode_odd_parity(byte), Some(data7));
}
}
#[test]
fn odd_parity_detects_but_does_not_correct() {
let byte = encode_odd_parity(b'H' & 0x7F);
let corrupted = byte ^ 0x01; assert_eq!(
decode_odd_parity(corrupted),
None,
"odd parity only detects errors; a corrupted byte must not decode"
);
}
#[test]
fn latin_g0_base_matches_ascii_outside_reserved_positions() {
for code in 0x20u8..0x7F {
let is_reserved = matches!(
code,
0x23 | 0x24
| 0x40
| 0x5B
| 0x5C
| 0x5D
| 0x5E
| 0x5F
| 0x60
| 0x7B
| 0x7C
| 0x7D
| 0x7E
);
if !is_reserved {
assert_eq!(
latin_g0_char(code, NationalOption::English),
code as char,
"non-reserved position {code:#04X} must match base ASCII"
);
}
}
}
#[test]
fn latin_g0_english_substitutions_verified_against_table_36() {
assert_eq!(latin_g0_char(0x23, NationalOption::English), '£');
assert_eq!(latin_g0_char(0x24, NationalOption::English), '$');
assert_eq!(latin_g0_char(0x40, NationalOption::English), '@');
assert_eq!(latin_g0_char(0x5B, NationalOption::English), '←');
assert_eq!(latin_g0_char(0x5C, NationalOption::English), '½');
assert_eq!(latin_g0_char(0x5D, NationalOption::English), '→');
assert_eq!(latin_g0_char(0x5E, NationalOption::English), '↑');
assert_eq!(latin_g0_char(0x5F, NationalOption::English), '#');
assert_eq!(latin_g0_char(0x7B, NationalOption::English), '¼');
assert_eq!(latin_g0_char(0x7C, NationalOption::English), '‖');
assert_eq!(latin_g0_char(0x7D, NationalOption::English), '¾');
assert_eq!(latin_g0_char(0x7E, NationalOption::English), '÷');
}
#[test]
fn latin_g0_non_english_falls_back_to_base_at_reserved_positions() {
assert_eq!(latin_g0_char(0x24, NationalOption::German), '$');
}
#[test]
fn control_codes_render_as_space_and_0x7f_as_full_block() {
assert_eq!(latin_g0_char(0x00, NationalOption::English), ' ');
assert_eq!(latin_g0_char(0x1F, NationalOption::English), ' ');
assert_eq!(latin_g0_char(0x7F, NationalOption::English), '\u{2588}');
}
#[test]
fn national_option_decodes_all_8_values() {
assert_eq!(NationalOption::from_bits(0), NationalOption::English);
assert_eq!(NationalOption::from_bits(1), NationalOption::German);
assert_eq!(
NationalOption::from_bits(2),
NationalOption::SwedishFinnishHungarian
);
assert_eq!(NationalOption::from_bits(3), NationalOption::Italian);
assert_eq!(NationalOption::from_bits(4), NationalOption::French);
assert_eq!(
NationalOption::from_bits(5),
NationalOption::PortugueseSpanish
);
assert_eq!(NationalOption::from_bits(6), NationalOption::CzechSlovak);
assert_eq!(NationalOption::from_bits(7), NationalOption::Reserved(7));
}
#[test]
fn packet_address_decodes_magazine_0_as_magazine_8() {
let b4 = encode_hamming_8_4(0); let b5 = encode_hamming_8_4(0); let addr = decode_packet_address(b4, b5).unwrap();
assert_eq!(
addr.magazine, 8,
"magazine field 0 must decode as magazine 8"
);
assert_eq!(addr.row, 0);
}
#[test]
fn packet_address_decodes_magazine_and_row() {
let b4 = encode_hamming_8_4(0b1_011); let b5 = encode_hamming_8_4(0b1000); let addr = decode_packet_address(b4, b5).unwrap();
assert_eq!(addr.magazine, 3);
assert_eq!(addr.row, 17);
}
#[test]
fn page_assembler_tracks_target_page_and_ignores_others() {
let mut asm = PageAssembler::new(8, 0x88);
let header_block = build_header_block(8, 0x88, true, true, false, NationalOption::English);
asm.push(&field_from_block(header_block, 0));
assert!(asm.active);
let row_block = build_row_block(8, 20, "HI");
asm.push(&field_from_block(row_block, 20));
assert_eq!(asm.display_text(), "HI");
let other_mag_row = build_row_block(1, 21, "IGNORED");
asm.push(&field_from_block(other_mag_row, 21));
assert_eq!(
asm.display_text(),
"HI",
"other magazine's row must be ignored"
);
let other_page_header =
build_header_block(8, 0x01, true, false, false, NationalOption::English);
asm.push(&field_from_block(other_page_header, 0));
assert!(!asm.active);
let row_after_switch = build_row_block(8, 21, "SHOULD NOT APPEAR");
asm.push(&field_from_block(row_after_switch, 21));
assert_eq!(
asm.display_text(),
"HI",
"rows while another page is active must not be stored"
);
let back_header = build_header_block(8, 0x88, true, true, false, NationalOption::English);
asm.push(&field_from_block(back_header, 0));
assert_eq!(asm.display_text(), "", "erase_page must clear old rows");
}
#[test]
fn page_assembler_inhibit_display_blanks_text() {
let mut asm = PageAssembler::new(8, 0x88);
asm.push(&field_from_block(
build_header_block(8, 0x88, true, true, false, NationalOption::English),
0,
));
asm.push(&field_from_block(build_row_block(8, 20, "HELLO"), 20));
assert_eq!(asm.display_text(), "HELLO");
asm.push(&field_from_block(
build_header_block(8, 0x88, false, false, true, NationalOption::English),
0,
));
assert_eq!(
asm.display_text(),
"",
"inhibit_display must blank the text"
);
assert_eq!(
asm.rows[19].trim_end(),
"HELLO",
"inhibit_display must not clear the underlying row buffer"
);
}
pub(super) fn build_header_block(
magazine: u8,
page: u8,
erase_page: bool,
subtitle: bool,
inhibit_display: bool,
option: NationalOption,
) -> [u8; 42] {
let mut b = [0u8; 42];
let mag_field = if magazine == 8 { 0 } else { magazine };
b[0] = encode_hamming_8_4(mag_field); b[1] = encode_hamming_8_4(0); b[2] = encode_hamming_8_4(page & 0xF); b[3] = encode_hamming_8_4((page >> 4) & 0xF); b[4] = encode_hamming_8_4(0); let c4 = u8::from(erase_page);
b[5] = encode_hamming_8_4(c4 << 3); b[6] = encode_hamming_8_4(0); let c6 = u8::from(subtitle);
b[7] = encode_hamming_8_4(c6 << 3); let c10 = u8::from(inhibit_display);
b[8] = encode_hamming_8_4(c10 << 3); let opt_bits = match option {
NationalOption::English => 0u8,
NationalOption::German => 1,
NationalOption::SwedishFinnishHungarian => 2,
NationalOption::Italian => 3,
NationalOption::French => 4,
NationalOption::PortugueseSpanish => 5,
NationalOption::CzechSlovak => 6,
NationalOption::Reserved(v) => v,
};
let c12 = (opt_bits >> 2) & 1;
let c13 = (opt_bits >> 1) & 1;
let c14 = opt_bits & 1;
b[9] = encode_hamming_8_4((c14 << 3) | (c13 << 2) | (c12 << 1)); for byte in b.iter_mut().skip(10) {
*byte = encode_odd_parity(0x20); }
b
}
pub(super) fn build_row_block(magazine: u8, row: u8, text: &str) -> [u8; 42] {
assert!((1..=24).contains(&row));
let mut b = [0u8; 42];
let mag_field = if magazine == 8 { 0 } else { magazine };
let y0 = row & 1;
b[0] = encode_hamming_8_4(mag_field | (y0 << 3));
b[1] = encode_hamming_8_4(row >> 1);
let bytes = text.as_bytes();
for i in 0..40usize {
let ch = bytes.get(i).copied().unwrap_or(b' ');
b[2 + i] = encode_odd_parity(ch & 0x7F);
}
b
}
pub(super) fn field_from_block(block: [u8; 42], line: u8) -> dvb_vbi::TeletextDataField {
dvb_vbi::TeletextDataField {
header: dvb_vbi::LineHeader::new(true, line % 24),
framing_code: dvb_vbi::FRAMING_CODE_EBU,
txt_data_block: block,
}
}
}