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}