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