Skip to main content

dvb_cc/decode/
cea608.rs

1//! CEA-608 (line-21) caption decode — ANSI/CTA-608-E.
2//!
3//! Decodes the line-21 two-byte control / character codes that the
4//! [`crate::CcData`] carriage demuxes (`cc_type` 0 = field 1, 1 = field 2) into a
5//! caption screen. Implements the control-code state machine of
6//! `dvb-cc/docs/decode/cea608-decode.md`:
7//!
8//! - pop-on (RCL/EOC), roll-up (RU2/RU3/RU4 + CR), paint-on (RDC) modes,
9//! - Preamble Address Codes (row + indent + colour/italics/underline),
10//! - mid-row colour/italics codes, tab offsets,
11//! - the standard (Table 50), special (Table 49) and extended Western-European
12//!   (Tables 5–10, automatic-backspace) character sets,
13//! - the four data channels CC1–CC4, control-code doubling, and field-2 XDS
14//!   detect-and-skip.
15//!
16//! Bytes carry odd parity in b7; this decoder strips parity (masks b7) to the
17//! 7-bit value before classifying.
18
19use crate::cc_data::{CcTriplet, CcType};
20use alloc::string::String;
21use alloc::vec::Vec;
22
23/// Foreground colour of a line-21 caption cell (CTA-608-E Tables 51/53).
24///
25/// Returned by [`Cea608StyledChar::color`]; corresponds to the 3-bit colour
26/// index in PAC / mid-row code tables.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize))]
29#[non_exhaustive]
30pub enum Cea608Color {
31    /// White (default).
32    #[default]
33    White,
34    /// Green.
35    Green,
36    /// Blue.
37    Blue,
38    /// Cyan.
39    Cyan,
40    /// Red.
41    Red,
42    /// Yellow.
43    Yellow,
44    /// Magenta.
45    Magenta,
46}
47
48impl Cea608Color {
49    /// Label per the project's `name()` convention.
50    #[must_use]
51    pub fn name(&self) -> &'static str {
52        match self {
53            Self::White => "white",
54            Self::Green => "green",
55            Self::Blue => "blue",
56            Self::Cyan => "cyan",
57            Self::Red => "red",
58            Self::Yellow => "yellow",
59            Self::Magenta => "magenta",
60        }
61    }
62
63    /// From the 3-bit colour index used in PAC and mid-row tables (Tables 51/53).
64    #[must_use]
65    pub(super) fn from_idx(idx: u8) -> Self {
66        match idx & 0x07 {
67            0 => Self::White,
68            1 => Self::Green,
69            2 => Self::Blue,
70            3 => Self::Cyan,
71            4 => Self::Red,
72            5 => Self::Yellow,
73            6 => Self::Magenta,
74            _ => Self::White,
75        }
76    }
77}
78dvb_common::impl_spec_display!(Cea608Color);
79
80/// Number of caption rows on a line-21 screen (§3.2.2).
81const SCREEN_ROWS: usize = 15;
82/// Number of caption columns on a line-21 screen.
83const SCREEN_COLS: usize = 32;
84
85// ── Misc-control 2nd-byte values (Table 52, data-channel-1 column) ────────────
86const MC_RCL: u8 = 0x20;
87const MC_BS: u8 = 0x21;
88const MC_DER: u8 = 0x24;
89const MC_RU2: u8 = 0x25;
90const MC_RU3: u8 = 0x26;
91const MC_RU4: u8 = 0x27;
92const MC_FON: u8 = 0x28;
93const MC_RDC: u8 = 0x29;
94const MC_TR: u8 = 0x2A;
95const MC_RTD: u8 = 0x2B;
96const MC_EDM: u8 = 0x2C;
97const MC_CR: u8 = 0x2D;
98const MC_ENM: u8 = 0x2E;
99const MC_EOC: u8 = 0x2F;
100
101// ── Tab offsets (Table 52): first byte 0x17/0x1F, 2nd byte 0x21–0x23 ──────────
102const TAB_FIRST_C1: u8 = 0x17;
103const TAB_FIRST_C2: u8 = 0x1F;
104const TAB1: u8 = 0x21;
105const TAB3: u8 = 0x23;
106
107/// The caption mode (§6.1, §7).
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize))]
110#[non_exhaustive]
111pub enum Cea608Mode {
112    /// No mode selected yet.
113    #[default]
114    None,
115    /// Pop-on (RCL → load to back buffer → EOC flips).
116    PopOn,
117    /// Roll-up with the given number of rows (2/3/4).
118    RollUp(u8),
119    /// Paint-on (RDC → paint to displayed memory).
120    PaintOn,
121    /// Text mode (TR/RTD).
122    Text,
123}
124
125impl Cea608Mode {
126    /// Label per the project's `name()` convention.
127    #[must_use]
128    pub fn name(&self) -> &'static str {
129        match self {
130            Self::None => "none",
131            Self::PopOn => "pop_on",
132            Self::RollUp(_) => "roll_up",
133            Self::PaintOn => "paint_on",
134            Self::Text => "text",
135        }
136    }
137}
138dvb_common::impl_spec_display!(Cea608Mode);
139
140/// A line-21 caption data channel (Table 1, §4.1): the eight CC/Text logical
141/// services keyed by field + data-channel.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143#[cfg_attr(feature = "serde", derive(serde::Serialize))]
144#[non_exhaustive]
145pub enum Cea608Channel {
146    /// CC1 — field 1, data channel 1 (primary).
147    Cc1,
148    /// CC2 — field 1, data channel 2.
149    Cc2,
150    /// CC3 — field 2, data channel 1 (secondary).
151    Cc3,
152    /// CC4 — field 2, data channel 2.
153    Cc4,
154}
155
156impl Cea608Channel {
157    /// Index 0–3 into the decoder's per-channel state.
158    #[must_use]
159    fn index(self) -> usize {
160        match self {
161            Self::Cc1 => 0,
162            Self::Cc2 => 1,
163            Self::Cc3 => 2,
164            Self::Cc4 => 3,
165        }
166    }
167    /// Label per the project's `name()` convention.
168    #[must_use]
169    pub fn name(&self) -> &'static str {
170        match self {
171            Self::Cc1 => "cc1",
172            Self::Cc2 => "cc2",
173            Self::Cc3 => "cc3",
174            Self::Cc4 => "cc4",
175        }
176    }
177}
178dvb_common::impl_spec_display!(Cea608Channel);
179
180/// A styled character cell on a 608 caption screen.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182#[cfg_attr(feature = "serde", derive(serde::Serialize))]
183pub struct Cea608StyledChar {
184    /// The glyph.
185    pub ch: char,
186    /// Underline attribute.
187    pub underline: bool,
188    /// Italics attribute.
189    pub italics: bool,
190    /// Foreground colour (CTA-608-E Tables 51/53).
191    pub color: Cea608Color,
192}
193
194impl Default for Cea608StyledChar {
195    fn default() -> Self {
196        Cea608StyledChar {
197            ch: ' ',
198            underline: false,
199            italics: false,
200            color: Cea608Color::White,
201        }
202    }
203}
204
205/// One row of a 608 caption screen.
206#[derive(Debug, Clone, PartialEq, Eq, Default)]
207#[cfg_attr(feature = "serde", derive(serde::Serialize))]
208pub struct Cea608Row {
209    /// The styled cells (sparse: only populated cells present, indexed by column).
210    cells: Vec<(usize, Cea608StyledChar)>,
211}
212
213impl Cea608Row {
214    fn set(&mut self, col: usize, c: Cea608StyledChar) {
215        if let Some(slot) = self.cells.iter_mut().find(|(i, _)| *i == col) {
216            slot.1 = c;
217        } else {
218            self.cells.push((col, c));
219        }
220    }
221    fn clear_from(&mut self, col: usize) {
222        self.cells.retain(|(i, _)| *i < col);
223    }
224    fn remove(&mut self, col: usize) {
225        self.cells.retain(|(i, _)| *i != col);
226    }
227    /// The row's text (cells sorted by column, gaps filled with spaces).
228    #[must_use]
229    pub fn text(&self) -> String {
230        let mut sorted = self.cells.clone();
231        sorted.sort_by_key(|(i, _)| *i);
232        let mut out = String::new();
233        let mut last = None;
234        for (i, c) in sorted {
235            if let Some(prev) = last {
236                for _ in (prev + 1)..i {
237                    out.push(' ');
238                }
239            }
240            out.push(c.ch);
241            last = Some(i);
242        }
243        out
244    }
245    /// Styled cells in column order.
246    #[must_use]
247    pub fn styled_cells(&self) -> Vec<(usize, Cea608StyledChar)> {
248        let mut sorted = self.cells.clone();
249        sorted.sort_by_key(|(i, _)| *i);
250        sorted
251    }
252}
253
254/// A 608 caption screen — `SCREEN_ROWS` rows of styled cells.
255#[derive(Debug, Clone, PartialEq, Eq, Default)]
256#[cfg_attr(feature = "serde", derive(serde::Serialize))]
257pub struct Cea608Screen {
258    rows: [Cea608Row; SCREEN_ROWS],
259}
260
261impl Cea608Screen {
262    /// The screen text: non-empty rows joined with `\n`, trailing spaces trimmed.
263    #[must_use]
264    pub fn text(&self) -> String {
265        let mut out = String::new();
266        for row in &self.rows {
267            let line = row.text();
268            let trimmed = line.trim_end();
269            if trimmed.is_empty() {
270                continue;
271            }
272            if !out.is_empty() {
273                out.push('\n');
274            }
275            out.push_str(trimmed);
276        }
277        out
278    }
279    /// All rows (including empty ones).
280    #[must_use]
281    pub fn rows(&self) -> &[Cea608Row; SCREEN_ROWS] {
282        &self.rows
283    }
284}
285
286/// Current pen attribute set by a PAC / mid-row code.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288struct Pen {
289    underline: bool,
290    italics: bool,
291    color: Cea608Color,
292}
293
294impl Default for Pen {
295    fn default() -> Self {
296        Pen {
297            underline: false,
298            italics: false,
299            color: Cea608Color::White,
300        }
301    }
302}
303
304/// Per-channel caption state (displayed + non-displayed memory, mode, cursor).
305#[derive(Debug, Clone, PartialEq, Eq, Default)]
306struct ChannelState {
307    displayed: Cea608Screen,
308    nondisplayed: Cea608Screen,
309    mode: Cea608Mode,
310    rollup_rows: u8,
311    cursor_row: usize,
312    cursor_col: usize,
313    pen: Pen,
314}
315
316impl ChannelState {
317    /// The active memory for the current mode (pop-on writes to non-displayed;
318    /// roll-up / paint-on write to displayed).
319    fn active_mut(&mut self) -> &mut Cea608Screen {
320        match self.mode {
321            Cea608Mode::PopOn => &mut self.nondisplayed,
322            _ => &mut self.displayed,
323        }
324    }
325}
326
327/// CEA-608 (line-21) caption decoder.
328///
329/// Feed it [`CcTriplet`]s (or raw byte pairs) of the 608 fields; read decoded
330/// text per channel via [`channel_text`](Cea608Decoder::channel_text) /
331/// [`screen`](Cea608Decoder::screen).
332///
333/// ```
334/// use dvb_cc::decode::{Cea608Decoder, Cea608Channel};
335/// let mut dec = Cea608Decoder::new();
336/// // RCL, PAC row 15, "HI", EOC — a pop-on caption on CC1 (field 1).
337/// dec.push_pair(false, 0x14, 0x20); // RCL (field 1 → CC1)
338/// dec.push_pair(false, 0x14, 0x20); // doubled control — ignored
339/// dec.push_pair(false, 0x14, 0x70); // PAC row 15 indent 0 (field 1)
340/// dec.push_pair(false, b'H', b'I');
341/// dec.push_pair(false, 0x14, 0x2F); // EOC → flip
342/// assert_eq!(dec.channel_text(Cea608Channel::Cc1), "HI");
343/// ```
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct Cea608Decoder {
346    channels: [ChannelState; 4],
347    /// The control pair last acted on, per field, for doubling suppression.
348    last_control: [Option<(u8, u8)>; 2],
349    xds_active: bool,
350}
351
352impl Default for Cea608Decoder {
353    fn default() -> Self {
354        Self::new()
355    }
356}
357
358impl Cea608Decoder {
359    /// A new decoder.
360    #[must_use]
361    pub fn new() -> Self {
362        Cea608Decoder {
363            channels: Default::default(),
364            last_control: [None, None],
365            xds_active: false,
366        }
367    }
368
369    /// Feed the decoder the 608 (line-21) triplets of a [`crate::CcData`].
370    pub fn push_triplets<'a, I>(&mut self, triplets: I)
371    where
372        I: IntoIterator<Item = &'a CcTriplet>,
373    {
374        for t in triplets {
375            if !t.cc_valid {
376                continue;
377            }
378            let field2 = match t.cc_type {
379                CcType::Ntsc608Field1 => false,
380                CcType::Ntsc608Field2 => true,
381                _ => continue,
382            };
383            self.process_pair(field2, t.cc_data_1, t.cc_data_2);
384        }
385    }
386
387    /// Feed one raw 608 byte pair. `field2 = false` ⇒ field 1 (`cc_type` 0);
388    /// `field2 = true` ⇒ field 2 (`cc_type` 1). Bytes may carry parity in b7.
389    pub fn push_pair(&mut self, field2: bool, b1: u8, b2: u8) {
390        self.process_pair(field2, b1, b2);
391    }
392
393    fn process_pair(&mut self, field2: bool, raw1: u8, raw2: u8) {
394        let b1 = raw1 & 0x7F;
395        let b2 = raw2 & 0x7F;
396        let field_idx = usize::from(field2);
397
398        // Null filler (0x00 0x00): nothing to do.
399        if b1 == 0x00 && b2 == 0x00 {
400            return;
401        }
402
403        // XDS (field 2 only): a control pair with first byte 0x01–0x0F begins or
404        // continues an XDS sub-packet; 0x0F + checksum ends it. Detect + skip the
405        // whole span (we never decode XDS into captions).
406        if field2 && (0x01..=0x0F).contains(&b1) {
407            // 0x0F = End control (followed by a checksum byte); otherwise
408            // Start/Continue. Either way this pair is XDS, not caption.
409            self.xds_active = b1 != 0x0F;
410            return;
411        }
412        // Any pair while an XDS sub-packet is open (field 2) is XDS payload
413        // (informational chars 0x20–0x7F or null) until the End control closes it.
414        if field2 && self.xds_active {
415            return;
416        }
417
418        // Control codes: first byte 0x10–0x1F.
419        if (0x10..=0x1F).contains(&b1) {
420            // Doubling: an identical control pair immediately repeated is one cmd.
421            if self.last_control[field_idx] == Some((b1, b2)) {
422                self.last_control[field_idx] = None; // consume the doubled copy
423                return;
424            }
425            self.last_control[field_idx] = Some((b1, b2));
426            self.xds_active = false;
427            self.handle_control(field2, b1, b2);
428            return;
429        }
430
431        // Displayable characters: first byte 0x20–0x7F.
432        self.last_control[field_idx] = None;
433        self.xds_active = false;
434        // Standard chars are not channel-tagged; route to the field's last-used
435        // channel (default CC1 for field 1, CC3 for field 2).
436        let ch = self.default_channel(field2);
437        if b1 >= 0x20 {
438            self.put_char(ch, Self::standard_char(b1));
439        }
440        if b2 >= 0x20 {
441            self.put_char(ch, Self::standard_char(b2));
442        }
443    }
444
445    /// The control-code handler. `b1` 0x10–0x1F, `b2` 0x20–0x7F (7-bit).
446    fn handle_control(&mut self, field2: bool, b1: u8, b2: u8) {
447        // Determine data channel from the first byte. For field 1: 0x10–0x17 =
448        // C1, 0x18–0x1F = C2. For field 2: same first-byte split selects C1/C2
449        // but maps to CC3/CC4.
450        let c2 = b1 >= 0x18;
451        let base1 = if c2 { b1 - 0x08 } else { b1 }; // fold C2 onto C1 range
452        let ch = self.channel_for(field2, c2);
453
454        // Misc control: first byte 0x14 (C1) / 0x15 (field-2 offset of 0x14).
455        // After folding C2→C1 (base1) we compare on the C1 first byte.
456        if base1 == 0x14 && (0x20..=0x2F).contains(&b2) {
457            self.misc_control(ch, b2);
458            return;
459        }
460        // Tab offsets: first byte 0x17 (C1) / 0x1F (C2), 2nd 0x21–0x23.
461        if (b1 == TAB_FIRST_C1 || b1 == TAB_FIRST_C2) && (TAB1..=TAB3).contains(&b2) {
462            let n = (b2 - TAB1 + 1) as usize;
463            let st = &mut self.channels[ch.index()];
464            st.cursor_col = (st.cursor_col + n).min(SCREEN_COLS - 1);
465            return;
466        }
467        // Mid-row codes: first byte 0x11 (C1) / 0x19 (C2), 2nd 0x20–0x2F.
468        if base1 == 0x11 && (0x20..=0x2F).contains(&b2) {
469            self.mid_row(ch, b2);
470            return;
471        }
472        // Special characters: first byte 0x11/0x19, 2nd 0x30–0x3F.
473        if base1 == 0x11 && (0x30..=0x3F).contains(&b2) {
474            self.put_char(ch, Self::special_char(b2));
475            return;
476        }
477        // Extended chars block 1: first byte 0x12/0x1A, 2nd 0x20–0x3F.
478        if base1 == 0x12 && (0x20..=0x3F).contains(&b2) {
479            self.put_extended(ch, Self::extended_char_block1(b2));
480            return;
481        }
482        // Extended chars block 2: first byte 0x13/0x1B, 2nd 0x20–0x3F.
483        if base1 == 0x13 && (0x20..=0x3F).contains(&b2) {
484            self.put_extended(ch, Self::extended_char_block2(b2));
485            return;
486        }
487        // PAC: 2nd byte 0x40–0x7F (any control first byte).
488        if (0x40..=0x7F).contains(&b2) {
489            self.pac(field2, ch, b1, b2);
490        }
491        // Background/foreground attr codes (0x10/0x18 2nd 0x20–0x2F) — ignored
492        // for text extraction.
493    }
494
495    /// Miscellaneous control codes (RCL/RU2-4/RDC/CR/EDM/ENM/EOC/DER/BS …).
496    fn misc_control(&mut self, ch: Cea608Channel, code: u8) {
497        let st = &mut self.channels[ch.index()];
498        match code {
499            MC_RCL => {
500                st.mode = Cea608Mode::PopOn;
501                st.nondisplayed = Cea608Screen::default();
502                st.cursor_row = 0;
503                st.cursor_col = 0;
504            }
505            MC_RU2 | MC_RU3 | MC_RU4 => {
506                let rows = match code {
507                    MC_RU2 => 2,
508                    MC_RU3 => 3,
509                    _ => 4,
510                };
511                st.mode = Cea608Mode::RollUp(rows);
512                st.rollup_rows = rows;
513                // base row defaults to bottom (row 15 → index 14)
514                st.cursor_row = SCREEN_ROWS - 1;
515                st.cursor_col = 0;
516                st.pen = Pen::default();
517            }
518            MC_RDC => {
519                st.mode = Cea608Mode::PaintOn;
520            }
521            MC_CR => {
522                self.carriage_return(ch);
523            }
524            MC_EDM => {
525                st.displayed = Cea608Screen::default();
526            }
527            MC_ENM => {
528                st.nondisplayed = Cea608Screen::default();
529            }
530            MC_EOC => {
531                // flip non-displayed ↔ displayed (pop-on)
532                core::mem::swap(&mut st.displayed, &mut st.nondisplayed);
533            }
534            MC_BS if st.cursor_col > 0 => {
535                st.cursor_col -= 1;
536                let row = st.cursor_row.min(SCREEN_ROWS - 1);
537                let col = st.cursor_col;
538                st.active_mut().rows[row].remove(col);
539            }
540            MC_DER => {
541                let row = st.cursor_row.min(SCREEN_ROWS - 1);
542                let col = st.cursor_col;
543                st.active_mut().rows[row].clear_from(col);
544            }
545            MC_TR => {
546                st.mode = Cea608Mode::Text;
547                st.displayed = Cea608Screen::default();
548                st.cursor_row = 0;
549                st.cursor_col = 0;
550            }
551            MC_RTD => {
552                st.mode = Cea608Mode::Text;
553            }
554            MC_FON => {}
555            _ => {}
556        }
557    }
558
559    /// Carriage Return in roll-up: roll the window up one row, clear the base row.
560    fn carriage_return(&mut self, ch: Cea608Channel) {
561        let st = &mut self.channels[ch.index()];
562        if let Cea608Mode::RollUp(rows) = st.mode {
563            let base = st.cursor_row.min(SCREEN_ROWS - 1);
564            let rows = rows as usize;
565            let top = base.saturating_sub(rows - 1);
566            // Move each row up by one within [top..=base].
567            for r in top..base {
568                st.displayed.rows[r] = st.displayed.rows[r + 1].clone();
569            }
570            st.displayed.rows[base] = Cea608Row::default();
571            st.cursor_col = 0;
572        } else {
573            // In other modes, CR is uncommon; just move cursor down.
574            if st.cursor_row + 1 < SCREEN_ROWS {
575                st.cursor_row += 1;
576            }
577            st.cursor_col = 0;
578        }
579    }
580
581    /// Mid-row code (Table 51): sets colour/italics + underline from the cursor.
582    fn mid_row(&mut self, ch: Cea608Channel, b2: u8) {
583        let idx = (b2 - 0x20) as usize; // 0x20..0x2F → 0..15
584        let underline = idx & 0x01 != 0;
585        let color_idx = (idx >> 1) as u8;
586        let (color, italics) = if color_idx <= 6 {
587            (Cea608Color::from_idx(color_idx), false)
588        } else {
589            (Cea608Color::White, true) // 0x2E/0x2F = italics
590        };
591        let st = &mut self.channels[ch.index()];
592        st.pen = Pen {
593            underline,
594            italics,
595            color,
596        };
597        // mid-row occupies one cell (a space carrying the attribute)
598        self.put_char(ch, ' ');
599    }
600
601    /// Preamble Address Code: set row + indent / colour + underline (Table 53).
602    fn pac(&mut self, _field2: bool, ch: Cea608Channel, b1: u8, b2: u8) {
603        // Fold the C2 first byte (0x18–0x1F) onto the C1 first byte (0x10–0x17).
604        let f = if b1 >= 0x18 { b1 - 0x08 } else { b1 };
605        // Row group from the first byte (Table 53 top block, C1 column).
606        let row_pair = match f {
607            0x11 => 1,  // rows 1–2
608            0x12 => 3,  // rows 3–4
609            0x15 => 5,  // rows 5–6
610            0x16 => 7,  // rows 7–8
611            0x17 => 9,  // rows 9–10
612            0x10 => 11, // row 11
613            0x13 => 12, // rows 12–13
614            0x14 => 14, // rows 14–15
615            _ => 1,
616        };
617        // Second byte high bit picks the row within the pair (0x40–0x5F = first
618        // of pair, 0x60–0x7F = second).
619        let second_of_pair = b2 >= 0x60;
620        let row = if f == 0x10 {
621            11 // row 11 has no pair partner
622        } else if second_of_pair {
623            row_pair + 1
624        } else {
625            row_pair
626        };
627        let row = row.clamp(1, SCREEN_ROWS); // 1-based
628        let attr = b2 & 0x1F; // low 5 bits (drop the col-A/B high nibble)
629        let underline = attr & 0x01 != 0;
630
631        let (color, italics, indent) = if attr >= 0x10 {
632            // Indent codes 0x10..0x1F → indent 0..28 in steps of 4, colour white.
633            let indent = ((attr - 0x10) >> 1) as usize * 4;
634            (Cea608Color::White, false, indent)
635        } else {
636            // Colour/italics codes 0x00..0x0F.
637            let color_idx = attr >> 1;
638            let (c, it) = if color_idx <= 6 {
639                (Cea608Color::from_idx(color_idx), false)
640            } else {
641                (Cea608Color::White, true)
642            };
643            (c, it, 0)
644        };
645
646        let st = &mut self.channels[ch.index()];
647        st.cursor_row = (row - 1).min(SCREEN_ROWS - 1);
648        st.cursor_col = indent.min(SCREEN_COLS - 1);
649        st.pen = Pen {
650            underline,
651            italics,
652            color,
653        };
654    }
655
656    /// Put a standard / special character at the cursor, advancing it.
657    fn put_char(&mut self, ch: Cea608Channel, c: char) {
658        let st = &mut self.channels[ch.index()];
659        let row = st.cursor_row.min(SCREEN_ROWS - 1);
660        let col = st.cursor_col;
661        if col >= SCREEN_COLS {
662            return;
663        }
664        let pen = st.pen;
665        let styled = Cea608StyledChar {
666            ch: c,
667            underline: pen.underline,
668            italics: pen.italics,
669            color: pen.color,
670        };
671        st.active_mut().rows[row].set(col, styled);
672        st.cursor_col = (col + 1).min(SCREEN_COLS);
673    }
674
675    /// Put an extended char: automatic backspace first (erase the fallback char
676    /// the provider sent), then the glyph.
677    fn put_extended(&mut self, ch: Cea608Channel, c: char) {
678        {
679            let st = &mut self.channels[ch.index()];
680            if st.cursor_col > 0 {
681                st.cursor_col -= 1;
682                let row = st.cursor_row.min(SCREEN_ROWS - 1);
683                let col = st.cursor_col;
684                st.active_mut().rows[row].remove(col);
685            }
686        }
687        self.put_char(ch, c);
688    }
689
690    /// Resolve the data channel for a control pair.
691    fn channel_for(&self, field2: bool, c2: bool) -> Cea608Channel {
692        match (field2, c2) {
693            (false, false) => Cea608Channel::Cc1,
694            (false, true) => Cea608Channel::Cc2,
695            (true, false) => Cea608Channel::Cc3,
696            (true, true) => Cea608Channel::Cc4,
697        }
698    }
699
700    /// The default channel for routing displayable chars on a field.
701    fn default_channel(&self, field2: bool) -> Cea608Channel {
702        if field2 {
703            Cea608Channel::Cc3
704        } else {
705            Cea608Channel::Cc1
706        }
707    }
708
709    /// The displayed screen for a channel.
710    #[must_use]
711    pub fn screen(&self, channel: Cea608Channel) -> &Cea608Screen {
712        &self.channels[channel.index()].displayed
713    }
714
715    /// The current mode for a channel.
716    #[must_use]
717    pub fn mode(&self, channel: Cea608Channel) -> Cea608Mode {
718        self.channels[channel.index()].mode
719    }
720
721    /// The decoded displayed text for a channel.
722    #[must_use]
723    pub fn channel_text(&self, channel: Cea608Channel) -> String {
724        self.channels[channel.index()].displayed.text()
725    }
726
727    // ── Character tables ──────────────────────────────────────────────────────
728
729    /// Standard character set (Table 50): 7-bit `0x20`–`0x7F` → glyph.
730    fn standard_char(b: u8) -> char {
731        match b {
732            0x2A => '\u{00E1}', // á
733            0x5C => '\u{00E9}', // é
734            0x5E => '\u{00ED}', // í
735            0x5F => '\u{00F3}', // ó
736            0x60 => '\u{00FA}', // ú
737            0x7B => '\u{00E7}', // ç
738            0x7C => '\u{00F7}', // ÷
739            0x7D => '\u{00D1}', // Ñ
740            0x7E => '\u{00F1}', // ñ
741            0x7F => '\u{25A0}', // ■
742            _ => char::from(b), // ASCII otherwise
743        }
744    }
745
746    /// Special characters (Table 49): 2nd byte 0x30–0x3F.
747    fn special_char(b2: u8) -> char {
748        match b2 {
749            0x30 => '\u{00AE}', // ®
750            0x31 => '\u{00B0}', // °
751            0x32 => '\u{00BD}', // ½
752            0x33 => '\u{00BF}', // ¿
753            0x34 => '\u{2122}', // ™
754            0x35 => '\u{00A2}', // ¢
755            0x36 => '\u{00A3}', // £
756            0x37 => '\u{266A}', // ♪
757            0x38 => '\u{00E0}', // à
758            0x39 => ' ',        // transparent space
759            0x3A => '\u{00E8}', // è
760            0x3B => '\u{00E2}', // â
761            0x3C => '\u{00EA}', // ê
762            0x3D => '\u{00EE}', // î
763            0x3E => '\u{00F4}', // ô
764            0x3F => '\u{00FB}', // û
765            _ => '?',
766        }
767    }
768
769    /// Extended Western-European block 1 (first byte 0x12/0x1A; Tables 5–7).
770    fn extended_char_block1(b2: u8) -> char {
771        match b2 {
772            // Spanish (Table 5)
773            0x20 => '\u{00C1}', // Á
774            0x21 => '\u{00C9}', // É
775            0x22 => '\u{00D3}', // Ó
776            0x23 => '\u{00DA}', // Ú
777            0x24 => '\u{00DC}', // Ü
778            0x25 => '\u{00FC}', // ü
779            0x26 => '\u{2018}', // ‘
780            0x27 => '\u{00A1}', // ¡
781            // Misc (Table 6)
782            0x28 => '*',
783            0x29 => '\'',
784            0x2A => '\u{2014}', // —
785            0x2B => '\u{00A9}', // ©
786            0x2C => '\u{2120}', // ℠
787            0x2D => '\u{2022}', // •
788            0x2E => '\u{201C}', // "
789            0x2F => '\u{201D}', // "
790            // French (Table 7)
791            0x30 => '\u{00C0}', // À
792            0x31 => '\u{00C2}', // Â
793            0x32 => '\u{00C7}', // Ç
794            0x33 => '\u{00C8}', // È
795            0x34 => '\u{00CA}', // Ê
796            0x35 => '\u{00CB}', // Ë
797            0x36 => '\u{00EB}', // ë
798            0x37 => '\u{00CE}', // Î
799            0x38 => '\u{00CF}', // Ï
800            0x39 => '\u{00EF}', // ï
801            0x3A => '\u{00D4}', // Ô
802            0x3B => '\u{00D9}', // Ù
803            0x3C => '\u{00F9}', // ù
804            0x3D => '\u{00DB}', // Û
805            0x3E => '\u{00AB}', // «
806            0x3F => '\u{00BB}', // »
807            _ => '?',
808        }
809    }
810
811    /// Extended Western-European block 2 (first byte 0x13/0x1B; Tables 8–10).
812    fn extended_char_block2(b2: u8) -> char {
813        match b2 {
814            // Portuguese (Table 8)
815            0x20 => '\u{00C3}', // Ã
816            0x21 => '\u{00E3}', // ã
817            0x22 => '\u{00CD}', // Í
818            0x23 => '\u{00CC}', // Ì
819            0x24 => '\u{00EC}', // ì
820            0x25 => '\u{00D2}', // Ò
821            0x26 => '\u{00F2}', // ò
822            0x27 => '\u{00D5}', // Õ
823            0x28 => '\u{00F5}', // õ
824            0x29 => '{',
825            0x2A => '}',
826            0x2B => '\\',
827            0x2C => '^',
828            0x2D => '_',
829            0x2E => '|',
830            0x2F => '~',
831            // German (Table 9)
832            0x30 => '\u{00C4}', // Ä
833            0x31 => '\u{00E4}', // ä
834            0x32 => '\u{00D6}', // Ö
835            0x33 => '\u{00F6}', // ö
836            0x34 => '\u{00DF}', // ß
837            0x35 => '\u{00A5}', // ¥
838            0x36 => '\u{00A4}', // ¤
839            0x37 => '|',
840            // Danish (Table 10)
841            0x38 => '\u{00C5}', // Å
842            0x39 => '\u{00E5}', // å
843            0x3A => '\u{00D8}', // Ø
844            0x3B => '\u{00F8}', // ø
845            0x3C => '\u{231C}', // ⌜
846            0x3D => '\u{231D}', // ⌝
847            0x3E => '\u{231E}', // ⌞
848            0x3F => '\u{231F}', // ⌟
849            _ => '?',
850        }
851    }
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857
858    /// Add odd parity to a 7-bit value (so inputs look like real line-21 bytes).
859    fn par(v: u8) -> u8 {
860        let ones = (v & 0x7F).count_ones();
861        if ones % 2 == 0 {
862            v | 0x80
863        } else {
864            v & 0x7F
865        }
866    }
867
868    /// Pop-on caption: RCL, PAC row 15 indent 0, "HI", EOC → on-screen "HI".
869    #[test]
870    fn pop_on_caption() {
871        let mut dec = Cea608Decoder::new();
872        dec.push_pair(false, par(0x14), par(0x20)); // RCL
873        dec.push_pair(false, par(0x14), par(0x70)); // PAC row 15 (col B), white indent0
874        dec.push_pair(false, par(b'H'), par(b'I'));
875        dec.push_pair(false, par(0x14), par(0x2F)); // EOC → flip to displayed
876        assert_eq!(dec.channel_text(Cea608Channel::Cc1), "HI");
877        assert_eq!(dec.mode(Cea608Channel::Cc1), Cea608Mode::PopOn);
878    }
879
880    /// Control-code doubling: a repeated RCL pair acts once.
881    #[test]
882    fn control_doubling() {
883        let mut dec = Cea608Decoder::new();
884        dec.push_pair(false, par(0x14), par(0x29)); // RDC
885        dec.push_pair(false, par(0x14), par(0x29)); // doubled — ignored
886        assert_eq!(dec.mode(Cea608Channel::Cc1), Cea608Mode::PaintOn);
887        // a non-doubled second RDC after a non-control acts again
888        dec.push_pair(false, par(b'X'), par(0x00));
889        dec.push_pair(false, par(0x14), par(0x2C)); // EDM
890    }
891
892    /// Roll-up across ≥2 rows: RU2, write line 1, CR, write line 2.
893    #[test]
894    fn roll_up_two_rows() {
895        let mut dec = Cea608Decoder::new();
896        dec.push_pair(false, par(0x14), par(0x25)); // RU2
897        dec.push_pair(false, par(b'A'), par(b'B'));
898        dec.push_pair(false, par(0x14), par(0x2D)); // CR
899        dec.push_pair(false, par(b'C'), par(b'D'));
900        let text = dec.channel_text(Cea608Channel::Cc1);
901        assert!(text.contains("AB"), "got {text:?}");
902        assert!(text.contains("CD"), "got {text:?}");
903        // AB rolled up above CD
904        let ab = text.find("AB").unwrap();
905        let cd = text.find("CD").unwrap();
906        assert!(ab < cd, "AB should be above CD: {text:?}");
907    }
908
909    /// Mid-row colour change places a styled space and changes the pen colour.
910    #[test]
911    fn mid_row_colour() {
912        let mut dec = Cea608Decoder::new();
913        dec.push_pair(false, par(0x14), par(0x20)); // RCL
914        dec.push_pair(false, par(0x14), par(0x70)); // PAC row 15
915        dec.push_pair(false, par(b'A'), par(0x00));
916        dec.push_pair(false, par(0x11), par(0x22)); // mid-row Green
917        dec.push_pair(false, par(b'B'), par(0x00));
918        dec.push_pair(false, par(0x14), par(0x2F)); // EOC
919                                                    // Find the styled cells; "B" must be green.
920        let screen = dec.screen(Cea608Channel::Cc1);
921        let mut found_green_b = false;
922        for row in screen.rows() {
923            for (_, c) in row.styled_cells() {
924                if c.ch == 'B' {
925                    assert_eq!(c.color, Cea608Color::Green);
926                    found_green_b = true;
927                }
928            }
929        }
930        assert!(found_green_b, "expected a green 'B'");
931    }
932
933    /// Special character (musical note) via 0x11 0x37.
934    #[test]
935    fn special_char_music_note() {
936        let mut dec = Cea608Decoder::new();
937        dec.push_pair(false, par(0x14), par(0x29)); // RDC (paint-on → displayed)
938        dec.push_pair(false, par(0x11), par(0x37)); // ♪ on CC1
939        assert!(dec.channel_text(Cea608Channel::Cc1).contains('\u{266A}'));
940    }
941
942    /// Extended char with automatic backspace: provider sends 'u' then ü code.
943    #[test]
944    fn extended_char_backspace() {
945        let mut dec = Cea608Decoder::new();
946        dec.push_pair(false, par(0x14), par(0x29)); // RDC
947        dec.push_pair(false, par(b'u'), par(0x00)); // fallback 'u'
948        dec.push_pair(false, par(0x12), par(0x25)); // extended ü (block1 0x25)
949        let t = dec.channel_text(Cea608Channel::Cc1);
950        assert_eq!(t, "\u{00FC}"); // just ü, the 'u' was backspaced
951    }
952
953    /// Channel routing: a CC2 control (0x1C…) writes to CC2, not CC1.
954    #[test]
955    fn channel_cc2() {
956        let mut dec = Cea608Decoder::new();
957        dec.push_pair(false, par(0x1C), par(0x29)); // RDC on data channel 2 → CC2
958        assert_eq!(dec.mode(Cea608Channel::Cc2), Cea608Mode::PaintOn);
959        assert_eq!(dec.mode(Cea608Channel::Cc1), Cea608Mode::None);
960    }
961
962    /// Field 2 → CC3.
963    #[test]
964    fn field2_cc3() {
965        let mut dec = Cea608Decoder::new();
966        dec.push_pair(true, par(0x14), par(0x29)); // RDC field 2 ch1 → CC3
967        assert_eq!(dec.mode(Cea608Channel::Cc3), Cea608Mode::PaintOn);
968    }
969
970    /// XDS on field 2 is detected and skipped (no caption output).
971    #[test]
972    fn xds_skipped() {
973        let mut dec = Cea608Decoder::new();
974        dec.push_pair(true, par(0x01), par(0x02)); // XDS start (Current class)
975        dec.push_pair(true, par(0x20), par(0x21)); // XDS informational
976        dec.push_pair(true, par(0x0F), par(0x40)); // XDS end + checksum
977        assert_eq!(dec.channel_text(Cea608Channel::Cc3), "");
978    }
979
980    #[test]
981    fn standard_char_accents() {
982        assert_eq!(Cea608Decoder::standard_char(0x2A), '\u{00E1}'); // á
983        assert_eq!(Cea608Decoder::standard_char(b'A'), 'A');
984        assert_eq!(Cea608Decoder::standard_char(0x7F), '\u{25A0}'); // ■
985    }
986
987    #[test]
988    fn no_panic_on_arbitrary_input() {
989        let mut dec = Cea608Decoder::new();
990        let mut x: u32 = 0xDEAD_BEEF;
991        for _ in 0..8192 {
992            x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
993            let b1 = (x >> 8) as u8;
994            let b2 = (x >> 16) as u8;
995            let f2 = (x & 1) != 0;
996            dec.push_pair(f2, b1, b2);
997        }
998        // Also via triplets, including invalid + truncated patterns.
999        let triplets = [
1000            CcTriplet {
1001                cc_valid: true,
1002                cc_type: CcType::Ntsc608Field1,
1003                cc_data_1: 0x14,
1004                cc_data_2: 0x2D,
1005            },
1006            CcTriplet {
1007                cc_valid: false,
1008                cc_type: CcType::Ntsc608Field2,
1009                cc_data_1: 0xFF,
1010                cc_data_2: 0xFF,
1011            },
1012            CcTriplet {
1013                cc_valid: true,
1014                cc_type: CcType::Ntsc608Field2,
1015                cc_data_1: 0x01,
1016                cc_data_2: 0x00,
1017            },
1018        ];
1019        dec.push_triplets(&triplets);
1020    }
1021}