Skip to main content

oximedia_subtitle/
cea708.rs

1//! CEA-708 DTVCC (Digital Television Closed Caption) decoder.
2//!
3//! CEA-708 is the standard for digital television closed captions in the USA.
4//! It uses the DTVCC (Digital Television Closed Caption) transport layer.
5//!
6//! # Window Model
7//!
8//! CEA-708 supports up to 8 concurrent caption windows. Each window has
9//! its own text buffer, visibility state, and positioning attributes.
10//!
11//! # Packet Structure
12//!
13//! DTVCC packets carry a sequence number and a payload of service blocks.
14//! Each service block addresses a specific caption service (1-63).
15
16#![allow(dead_code)]
17
18/// A raw DTVCC packet from the transport layer.
19#[derive(Clone, Debug, PartialEq)]
20pub struct Dtvcc708Packet {
21    /// Sequence counter (0-3, wraps around).
22    pub sequence: u8,
23    /// Raw packet payload bytes.
24    pub data: Vec<u8>,
25}
26
27impl Dtvcc708Packet {
28    /// Create a new DTVCC packet.
29    #[must_use]
30    pub fn new(sequence: u8, data: Vec<u8>) -> Self {
31        Self { sequence, data }
32    }
33}
34
35/// A CEA-708 caption window.
36#[derive(Clone, Debug, PartialEq)]
37pub struct CaptionWindow {
38    /// Window identifier (0-7).
39    pub id: u8,
40    /// Whether this window is currently visible.
41    pub visible: bool,
42    /// Number of rows in the window (1-15).
43    pub row_count: u8,
44    /// Number of columns in the window (1-63).
45    pub col_count: u8,
46    /// Text content of the window.
47    pub text: String,
48    /// Current pen row (0-based).
49    pub pen_row: u8,
50    /// Current pen column (0-based).
51    pub pen_col: u8,
52}
53
54impl CaptionWindow {
55    /// Create a new empty caption window with default dimensions.
56    #[must_use]
57    pub fn new(id: u8) -> Self {
58        Self {
59            id,
60            visible: false,
61            row_count: 15,
62            col_count: 32,
63            text: String::new(),
64            pen_row: 0,
65            pen_col: 0,
66        }
67    }
68}
69
70impl Default for CaptionWindow {
71    fn default() -> Self {
72        Self::new(0)
73    }
74}
75
76/// CEA-708 DTVCC command decoded from a packet.
77#[derive(Clone, Debug, PartialEq)]
78pub enum Dtvcc708Command {
79    /// Set the current active window (window id 0-7).
80    SetCurrentWindow(u8),
81    /// Delete windows specified by the bitmask.
82    DeleteWindows(u8),
83    /// Make windows specified by the bitmask visible.
84    DisplayWindows(u8),
85    /// Clear (erase) text from windows specified by the bitmask.
86    ClearWindows(u8),
87    /// Hide windows specified by the bitmask (but retain text).
88    HideWindows(u8),
89    /// Set window display attributes (parameters consumed but not fully decoded).
90    SetWindowAttributes,
91    /// Set pen text attributes (parameters consumed but not fully decoded).
92    SetPenAttributes,
93    /// Set pen foreground/background color (parameters consumed but not fully decoded).
94    SetPenColor,
95    /// Move the pen to the specified row and column.
96    SetPenLocation {
97        /// Row index (0-based).
98        row: u8,
99        /// Column index (0-based).
100        col: u8,
101    },
102    /// Printable text accumulated from 0x20-0x7F bytes.
103    Text(String),
104    /// Backspace — delete last character in current window.
105    Backspace,
106    /// Form feed / clear screen for current window.
107    FormFeed,
108    /// Carriage return — move to next line.
109    CarriageReturn,
110    /// An unrecognized command byte.
111    Unknown(u8),
112}
113
114// ============================================================================
115// Number of caption windows per CEA-708 spec
116// ============================================================================
117const NUM_WINDOWS: usize = 8;
118
119/// CEA-708 DTVCC stateful decoder.
120///
121/// Maintains the state of up to 8 caption windows across multiple packets.
122pub struct Dtvcc708Decoder {
123    /// The 8 caption windows.
124    windows: [CaptionWindow; NUM_WINDOWS],
125    /// Index of the current active window (0-7).
126    current_window: usize,
127}
128
129impl Dtvcc708Decoder {
130    /// Create a new decoder with 8 empty, invisible windows.
131    #[must_use]
132    pub fn new() -> Self {
133        Self {
134            windows: [
135                CaptionWindow::new(0),
136                CaptionWindow::new(1),
137                CaptionWindow::new(2),
138                CaptionWindow::new(3),
139                CaptionWindow::new(4),
140                CaptionWindow::new(5),
141                CaptionWindow::new(6),
142                CaptionWindow::new(7),
143            ],
144            current_window: 0,
145        }
146    }
147
148    /// Decode a DTVCC packet and update internal window state.
149    ///
150    /// Returns the current state of all 8 windows after processing.
151    pub fn decode(&mut self, packet: &Dtvcc708Packet) -> Vec<CaptionWindow> {
152        let commands = Self::parse_commands(&packet.data);
153        for cmd in commands {
154            self.apply_command(&cmd);
155        }
156        self.windows.to_vec()
157    }
158
159    /// Get the current active window index.
160    #[must_use]
161    pub fn current_window(&self) -> usize {
162        self.current_window
163    }
164
165    /// Get a reference to all 8 windows.
166    #[must_use]
167    pub fn windows(&self) -> &[CaptionWindow; NUM_WINDOWS] {
168        &self.windows
169    }
170
171    // ---- command parsing ----
172
173    /// Parse raw packet bytes into a sequence of commands.
174    fn parse_commands(data: &[u8]) -> Vec<Dtvcc708Command> {
175        let mut commands = Vec::new();
176        let mut i = 0;
177        let mut text_buf = String::new();
178
179        while i < data.len() {
180            let byte = data[i];
181
182            match byte {
183                // Printable ASCII
184                0x20..=0x7E => {
185                    text_buf.push(byte as char);
186                    i += 1;
187                }
188                // DEL — treated as printable in some contexts; skip
189                0x7F => {
190                    i += 1;
191                }
192                // Non-printable control codes — flush any pending text first
193                _ => {
194                    if !text_buf.is_empty() {
195                        commands.push(Dtvcc708Command::Text(text_buf.clone()));
196                        text_buf.clear();
197                    }
198
199                    match byte {
200                        // Backspace
201                        0x08 => {
202                            commands.push(Dtvcc708Command::Backspace);
203                            i += 1;
204                        }
205                        // Form Feed / clear screen
206                        0x0C => {
207                            commands.push(Dtvcc708Command::FormFeed);
208                            i += 1;
209                        }
210                        // Carriage Return
211                        0x0D => {
212                            commands.push(Dtvcc708Command::CarriageReturn);
213                            i += 1;
214                        }
215                        // SetCurrentWindow (primary range 0x80-0x87)
216                        0x80..=0x87 => {
217                            let win_id = byte & 0x07;
218                            commands.push(Dtvcc708Command::SetCurrentWindow(win_id));
219                            i += 1;
220                        }
221                        // ClearWindows (0x88) — next byte is bitmask
222                        0x88 => {
223                            i += 1;
224                            let mask = data.get(i).copied().unwrap_or(0);
225                            commands.push(Dtvcc708Command::ClearWindows(mask));
226                            i += 1;
227                        }
228                        // DisplayWindows (0x89) — next byte is bitmask
229                        0x89 => {
230                            i += 1;
231                            let mask = data.get(i).copied().unwrap_or(0);
232                            commands.push(Dtvcc708Command::DisplayWindows(mask));
233                            i += 1;
234                        }
235                        // HideWindows (0x8A) — next byte is bitmask
236                        0x8A => {
237                            i += 1;
238                            let mask = data.get(i).copied().unwrap_or(0);
239                            commands.push(Dtvcc708Command::HideWindows(mask));
240                            i += 1;
241                        }
242                        // ToggleWindows (0x8B) — next byte is bitmask — treat as unknown
243                        0x8B => {
244                            i += 2; // consume command + bitmask byte
245                        }
246                        // DeleteWindows (0x8C) — next byte is bitmask
247                        0x8C => {
248                            i += 1;
249                            let mask = data.get(i).copied().unwrap_or(0);
250                            commands.push(Dtvcc708Command::DeleteWindows(mask));
251                            i += 1;
252                        }
253                        // Delay (0x8D) — next byte is tenths of a second — consume
254                        0x8D => {
255                            i += 2;
256                        }
257                        // DelayCancel (0x8E)
258                        0x8E => {
259                            i += 1;
260                        }
261                        // Reset (0x8F)
262                        0x8F => {
263                            i += 1;
264                        }
265                        // SetCurrentWindow (secondary range 0x91-0x97, mirrors primary)
266                        0x91..=0x97 => {
267                            let win_id = byte & 0x07;
268                            commands.push(Dtvcc708Command::SetCurrentWindow(win_id));
269                            i += 1;
270                        }
271                        // SetWindowAttributes (0x98) — 4 param bytes
272                        0x98 => {
273                            commands.push(Dtvcc708Command::SetWindowAttributes);
274                            i += 1 + 4; // skip 4 param bytes
275                        }
276                        // SetPenAttributes (0x99) — 2 param bytes
277                        0x99 => {
278                            commands.push(Dtvcc708Command::SetPenAttributes);
279                            i += 1 + 2;
280                        }
281                        // SetPenColor (0x9A) — 3 param bytes
282                        0x9A => {
283                            commands.push(Dtvcc708Command::SetPenColor);
284                            i += 1 + 3;
285                        }
286                        // SetPenLocation (0x9B) — 2 param bytes: row, col
287                        0x9B => {
288                            i += 1;
289                            let row = data.get(i).copied().unwrap_or(0) & 0x0F;
290                            i += 1;
291                            let col = data.get(i).copied().unwrap_or(0) & 0x3F;
292                            i += 1;
293                            commands.push(Dtvcc708Command::SetPenLocation { row, col });
294                        }
295                        // Extended control codes (0x10-0x17) — consume 1 additional byte
296                        0x10..=0x17 => {
297                            i += 2;
298                        }
299                        // Everything else — unknown
300                        _ => {
301                            commands.push(Dtvcc708Command::Unknown(byte));
302                            i += 1;
303                        }
304                    }
305                }
306            }
307        }
308
309        // Flush any remaining text
310        if !text_buf.is_empty() {
311            commands.push(Dtvcc708Command::Text(text_buf));
312        }
313
314        commands
315    }
316
317    // ---- command application ----
318
319    fn apply_command(&mut self, cmd: &Dtvcc708Command) {
320        match cmd {
321            Dtvcc708Command::SetCurrentWindow(id) => {
322                let win_id = (*id as usize).min(NUM_WINDOWS - 1);
323                self.current_window = win_id;
324            }
325            Dtvcc708Command::DeleteWindows(mask) => {
326                for bit in 0..NUM_WINDOWS {
327                    if mask & (1 << bit) != 0 {
328                        self.windows[bit].text.clear();
329                        self.windows[bit].visible = false;
330                        self.windows[bit].pen_row = 0;
331                        self.windows[bit].pen_col = 0;
332                    }
333                }
334            }
335            Dtvcc708Command::DisplayWindows(mask) => {
336                for bit in 0..NUM_WINDOWS {
337                    if mask & (1 << bit) != 0 {
338                        self.windows[bit].visible = true;
339                    }
340                }
341            }
342            Dtvcc708Command::HideWindows(mask) => {
343                for bit in 0..NUM_WINDOWS {
344                    if mask & (1 << bit) != 0 {
345                        self.windows[bit].visible = false;
346                    }
347                }
348            }
349            Dtvcc708Command::ClearWindows(mask) => {
350                for bit in 0..NUM_WINDOWS {
351                    if mask & (1 << bit) != 0 {
352                        self.windows[bit].text.clear();
353                        self.windows[bit].pen_row = 0;
354                        self.windows[bit].pen_col = 0;
355                    }
356                }
357            }
358            Dtvcc708Command::SetWindowAttributes => {
359                // Parameters already consumed during parse; nothing to apply here
360            }
361            Dtvcc708Command::SetPenAttributes => {
362                // Parameters already consumed during parse; nothing to apply here
363            }
364            Dtvcc708Command::SetPenColor => {
365                // Parameters already consumed during parse; nothing to apply here
366            }
367            Dtvcc708Command::SetPenLocation { row, col } => {
368                self.windows[self.current_window].pen_row = *row;
369                self.windows[self.current_window].pen_col = *col;
370            }
371            Dtvcc708Command::Text(text) => {
372                self.windows[self.current_window].text.push_str(text);
373            }
374            Dtvcc708Command::Backspace => {
375                let win = &mut self.windows[self.current_window];
376                if !win.text.is_empty() {
377                    // Remove the last Unicode character
378                    let mut chars: Vec<char> = win.text.chars().collect();
379                    chars.pop();
380                    win.text = chars.into_iter().collect();
381                }
382            }
383            Dtvcc708Command::FormFeed => {
384                let win = &mut self.windows[self.current_window];
385                win.text.clear();
386                win.pen_row = 0;
387                win.pen_col = 0;
388            }
389            Dtvcc708Command::CarriageReturn => {
390                let win = &mut self.windows[self.current_window];
391                win.text.push('\n');
392                win.pen_row = win.pen_row.saturating_add(1);
393                win.pen_col = 0;
394            }
395            Dtvcc708Command::Unknown(_) => {
396                // Ignore unknown commands
397            }
398        }
399    }
400}
401
402impl Default for Dtvcc708Decoder {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408// ============================================================================
409// Tests
410// ============================================================================
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn packet(seq: u8, data: &[u8]) -> Dtvcc708Packet {
417        Dtvcc708Packet::new(seq, data.to_vec())
418    }
419
420    #[test]
421    fn test_new_decoder_has_eight_windows() {
422        let decoder = Dtvcc708Decoder::new();
423        assert_eq!(decoder.windows().len(), 8);
424    }
425
426    #[test]
427    fn test_new_decoder_all_windows_invisible() {
428        let decoder = Dtvcc708Decoder::new();
429        for win in decoder.windows().iter() {
430            assert!(
431                !win.visible,
432                "window {} should be invisible by default",
433                win.id
434            );
435        }
436    }
437
438    #[test]
439    fn test_new_decoder_all_windows_empty_text() {
440        let decoder = Dtvcc708Decoder::new();
441        for win in decoder.windows().iter() {
442            assert!(win.text.is_empty());
443        }
444    }
445
446    #[test]
447    fn test_decode_empty_packet_returns_eight_windows() {
448        let mut decoder = Dtvcc708Decoder::new();
449        let windows = decoder.decode(&packet(0, &[]));
450        assert_eq!(windows.len(), 8);
451    }
452
453    #[test]
454    fn test_decode_printable_text_appends_to_current_window() {
455        let mut decoder = Dtvcc708Decoder::new();
456        let windows = decoder.decode(&packet(0, b"Hello"));
457        assert_eq!(windows[0].text, "Hello");
458    }
459
460    #[test]
461    fn test_set_current_window_switches_active() {
462        let mut decoder = Dtvcc708Decoder::new();
463        // 0x82 = SetCurrentWindow(2)
464        let windows = decoder.decode(&packet(0, &[0x82, b'X']));
465        assert_eq!(windows[2].text, "X");
466        assert!(windows[0].text.is_empty());
467    }
468
469    #[test]
470    fn test_clear_windows_by_bitmask() {
471        let mut decoder = Dtvcc708Decoder::new();
472        // Write "Hello" to window 0
473        decoder.decode(&packet(0, b"Hello"));
474        // ClearWindows(0x88) with mask=0x01 (window 0)
475        let windows = decoder.decode(&packet(1, &[0x88, 0x01]));
476        assert!(windows[0].text.is_empty());
477    }
478
479    #[test]
480    fn test_display_windows_makes_window_visible() {
481        let mut decoder = Dtvcc708Decoder::new();
482        // DisplayWindows(0x89) with mask=0x01 (window 0)
483        let windows = decoder.decode(&packet(0, &[0x89, 0x01]));
484        assert!(windows[0].visible);
485        assert!(!windows[1].visible);
486    }
487
488    #[test]
489    fn test_delete_windows_clears_text_and_visibility() {
490        let mut decoder = Dtvcc708Decoder::new();
491        decoder.decode(&packet(0, b"Test"));
492        decoder.decode(&packet(1, &[0x89, 0x01])); // make visible
493        let windows = decoder.decode(&packet(2, &[0x8C, 0x01])); // delete window 0
494        assert!(windows[0].text.is_empty());
495        assert!(!windows[0].visible);
496    }
497
498    #[test]
499    fn test_set_pen_location() {
500        let mut decoder = Dtvcc708Decoder::new();
501        // SetPenLocation with row=3, col=5
502        let windows = decoder.decode(&packet(0, &[0x9B, 0x03, 0x05]));
503        assert_eq!(windows[0].pen_row, 3);
504        assert_eq!(windows[0].pen_col, 5);
505    }
506
507    #[test]
508    fn test_backspace_removes_last_char() {
509        let mut decoder = Dtvcc708Decoder::new();
510        decoder.decode(&packet(0, b"Hello"));
511        let windows = decoder.decode(&packet(1, &[0x08]));
512        assert_eq!(windows[0].text, "Hell");
513    }
514
515    #[test]
516    fn test_form_feed_clears_current_window() {
517        let mut decoder = Dtvcc708Decoder::new();
518        decoder.decode(&packet(0, b"Some text"));
519        let windows = decoder.decode(&packet(1, &[0x0C]));
520        assert!(windows[0].text.is_empty());
521    }
522
523    #[test]
524    fn test_sequence_number_stored() {
525        let pkt = Dtvcc708Packet::new(3, vec![0x20]);
526        assert_eq!(pkt.sequence, 3);
527    }
528
529    #[test]
530    fn test_window_id_bounds() {
531        let mut decoder = Dtvcc708Decoder::new();
532        // 0x87 = SetCurrentWindow(7) — highest valid id
533        decoder.decode(&packet(0, &[0x87, b'Z']));
534        assert_eq!(decoder.current_window(), 7);
535        assert_eq!(decoder.windows()[7].text, "Z");
536    }
537
538    #[test]
539    fn test_text_accumulates_across_multiple_decodes() {
540        let mut decoder = Dtvcc708Decoder::new();
541        decoder.decode(&packet(0, b"Foo"));
542        decoder.decode(&packet(1, b"Bar"));
543        let windows = decoder.decode(&packet(2, b"!"));
544        assert_eq!(windows[0].text, "FooBar!");
545    }
546
547    #[test]
548    fn test_mixed_control_and_text() {
549        let mut decoder = Dtvcc708Decoder::new();
550        // Write to window 1, then switch back to window 0
551        // 0x81 = SetCurrentWindow(1), then "Hi", then 0x80 = SetCurrentWindow(0), then "Bye"
552        let data = [0x81, b'H', b'i', 0x80, b'B', b'y', b'e'];
553        let windows = decoder.decode(&packet(0, &data));
554        assert_eq!(windows[1].text, "Hi");
555        assert_eq!(windows[0].text, "Bye");
556    }
557
558    #[test]
559    fn test_carriage_return_appends_newline() {
560        let mut decoder = Dtvcc708Decoder::new();
561        decoder.decode(&packet(0, b"Line1"));
562        let windows = decoder.decode(&packet(1, &[0x0D]));
563        assert_eq!(windows[0].text, "Line1\n");
564    }
565
566    #[test]
567    fn test_hide_windows_makes_invisible() {
568        let mut decoder = Dtvcc708Decoder::new();
569        // Display window 0
570        decoder.decode(&packet(0, &[0x89, 0x01]));
571        // Hide window 0
572        let windows = decoder.decode(&packet(1, &[0x8A, 0x01]));
573        assert!(!windows[0].visible);
574    }
575}