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}