Skip to main content

timed_metadata/webvtt/
teletext.rs

1//! EBU Teletext (ETSI EN 300 706 V1.2.1) subtitle page decode.
2//!
3//! Cite: `docs/teletext-subtitles.md` (curated transcription of the sections
4//! used here: §7.1 packet structure, §8.1/§8.2 FEC, §9.3.1 page header,
5//! Table 2 control bits, §15.1-15.2 national option selection, Table 35/36
6//! Latin G0 character set).
7//!
8//! `dvb-vbi` carries the EN 301 775 VBI PES framing (`TeletextDataField`, a
9//! 42-byte opaque `txt_data_block`) but — by its own module docs — does not
10//! decode EN 300 706 (a large, separate spec covering FEC, character sets and
11//! page composition, not carriage). This module owns that decode, consuming
12//! only `dvb_vbi::TeletextDataField`'s raw bytes:
13//!
14//! - [`decode_hamming_8_4`] / [`encode_hamming_8_4`] — the 4-data-bit +
15//!   4-parity-bit code protecting packet addresses and page header fields
16//!   (§8.2): single-bit errors corrected, double-bit errors rejected.
17//! - [`decode_odd_parity`] / [`encode_odd_parity`] — the 7-data-bit + 1-parity
18//!   code protecting displayable row bytes (§8.1): errors only *detected*
19//!   (odd parity carries no correction capability), rendered as
20//!   `'\u{FFFD}'`.
21//! - [`NationalOption`] — the C12/C13/C14 national option sub-set selector
22//!   (§15.1, Table 32's first ["Latin 0", Level-1-ambiguous] group, the
23//!   interpretation clause 15.1 mandates at presentation Level 1). Every
24//!   variant is decoded and labelled; only [`NationalOption::English`]'s
25//!   character substitutions are applied by [`latin_g0_char`] — others fall
26//!   back to the base/IRV glyph at the 13 nationally-substitutable positions
27//!   (a documented gap: see the crate doc above and `docs/teletext-subtitles.md`).
28//! - [`latin_g0_char`] — Table 35 (Latin G0 Primary Set, identical to 7-bit
29//!   ASCII `0x20`-`0x7F` outside 13 reserved positions) + Table 36 (English
30//!   national option substitutions at those positions).
31//! - [`PacketAddress`] / [`decode_packet_address`] — the magazine + row
32//!   number prefix common to every packet (§7.1.2).
33//! - [`PageHeader`] / [`PageHeader::parse`] — the page header packet (`Y=0`,
34//!   §9.3.1): page number, sub-code, and all eleven control bits (Table 2).
35//! - `PageAssembler` (crate-private) — accumulates a single tracked
36//!   `(magazine, page)`'s row packets (`Y=1..=24`) into display text,
37//!   applying the erase-page (C4) and inhibit-display (C10) control bits;
38//!   drives [`crate::webvtt::TeletextCueExtractor`].
39//!
40//! # Documented losses (first pass, matching this crate's `cc-data`
41//! extractors' lossy-by-design philosophy)
42//!
43//! - **No enhancement packets**: `X/26` (character/attribute overwrite),
44//!   `X/27`/`X/28`/`M/29` (page linking, character-set re-designation, side
45//!   panels, CLUTs) are not processed. Only basic Level-1 page composition
46//!   (`X/0` header + `X/1`-`X/24` display rows) is decoded.
47//! - **No styling**: spacing-attribute control codes (`0x00`-`0x1F`, clause
48//!   12.2 — colour, flash, double-height, box mode, etc.) are rendered as a
49//!   space, not carried into the WebVTT payload as cue styling.
50//! - **Sub-code ignored for page matching**: a page is matched by magazine +
51//!   page number only; multi-subpage rotation (e.g. multi-language subtitle
52//!   variants sharing one page number) is not distinguished.
53//! - **National options**: see [`NationalOption`] above — only English's
54//!   character substitutions are applied.
55use alloc::string::String;
56use alloc::vec::Vec;
57
58/// Decode a Hamming-8/4 protected byte (ETSI EN 300 706 §8.2): bits 1,3,5,7
59/// (transmission order, i.e. the LSB-first bit positions `0x01,0x04,0x10,0x40`)
60/// are the protection bits P1-P4, bits 2,4,6,8 (`0x02,0x08,0x20,0x80`) carry
61/// the 4 data bits D1-D4.
62///
63/// Implemented as a brute-force nearest-codeword search against
64/// [`encode_hamming_8_4`] rather than a hand-derived syndrome table: the code
65/// has minimum distance 4 (a (7,4) Hamming code extended with an overall
66/// parity bit), so a 0-bit-error byte matches its own re-encoding exactly, a
67/// single-bit error matches after flipping exactly one bit, and a
68/// (rejected) double-bit error matches no single flip — this is
69/// mathematically equivalent to the spec's "four odd parity tests A-D"
70/// procedure and was cross-checked against it (see the crate tests below).
71///
72/// Returns the corrected 4-bit data nibble (`D1` in bit 0 .. `D4` in bit 3),
73/// or `None` if the byte is not within Hamming distance 1 of any valid
74/// codeword (an uncorrectable, "double error", byte per §8.2's decode table).
75#[must_use]
76pub fn decode_hamming_8_4(byte: u8) -> Option<u8> {
77    let candidate =
78        (byte >> 1) & 1 | ((byte >> 3) & 1) << 1 | ((byte >> 5) & 1) << 2 | ((byte >> 7) & 1) << 3;
79    if encode_hamming_8_4(candidate) == byte {
80        return Some(candidate);
81    }
82    for bit in 0..8u8 {
83        let flipped = byte ^ (1 << bit);
84        let candidate = (flipped >> 1) & 1
85            | ((flipped >> 3) & 1) << 1
86            | ((flipped >> 5) & 1) << 2
87            | ((flipped >> 7) & 1) << 3;
88        if encode_hamming_8_4(candidate) == flipped {
89            return Some(candidate);
90        }
91    }
92    None
93}
94
95/// Encode a 4-bit data nibble (`D1` in bit 0 .. `D4` in bit 3) as a
96/// Hamming-8/4 protected byte, per the ETSI EN 300 706 §8.2 encoding
97/// equations:
98///
99/// ```text
100/// P1 = 1 ⊕ D1 ⊕ D3 ⊕ D4
101/// P2 = 1 ⊕ D1 ⊕ D2 ⊕ D4
102/// P3 = 1 ⊕ D1 ⊕ D2 ⊕ D3
103/// P4 = 1 ⊕ P1 ⊕ D1 ⊕ P2 ⊕ D2 ⊕ P3 ⊕ D3 ⊕ D4
104/// ```
105///
106/// with wire bit order (transmission order, LSB first) `P1 D1 P2 D2 P3 D3 P4 D4`.
107/// Only the low 4 bits of `nibble` are used.
108#[must_use]
109pub fn encode_hamming_8_4(nibble: u8) -> u8 {
110    let d1 = nibble & 1;
111    let d2 = (nibble >> 1) & 1;
112    let d3 = (nibble >> 2) & 1;
113    let d4 = (nibble >> 3) & 1;
114    let p1 = 1 ^ d1 ^ d3 ^ d4;
115    let p2 = 1 ^ d1 ^ d2 ^ d4;
116    let p3 = 1 ^ d1 ^ d2 ^ d3;
117    let p4 = 1 ^ p1 ^ d1 ^ p2 ^ d2 ^ p3 ^ d3 ^ d4;
118    p1 | (d1 << 1) | (p2 << 2) | (d2 << 3) | (p3 << 4) | (d3 << 5) | (p4 << 6) | (d4 << 7)
119}
120
121/// Decode an odd-parity protected byte (ETSI EN 300 706 §8.1): bit 8 (the
122/// MSB, `0x80`) is the parity bit, bits 1-7 (`0x7F`) carry the 7 data bits.
123/// Odd parity **detects but cannot correct** single-bit errors (unlike
124/// Hamming-8/4): returns `None` on any parity mismatch.
125///
126/// Returns the 7-bit data value, or `None` if the byte does not have odd
127/// parity (an even count of set bits).
128#[must_use]
129pub fn decode_odd_parity(byte: u8) -> Option<u8> {
130    if byte.count_ones() % 2 == 1 {
131        Some(byte & 0x7F)
132    } else {
133        None
134    }
135}
136
137/// Encode a 7-bit data value with odd parity in bit 8 (MSB), per ETSI
138/// EN 300 706 §8.1. Only the low 7 bits of `data7` are used.
139#[must_use]
140pub fn encode_odd_parity(data7: u8) -> u8 {
141    let d = data7 & 0x7F;
142    if d.count_ones() % 2 == 0 { d | 0x80 } else { d }
143}
144
145/// The C12/C13/C14 "National Option Character Subset" selector (ETSI
146/// EN 300 706 Table 2, page header control bits) — decoded per Table 32's
147/// first group (`0000XXX`, "Latin 0"), which clause 15.1 states is the
148/// interpretation at presentation Level 1 ("the national option sub-set in
149/// use on the page is defined by the C12, C13 and C14 control bits in the
150/// page header alone").
151///
152/// Only [`NationalOption::English`]'s character substitutions are applied by
153/// [`latin_g0_char`]; the other variants are decoded and labelled (spec
154/// fidelity — every value of this 3-bit field has a name) but fall back to
155/// the base Latin G0 glyph at the 13 nationally-substitutable positions, a
156/// documented gap (see the module docs and `docs/teletext-subtitles.md`).
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
159#[non_exhaustive]
160pub enum NationalOption {
161    /// `000` — English. The only option whose Table 36 substitutions are
162    /// implemented (see [`latin_g0_char`]).
163    English,
164    /// `001` — German.
165    German,
166    /// `010` — Swedish/Finnish/Hungarian.
167    SwedishFinnishHungarian,
168    /// `011` — Italian.
169    Italian,
170    /// `100` — French.
171    French,
172    /// `101` — Portuguese/Spanish.
173    PortugueseSpanish,
174    /// `110` — Czech/Slovak.
175    CzechSlovak,
176    /// `111` — reserved (Table 32's first group defines no option here); the
177    /// raw 3-bit value is retained.
178    Reserved(u8),
179}
180
181impl NationalOption {
182    /// Decode from the packed 3-bit value `(c12 << 2) | (c13 << 1) | c14`.
183    #[must_use]
184    pub fn from_bits(v: u8) -> Self {
185        match v & 0x7 {
186            0 => NationalOption::English,
187            1 => NationalOption::German,
188            2 => NationalOption::SwedishFinnishHungarian,
189            3 => NationalOption::Italian,
190            4 => NationalOption::French,
191            5 => NationalOption::PortugueseSpanish,
192            6 => NationalOption::CzechSlovak,
193            other => NationalOption::Reserved(other),
194        }
195    }
196
197    /// Spec token (issue #204 label convention).
198    #[must_use]
199    pub fn name(&self) -> &'static str {
200        match self {
201            NationalOption::English => "English",
202            NationalOption::German => "German",
203            NationalOption::SwedishFinnishHungarian => "Swedish/Finnish/Hungarian",
204            NationalOption::Italian => "Italian",
205            NationalOption::French => "French",
206            NationalOption::PortugueseSpanish => "Portuguese/Spanish",
207            NationalOption::CzechSlovak => "Czech/Slovak",
208            NationalOption::Reserved(_) => "reserved",
209        }
210    }
211}
212broadcast_common::impl_spec_display!(NationalOption, Reserved);
213
214/// Decode one Latin G0 (Table 35) code position (`0x20`-`0x7F`) to its
215/// display character, applying English (Table 36) national-option
216/// substitutions at the 13 reserved positions when `option` is
217/// [`NationalOption::English`] (verified against the ETSI EN 300 706 PDF's
218/// Table 35/36 glyph charts — these are rendered as images in the spec, not
219/// extractable text, so they were read visually; see
220/// `docs/teletext-subtitles.md`).
221///
222/// Codes below `0x20` are Level-1 spacing attributes (clause 12.2: colour,
223/// flash, height, box mode, …) — not decoded to a glyph; rendered as a space
224/// (this crate's lossy-first-pass philosophy, matching the `cc-data`
225/// extractors' documented styling losses). Code `0x7F` is the Level-1
226/// "reserved position" full block (Table 35 note 4); rendered as `'\u{2588}'`
227/// (FULL BLOCK).
228#[must_use]
229pub fn latin_g0_char(code: u8, option: NationalOption) -> char {
230    let code = code & 0x7F;
231    if code < 0x20 {
232        return ' ';
233    }
234    if code == 0x7F {
235        return '\u{2588}';
236    }
237    if option == NationalOption::English {
238        if let Some(c) = english_substitution(code) {
239            return c;
240        }
241    }
242    // Base Latin G0 / International Reference Version: identical to ASCII
243    // at every position outside the 13 reserved ones.
244    code as char
245}
246
247/// The English (Table 36) substitution for one of Table 35's 13 reserved
248/// code positions, or `None` if `code` is not one of them (in which case the
249/// base ASCII/IRV glyph applies).
250fn english_substitution(code: u8) -> Option<char> {
251    Some(match code {
252        0x23 => '£', // POUND SIGN
253        0x24 => '$', // DOLLAR SIGN (unchanged from base for English)
254        0x40 => '@', // COMMERCIAL AT (unchanged from base for English)
255        0x5B => '←', // LEFTWARDS ARROW
256        0x5C => '½', // VULGAR FRACTION ONE HALF
257        0x5D => '→', // RIGHTWARDS ARROW
258        0x5E => '↑', // UPWARDS ARROW
259        0x5F => '#', // NUMBER SIGN
260        0x60 => '―', // HORIZONTAL BAR (glyph is a plain horizontal
261        // rule in the spec's bitmap chart; U+2015 is this crate's choice of
262        // Unicode codepoint for it — a judgment call, see module docs)
263        0x7B => '¼', // VULGAR FRACTION ONE QUARTER
264        0x7C => '‖', // DOUBLE VERTICAL LINE
265        0x7D => '¾', // VULGAR FRACTION THREE QUARTERS
266        0x7E => '÷', // DIVISION SIGN
267        _ => return None,
268    })
269}
270
271/// The magazine + row number packet address prefix common to every Teletext
272/// packet (ETSI EN 300 706 §7.1.2): 2 bytes, both Hamming-8/4 coded.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct PacketAddress {
275    /// Magazine number, `1..=8` (a packet address magazine field of `0`
276    /// denotes magazine 8 — §3, "magazine number 8").
277    pub magazine: u8,
278    /// Packet number `Y`, `0..=31` (`0` = page header, `1..=25` = display
279    /// rows, `26..=31` = non-displayable enhancement packets).
280    pub row: u8,
281}
282
283/// Decode the 2-byte packet address prefix (`txt_data_block[0..2]` of a
284/// [`dvb_vbi::TeletextDataField`] — EN 300 706 bytes 4-5, since
285/// `txt_data_block` starts after the clock-run-in/framing-code).
286///
287/// Returns `None` if either byte is an uncorrectable (double-bit-error)
288/// Hamming-8/4 byte; the packet is then silently ignored by
289/// `PageAssembler` (a robustness choice, not a spec requirement).
290#[must_use]
291pub fn decode_packet_address(b4: u8, b5: u8) -> Option<PacketAddress> {
292    let n4 = decode_hamming_8_4(b4)?;
293    let n5 = decode_hamming_8_4(b5)?;
294    let mag_field = n4 & 0x7;
295    let magazine = if mag_field == 0 { 8 } else { mag_field };
296    let y0 = (n4 >> 3) & 1;
297    let row = y0 | (n5 << 1);
298    Some(PacketAddress { magazine, row })
299}
300
301/// A decoded page header packet (`Y = 0`, ETSI EN 300 706 §9.3.1): page
302/// address, sub-code, and all eleven control bits (Table 2).
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub struct PageHeader {
305    /// Page number, `Pt << 4 | Pu` (§9.3.1.1; both nibbles `0x0`-`0xF`).
306    pub page: u8,
307    /// Page sub-code element S1 (§9.3.1.2, byte 8; `0x0`-`0xF`).
308    pub s1: u8,
309    /// Page sub-code element S2 (byte 9 bits 2/4/6; `0x0`-`0x7`).
310    pub s2: u8,
311    /// Page sub-code element S3 (byte 10; `0x0`-`0xF`).
312    pub s3: u8,
313    /// Page sub-code element S4 (byte 11 bits 2/4; `0x0`-`0x3`).
314    pub s4: u8,
315    /// C4 Erase Page.
316    pub erase_page: bool,
317    /// C5 Newsflash.
318    pub newsflash: bool,
319    /// C6 Subtitle.
320    pub subtitle: bool,
321    /// C7 Suppress Header (row 0 not displayed).
322    pub suppress_header: bool,
323    /// C8 Update Indicator.
324    pub update_indicator: bool,
325    /// C9 Interrupted Sequence.
326    pub interrupted_sequence: bool,
327    /// C10 Inhibit Display (rows 1-24 not displayed).
328    pub inhibit_display: bool,
329    /// C11 Magazine Serial (`true` = serial mode, `false` = parallel mode).
330    pub magazine_serial: bool,
331    /// C12/C13/C14 National Option Character Subset.
332    pub national_option: NationalOption,
333}
334
335impl PageHeader {
336    /// Decode a page header from the 8 Hamming-8/4 bytes at
337    /// `txt_data_block[2..10]` (EN 300 706 bytes 6-13 — the 2 bytes of
338    /// packet address precede these). Returns `None` if any of the 8 bytes
339    /// is an uncorrectable Hamming-8/4 byte.
340    #[must_use]
341    pub fn parse(txt_data_block: &[u8; 42]) -> Option<PageHeader> {
342        let page_units = decode_hamming_8_4(txt_data_block[2])?;
343        let page_tens = decode_hamming_8_4(txt_data_block[3])?;
344        let s1 = decode_hamming_8_4(txt_data_block[4])?;
345        let n_s2_c4 = decode_hamming_8_4(txt_data_block[5])?;
346        let s3 = decode_hamming_8_4(txt_data_block[6])?;
347        let n_s4_c5_c6 = decode_hamming_8_4(txt_data_block[7])?;
348        let n_c7_c10 = decode_hamming_8_4(txt_data_block[8])?;
349        let n_c11_c14 = decode_hamming_8_4(txt_data_block[9])?;
350
351        let s2 = n_s2_c4 & 0x7;
352        let c4 = (n_s2_c4 >> 3) & 1;
353        let s4 = n_s4_c5_c6 & 0x3;
354        let c5 = (n_s4_c5_c6 >> 2) & 1;
355        let c6 = (n_s4_c5_c6 >> 3) & 1;
356        let c7 = n_c7_c10 & 1;
357        let c8 = (n_c7_c10 >> 1) & 1;
358        let c9 = (n_c7_c10 >> 2) & 1;
359        let c10 = (n_c7_c10 >> 3) & 1;
360        let c11 = n_c11_c14 & 1;
361        let c12 = (n_c11_c14 >> 1) & 1;
362        let c13 = (n_c11_c14 >> 2) & 1;
363        let c14 = (n_c11_c14 >> 3) & 1;
364
365        Some(PageHeader {
366            page: (page_tens << 4) | page_units,
367            s1,
368            s2,
369            s3,
370            s4,
371            erase_page: c4 != 0,
372            newsflash: c5 != 0,
373            subtitle: c6 != 0,
374            suppress_header: c7 != 0,
375            update_indicator: c8 != 0,
376            interrupted_sequence: c9 != 0,
377            inhibit_display: c10 != 0,
378            magazine_serial: c11 != 0,
379            national_option: NationalOption::from_bits((c12 << 2) | (c13 << 1) | c14),
380        })
381    }
382}
383
384/// Decode a display row's 40 odd-parity payload bytes
385/// (`txt_data_block[2..42]`) to text, applying `option`'s character
386/// substitutions. A byte that fails its parity check (undetectable which bit
387/// is wrong — odd parity has no correction capability, §8.1) is rendered as
388/// `'\u{FFFD}'` (REPLACEMENT CHARACTER).
389fn decode_row_text(txt_data_block: &[u8; 42], option: NationalOption) -> String {
390    let mut s = String::with_capacity(40);
391    for &b in &txt_data_block[2..42] {
392        match decode_odd_parity(b) {
393            Some(code) => s.push(latin_g0_char(code, option)),
394            None => s.push('\u{FFFD}'),
395        }
396    }
397    s
398}
399
400/// Accumulates one tracked `(magazine, page)`'s row packets into display
401/// text (ETSI EN 300 706 §7.2: a page's body is its header packet plus all
402/// subsequent `Y=1..=24` packets in the same magazine, up to the next
403/// header). Crate-private: the public surface is
404/// [`crate::webvtt::TeletextCueExtractor`].
405pub(crate) struct PageAssembler {
406    magazine: u8,
407    page: u8,
408    /// Whether the magazine's most-recently-seen header packet matched
409    /// `(magazine, page)` — row packets in a magazine belong to whichever
410    /// page was last headed, per §7.2.1.
411    active: bool,
412    inhibited: bool,
413    national_option: NationalOption,
414    /// Index `0` = row 1 .. index `23` = row 24.
415    rows: [String; 24],
416}
417
418impl PageAssembler {
419    pub(crate) fn new(magazine: u8, page: u8) -> Self {
420        PageAssembler {
421            magazine,
422            page,
423            active: false,
424            inhibited: false,
425            national_option: NationalOption::English,
426            rows: core::array::from_fn(|_| String::new()),
427        }
428    }
429
430    pub(crate) fn push(&mut self, field: &dvb_vbi::TeletextDataField) {
431        let block = &field.txt_data_block;
432        let Some(addr) = decode_packet_address(block[0], block[1]) else {
433            return;
434        };
435        if addr.magazine != self.magazine {
436            return;
437        }
438        if addr.row == 0 {
439            let Some(header) = PageHeader::parse(block) else {
440                return;
441            };
442            self.active = header.page == self.page;
443            if self.active {
444                if header.erase_page {
445                    for row in &mut self.rows {
446                        row.clear();
447                    }
448                }
449                self.inhibited = header.inhibit_display;
450                self.national_option = header.national_option;
451            }
452            return;
453        }
454        if !self.active || addr.row > 24 {
455            return;
456        }
457        self.rows[(addr.row - 1) as usize] = decode_row_text(block, self.national_option);
458    }
459
460    /// The currently displayed subtitle text: non-empty, trailing-space
461    /// trimmed rows 1-24, in row order, joined with `\n`. Empty if the page
462    /// is currently inhibited (C10) or no row is non-empty.
463    pub(crate) fn display_text(&self) -> String {
464        if self.inhibited {
465            return String::new();
466        }
467        let lines: Vec<&str> = self
468            .rows
469            .iter()
470            .map(|r| r.trim_end())
471            .filter(|r| !r.is_empty())
472            .collect();
473        lines.join("\n")
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn hamming_8_4_round_trips_all_16_nibbles() {
483        for nibble in 0u8..16 {
484            let byte = encode_hamming_8_4(nibble);
485            assert_eq!(
486                decode_hamming_8_4(byte),
487                Some(nibble),
488                "nibble {nibble:#X} round-trip"
489            );
490        }
491    }
492
493    #[test]
494    fn hamming_8_4_corrects_every_single_bit_error() {
495        for nibble in 0u8..16 {
496            let byte = encode_hamming_8_4(nibble);
497            for bit in 0..8u8 {
498                let corrupted = byte ^ (1 << bit);
499                assert_eq!(
500                    decode_hamming_8_4(corrupted),
501                    Some(nibble),
502                    "nibble {nibble:#X}, single-bit error at bit {bit} must be corrected"
503                );
504            }
505        }
506    }
507
508    #[test]
509    fn hamming_8_4_rejects_double_bit_errors() {
510        // Flip 2 bits of a known-good codeword: per §8.2, "double bit errors
511        // can be detected" (rejected, not silently accepted as some other
512        // nibble).
513        let byte = encode_hamming_8_4(0b0110);
514        let corrupted = byte ^ 0b0000_0011; // flip bits 0 and 1
515        assert_eq!(
516            decode_hamming_8_4(corrupted),
517            None,
518            "a double-bit error must be rejected, not miscorrected"
519        );
520    }
521
522    #[test]
523    fn hamming_manual_cross_check_against_spec_encoding_equations() {
524        // Manually compute nibble 0b0110 (D1=0,D2=1,D3=1,D4=0) by hand from
525        // §8.2's stated equations, cross-checking `encode_hamming_8_4`:
526        // P1 = 1^D1^D3^D4 = 1^0^1^0 = 0
527        // P2 = 1^D1^D2^D4 = 1^0^1^0 = 0
528        // P3 = 1^D1^D2^D3 = 1^0^1^1 = 1
529        // P4 = 1^P1^D1^P2^D2^P3^D3^D4 = 1^0^0^0^1^1^1^0 = 0
530        // wire bits (transmission order P1 D1 P2 D2 P3 D3 P4 D4) = 0 0 0 1 1 1 0 0
531        // packed LSB-first: bit1=P1=0,bit2=D1=0,bit3=P2=0,bit4=D2=1,
532        //                    bit5=P3=1,bit6=D3=1,bit7=P4=0,bit8=D4=0
533        // byte = 0b0011_1000 = 0x38
534        assert_eq!(encode_hamming_8_4(0b0110), 0x38);
535        assert_eq!(decode_hamming_8_4(0x38), Some(0b0110));
536    }
537
538    #[test]
539    fn odd_parity_round_trips_all_128_values() {
540        for data7 in 0u8..128 {
541            let byte = encode_odd_parity(data7);
542            assert_eq!(
543                byte.count_ones() % 2,
544                1,
545                "encoded byte must have odd parity"
546            );
547            assert_eq!(decode_odd_parity(byte), Some(data7));
548        }
549    }
550
551    #[test]
552    fn odd_parity_detects_but_does_not_correct() {
553        let byte = encode_odd_parity(b'H' & 0x7F);
554        let corrupted = byte ^ 0x01; // flip one data bit -> even parity
555        assert_eq!(
556            decode_odd_parity(corrupted),
557            None,
558            "odd parity only detects errors; a corrupted byte must not decode"
559        );
560    }
561
562    #[test]
563    fn latin_g0_base_matches_ascii_outside_reserved_positions() {
564        for code in 0x20u8..0x7F {
565            let is_reserved = matches!(
566                code,
567                0x23 | 0x24
568                    | 0x40
569                    | 0x5B
570                    | 0x5C
571                    | 0x5D
572                    | 0x5E
573                    | 0x5F
574                    | 0x60
575                    | 0x7B
576                    | 0x7C
577                    | 0x7D
578                    | 0x7E
579            );
580            if !is_reserved {
581                assert_eq!(
582                    latin_g0_char(code, NationalOption::English),
583                    code as char,
584                    "non-reserved position {code:#04X} must match base ASCII"
585                );
586            }
587        }
588    }
589
590    #[test]
591    fn latin_g0_english_substitutions_verified_against_table_36() {
592        // Values read directly from ETSI EN 300 706 Table 36's "English" row
593        // (bitmap glyph chart, visually verified — see docs/teletext-subtitles.md).
594        assert_eq!(latin_g0_char(0x23, NationalOption::English), '£');
595        assert_eq!(latin_g0_char(0x24, NationalOption::English), '$');
596        assert_eq!(latin_g0_char(0x40, NationalOption::English), '@');
597        assert_eq!(latin_g0_char(0x5B, NationalOption::English), '←');
598        assert_eq!(latin_g0_char(0x5C, NationalOption::English), '½');
599        assert_eq!(latin_g0_char(0x5D, NationalOption::English), '→');
600        assert_eq!(latin_g0_char(0x5E, NationalOption::English), '↑');
601        assert_eq!(latin_g0_char(0x5F, NationalOption::English), '#');
602        assert_eq!(latin_g0_char(0x7B, NationalOption::English), '¼');
603        assert_eq!(latin_g0_char(0x7C, NationalOption::English), '‖');
604        assert_eq!(latin_g0_char(0x7D, NationalOption::English), '¾');
605        assert_eq!(latin_g0_char(0x7E, NationalOption::English), '÷');
606    }
607
608    #[test]
609    fn latin_g0_non_english_falls_back_to_base_at_reserved_positions() {
610        // Documented gap: German substitutions are not implemented, so the
611        // base ASCII glyph is returned instead (not the German '§' etc).
612        assert_eq!(latin_g0_char(0x24, NationalOption::German), '$');
613    }
614
615    #[test]
616    fn control_codes_render_as_space_and_0x7f_as_full_block() {
617        assert_eq!(latin_g0_char(0x00, NationalOption::English), ' ');
618        assert_eq!(latin_g0_char(0x1F, NationalOption::English), ' ');
619        assert_eq!(latin_g0_char(0x7F, NationalOption::English), '\u{2588}');
620    }
621
622    #[test]
623    fn national_option_decodes_all_8_values() {
624        assert_eq!(NationalOption::from_bits(0), NationalOption::English);
625        assert_eq!(NationalOption::from_bits(1), NationalOption::German);
626        assert_eq!(
627            NationalOption::from_bits(2),
628            NationalOption::SwedishFinnishHungarian
629        );
630        assert_eq!(NationalOption::from_bits(3), NationalOption::Italian);
631        assert_eq!(NationalOption::from_bits(4), NationalOption::French);
632        assert_eq!(
633            NationalOption::from_bits(5),
634            NationalOption::PortugueseSpanish
635        );
636        assert_eq!(NationalOption::from_bits(6), NationalOption::CzechSlovak);
637        assert_eq!(NationalOption::from_bits(7), NationalOption::Reserved(7));
638    }
639
640    #[test]
641    fn packet_address_decodes_magazine_0_as_magazine_8() {
642        // magazine field = 0 (nibble low 3 bits = 0), row = 0.
643        let b4 = encode_hamming_8_4(0); // magazine field 0, Y bit0 = 0
644        let b5 = encode_hamming_8_4(0); // Y bits 1-4 = 0
645        let addr = decode_packet_address(b4, b5).unwrap();
646        assert_eq!(
647            addr.magazine, 8,
648            "magazine field 0 must decode as magazine 8"
649        );
650        assert_eq!(addr.row, 0);
651    }
652
653    #[test]
654    fn packet_address_decodes_magazine_and_row() {
655        // magazine field = 3, Y = 17 (0b10001): Y bit0=1 (goes in byte4 D4),
656        // Y bits1-4 = 0b1000 (goes in byte5 nibble).
657        let b4 = encode_hamming_8_4(0b1_011); // D1..D3=011(mag=3), D4=1(Y bit0)
658        let b5 = encode_hamming_8_4(0b1000); // Y bits1..4 = 1000
659        let addr = decode_packet_address(b4, b5).unwrap();
660        assert_eq!(addr.magazine, 3);
661        assert_eq!(addr.row, 17);
662    }
663
664    #[test]
665    fn page_assembler_tracks_target_page_and_ignores_others() {
666        let mut asm = PageAssembler::new(8, 0x88);
667
668        // Header for magazine 8, page 0x88, C6 subtitle set, C4 erase set.
669        let header_block = build_header_block(8, 0x88, true, true, false, NationalOption::English);
670        asm.push(&field_from_block(header_block, 0));
671        assert!(asm.active);
672
673        // Row 20: "HI"
674        let row_block = build_row_block(8, 20, "HI");
675        asm.push(&field_from_block(row_block, 20));
676        assert_eq!(asm.display_text(), "HI");
677
678        // A row for a DIFFERENT magazine must be ignored.
679        let other_mag_row = build_row_block(1, 21, "IGNORED");
680        asm.push(&field_from_block(other_mag_row, 21));
681        assert_eq!(
682            asm.display_text(),
683            "HI",
684            "other magazine's row must be ignored"
685        );
686
687        // A header for a different page in the same magazine deactivates us.
688        let other_page_header =
689            build_header_block(8, 0x01, true, false, false, NationalOption::English);
690        asm.push(&field_from_block(other_page_header, 0));
691        assert!(!asm.active);
692        let row_after_switch = build_row_block(8, 21, "SHOULD NOT APPEAR");
693        asm.push(&field_from_block(row_after_switch, 21));
694        assert_eq!(
695            asm.display_text(),
696            "HI",
697            "rows while another page is active must not be stored"
698        );
699
700        // Switch back with erase -> old row content is cleared.
701        let back_header = build_header_block(8, 0x88, true, true, false, NationalOption::English);
702        asm.push(&field_from_block(back_header, 0));
703        assert_eq!(asm.display_text(), "", "erase_page must clear old rows");
704    }
705
706    #[test]
707    fn page_assembler_inhibit_display_blanks_text() {
708        let mut asm = PageAssembler::new(8, 0x88);
709        asm.push(&field_from_block(
710            build_header_block(8, 0x88, true, true, false, NationalOption::English),
711            0,
712        ));
713        asm.push(&field_from_block(build_row_block(8, 20, "HELLO"), 20));
714        assert_eq!(asm.display_text(), "HELLO");
715
716        // A header with C10 (inhibit_display) set, but C4 (erase) NOT set,
717        // must blank the display while leaving the row buffer itself
718        // populated (proves inhibit is a display-time gate, not a clear).
719        asm.push(&field_from_block(
720            build_header_block(8, 0x88, false, false, true, NationalOption::English),
721            0,
722        ));
723        assert_eq!(
724            asm.display_text(),
725            "",
726            "inhibit_display must blank the text"
727        );
728        assert_eq!(
729            asm.rows[19].trim_end(),
730            "HELLO",
731            "inhibit_display must not clear the underlying row buffer"
732        );
733    }
734
735    /// Test-only helper: build a 42-byte `txt_data_block` for a page header
736    /// packet (`Y=0`) at `(magazine, page)` with the given C4/C6/C10 bits.
737    /// Uses [`encode_hamming_8_4`] — the same function proven correct by the
738    /// round-trip and manual-cross-check tests above — to build spec-valid
739    /// wire bytes (this project's established "construct from verified spec
740    /// encode rules" fixture fallback; see `docs/teletext-subtitles.md`).
741    pub(super) fn build_header_block(
742        magazine: u8,
743        page: u8,
744        erase_page: bool,
745        subtitle: bool,
746        inhibit_display: bool,
747        option: NationalOption,
748    ) -> [u8; 42] {
749        let mut b = [0u8; 42];
750        let mag_field = if magazine == 8 { 0 } else { magazine };
751        b[0] = encode_hamming_8_4(mag_field); // Y bit0 = 0 (row 0)
752        b[1] = encode_hamming_8_4(0); // Y bits1..4 = 0
753        b[2] = encode_hamming_8_4(page & 0xF); // page units
754        b[3] = encode_hamming_8_4((page >> 4) & 0xF); // page tens
755        b[4] = encode_hamming_8_4(0); // S1 = 0
756        let c4 = u8::from(erase_page);
757        b[5] = encode_hamming_8_4(c4 << 3); // S2=0, C4
758        b[6] = encode_hamming_8_4(0); // S3 = 0
759        let c6 = u8::from(subtitle);
760        b[7] = encode_hamming_8_4(c6 << 3); // S4=0, C5=0, C6
761        let c10 = u8::from(inhibit_display);
762        b[8] = encode_hamming_8_4(c10 << 3); // C7=C8=C9=0, C10
763        let opt_bits = match option {
764            NationalOption::English => 0u8,
765            NationalOption::German => 1,
766            NationalOption::SwedishFinnishHungarian => 2,
767            NationalOption::Italian => 3,
768            NationalOption::French => 4,
769            NationalOption::PortugueseSpanish => 5,
770            NationalOption::CzechSlovak => 6,
771            NationalOption::Reserved(v) => v,
772        };
773        let c12 = (opt_bits >> 2) & 1;
774        let c13 = (opt_bits >> 1) & 1;
775        let c14 = opt_bits & 1;
776        b[9] = encode_hamming_8_4((c14 << 3) | (c13 << 2) | (c12 << 1)); // C11=0
777        for byte in b.iter_mut().skip(10) {
778            *byte = encode_odd_parity(0x20); // row-0 text: spaces
779        }
780        b
781    }
782
783    /// Test-only helper: build a 42-byte `txt_data_block` for a display row
784    /// packet (`Y=1..=24`) carrying `text` (ASCII only, right-padded with
785    /// spaces to 40 columns), odd-parity encoded per §8.1.
786    pub(super) fn build_row_block(magazine: u8, row: u8, text: &str) -> [u8; 42] {
787        assert!((1..=24).contains(&row));
788        let mut b = [0u8; 42];
789        let mag_field = if magazine == 8 { 0 } else { magazine };
790        let y0 = row & 1;
791        b[0] = encode_hamming_8_4(mag_field | (y0 << 3));
792        b[1] = encode_hamming_8_4(row >> 1);
793        let bytes = text.as_bytes();
794        for i in 0..40usize {
795            let ch = bytes.get(i).copied().unwrap_or(b' ');
796            b[2 + i] = encode_odd_parity(ch & 0x7F);
797        }
798        b
799    }
800
801    pub(super) fn field_from_block(block: [u8; 42], line: u8) -> dvb_vbi::TeletextDataField {
802        dvb_vbi::TeletextDataField {
803            header: dvb_vbi::LineHeader::new(true, line % 24),
804            framing_code: dvb_vbi::FRAMING_CODE_EBU,
805            txt_data_block: block,
806        }
807    }
808}