use crate::Subtitle;
use std::collections::VecDeque;
const ROWS: usize = 15;
const COLS: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CharCell {
pub ch: char,
pub italic: bool,
pub underline: bool,
pub color: u8,
}
impl CharCell {
#[must_use]
pub const fn blank() -> Self {
Self {
ch: ' ',
italic: false,
underline: false,
color: 0,
}
}
#[must_use]
pub fn is_visible(&self) -> bool {
!self.ch.is_whitespace()
}
}
#[derive(Clone, Debug)]
struct ScreenBuffer {
cells: [[CharCell; COLS]; ROWS],
}
impl ScreenBuffer {
fn new() -> Self {
Self {
cells: [[CharCell::blank(); COLS]; ROWS],
}
}
fn clear(&mut self) {
*self = Self::new();
}
fn set(&mut self, row: usize, col: usize, cell: CharCell) {
if row < ROWS && col < COLS {
self.cells[row][col] = cell;
}
}
fn clear_cell(&mut self, row: usize, col: usize) {
if row < ROWS && col < COLS {
self.cells[row][col] = CharCell::blank();
}
}
fn scroll_up(&mut self) {
for r in 0..ROWS - 1 {
self.cells[r] = self.cells[r + 1];
}
self.cells[ROWS - 1] = [CharCell::blank(); COLS];
}
fn to_text(&self) -> String {
self.cells
.iter()
.map(|row| {
row.iter()
.map(|c| c.ch)
.collect::<String>()
.trim_end()
.to_string()
})
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn has_content(&self) -> bool {
self.cells
.iter()
.any(|row| row.iter().any(CharCell::is_visible))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayMode {
PopOn,
RollUp,
PaintOn,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CaptionEvent {
DisplayCaption {
start_ms: i64,
text: String,
},
ClearCaption {
timestamp_ms: i64,
},
RollUpText {
timestamp_ms: i64,
text: String,
},
}
impl CaptionEvent {
#[must_use]
pub fn to_subtitle(&self) -> Option<Subtitle> {
match self {
Self::DisplayCaption { start_ms, text } => {
Some(Subtitle::new(*start_ms, start_ms + 5_000, text.clone()))
}
_ => None,
}
}
}
pub struct RealtimeCea608Decoder {
mode: DisplayMode,
display_buf: ScreenBuffer,
stage_buf: ScreenBuffer,
cursor_row: usize,
cursor_col: usize,
rollup_rows: usize,
italic: bool,
underline: bool,
color: u8,
caption_start: Option<i64>,
last_pair: Option<(u8, u8)>,
events: VecDeque<CaptionEvent>,
}
impl RealtimeCea608Decoder {
#[must_use]
pub fn new() -> Self {
Self {
mode: DisplayMode::PopOn,
display_buf: ScreenBuffer::new(),
stage_buf: ScreenBuffer::new(),
cursor_row: ROWS - 1,
cursor_col: 0,
rollup_rows: 3,
italic: false,
underline: false,
color: 0,
caption_start: None,
last_pair: None,
events: VecDeque::new(),
}
}
pub fn feed(&mut self, byte1: u8, byte2: u8, timestamp_ms: i64) {
let b1 = byte1 & 0x7F;
let b2 = byte2 & 0x7F;
if b1 == 0x00 && b2 == 0x00 {
return;
}
let is_control = (0x10..=0x1F).contains(&b1);
if is_control {
if self.last_pair == Some((b1, b2)) {
self.last_pair = None;
return;
}
self.last_pair = Some((b1, b2));
} else {
self.last_pair = None;
}
if is_control {
self.handle_control(b1, b2, timestamp_ms);
} else if b1 >= 0x20 {
self.add_char(map_char(b1), timestamp_ms);
if b2 >= 0x20 {
self.add_char(map_char(b2), timestamp_ms);
}
}
}
pub fn drain_events(&mut self) -> Vec<CaptionEvent> {
self.events.drain(..).collect()
}
#[must_use]
pub fn collect_subtitles(&self) -> Vec<Subtitle> {
self.events
.iter()
.filter_map(CaptionEvent::to_subtitle)
.collect()
}
fn handle_control(&mut self, b1: u8, b2: u8, ts: i64) {
match (b1, b2) {
(0x14 | 0x1C, 0x20) => {
self.mode = DisplayMode::PopOn;
if self.caption_start.is_none() {
self.caption_start = Some(ts);
}
}
(0x14 | 0x1C, 0x2F) => {
if self.mode == DisplayMode::PopOn {
std::mem::swap(&mut self.display_buf, &mut self.stage_buf);
self.stage_buf.clear();
let text = self.display_buf.to_text();
if !text.trim().is_empty() {
let start = self.caption_start.unwrap_or(ts);
self.events.push_back(CaptionEvent::DisplayCaption {
start_ms: start,
text,
});
}
self.caption_start = None;
}
}
(0x14 | 0x1C, 0x25) => {
self.mode = DisplayMode::RollUp;
self.rollup_rows = 2;
if self.caption_start.is_none() {
self.caption_start = Some(ts);
}
}
(0x14 | 0x1C, 0x26) => {
self.mode = DisplayMode::RollUp;
self.rollup_rows = 3;
if self.caption_start.is_none() {
self.caption_start = Some(ts);
}
}
(0x14 | 0x1C, 0x27) => {
self.mode = DisplayMode::RollUp;
self.rollup_rows = 4;
if self.caption_start.is_none() {
self.caption_start = Some(ts);
}
}
(0x14 | 0x1C, 0x29) => {
self.mode = DisplayMode::PaintOn;
if self.caption_start.is_none() {
self.caption_start = Some(ts);
}
}
(0x14 | 0x1C, 0x2D) => {
if self.mode == DisplayMode::RollUp {
self.display_buf.scroll_up();
let text = self.display_buf.to_text();
if !text.trim().is_empty() {
self.events.push_back(CaptionEvent::RollUpText {
timestamp_ms: ts,
text,
});
}
self.cursor_col = 0;
}
}
(0x14 | 0x1C, 0x2C) => {
self.display_buf.clear();
self.events
.push_back(CaptionEvent::ClearCaption { timestamp_ms: ts });
}
(0x14 | 0x1C, 0x2E) => {
self.stage_buf.clear();
}
(0x14 | 0x1C, 0x21) => {
if self.cursor_col > 0 {
self.cursor_col -= 1;
let row = self.cursor_row;
let col = self.cursor_col;
self.active_buf_mut().clear_cell(row, col);
}
}
(0x11, b2) if (0x20..=0x2F).contains(&b2) => {
self.underline = (b2 & 0x01) != 0;
let style = (b2 & 0x0E) >> 1;
if style == 7 {
self.italic = true;
} else {
self.italic = false;
self.color = style;
}
}
(0x11, b2) if (0x30..=0x3F).contains(&b2) => {
if let Some(ch) = special_char(b2) {
self.add_char(ch, ts);
}
}
(b1, b2) if (0x10..=0x1F).contains(&b1) => {
let row = pac_row(b1);
self.cursor_row = row;
self.cursor_col = 0;
self.underline = (b2 & 0x01) != 0;
let style = (b2 & 0x0E) >> 1;
if style == 7 {
self.italic = true;
} else {
self.italic = false;
self.color = style;
}
}
_ => {}
}
}
fn add_char(&mut self, ch: char, ts: i64) {
let cell = CharCell {
ch,
italic: self.italic,
underline: self.underline,
color: self.color,
};
let row = self.cursor_row;
let col = self.cursor_col;
self.active_buf_mut().set(row, col, cell);
self.cursor_col = (self.cursor_col + 1).min(COLS - 1);
if self.mode == DisplayMode::PaintOn {
let text = self.display_buf.to_text();
if !text.trim().is_empty() {
self.events.push_back(CaptionEvent::RollUpText {
timestamp_ms: ts,
text,
});
}
}
}
fn active_buf_mut(&mut self) -> &mut ScreenBuffer {
match self.mode {
DisplayMode::PopOn => &mut self.stage_buf,
DisplayMode::RollUp | DisplayMode::PaintOn => &mut self.display_buf,
}
}
}
impl Default for RealtimeCea608Decoder {
fn default() -> Self {
Self::new()
}
}
fn map_char(b: u8) -> char {
match b {
0x2A => 'á',
0x5C => 'é',
0x5E => 'í',
0x5F => 'ó',
0x60 => 'ú',
0x7B => 'ç',
0x7C => '÷',
0x7D => 'Ñ',
0x7E => 'ñ',
0x7F => '█',
b if b >= 0x20 => b as char,
_ => ' ',
}
}
fn special_char(b2: u8) -> Option<char> {
Some(match b2 {
0x30 => '®',
0x31 => '°',
0x32 => '½',
0x33 => '¿',
0x34 => '™',
0x35 => '¢',
0x36 => '£',
0x37 => '♪',
0x38 => 'à',
0x39 => '\u{00A0}', 0x3A => 'è',
0x3B => 'â',
0x3C => 'ê',
0x3D => 'î',
0x3E => 'ô',
0x3F => 'û',
_ => return None,
})
}
fn pac_row(b1: u8) -> usize {
let high = if b1 & 0x08 != 0 { 8 } else { 0 };
let low = (b1 & 0x07) as usize;
(high + low).min(ROWS - 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pop_on_basic() {
let mut dec = RealtimeCea608Decoder::new();
dec.feed(0x94, 0x20, 0);
dec.feed(0x48, 0x49, 100);
dec.feed(0x94, 0x2F, 500);
let events = dec.drain_events();
let displays: Vec<_> = events
.iter()
.filter(|e| matches!(e, CaptionEvent::DisplayCaption { .. }))
.collect();
assert_eq!(displays.len(), 1, "expected exactly one DisplayCaption");
if let CaptionEvent::DisplayCaption { text, start_ms } = &displays[0] {
assert!(text.contains("HI"), "text should contain HI, got: {text}");
assert_eq!(*start_ms, 0);
}
}
#[test]
fn test_duplicate_control_ignored() {
let mut dec = RealtimeCea608Decoder::new();
dec.feed(0x94, 0x20, 0);
dec.feed(0x94, 0x20, 0); dec.feed(0x48, 0x49, 100);
dec.feed(0x94, 0x2F, 500);
dec.feed(0x94, 0x2F, 500);
let events = dec.drain_events();
let count = events
.iter()
.filter(|e| matches!(e, CaptionEvent::DisplayCaption { .. }))
.count();
assert_eq!(count, 1);
}
#[test]
fn test_erase_displayed_emits_clear() {
let mut dec = RealtimeCea608Decoder::new();
dec.feed(0x94, 0x2C, 1000); let events = dec.drain_events();
assert!(events
.iter()
.any(|e| matches!(e, CaptionEvent::ClearCaption { .. })));
}
#[test]
fn test_rollup_carriage_return_emits_event() {
let mut dec = RealtimeCea608Decoder::new();
dec.feed(0x94, 0x25, 0); dec.feed(0x48, 0x49, 100); dec.feed(0x94, 0x2D, 200);
let events = dec.drain_events();
assert!(events
.iter()
.any(|e| matches!(e, CaptionEvent::RollUpText { .. })));
}
#[test]
fn test_special_char_registered() {
assert_eq!(special_char(0x30), Some('®'));
assert_eq!(special_char(0x37), Some('♪'));
assert_eq!(special_char(0x3F), Some('û'));
assert_eq!(special_char(0x00), None);
}
#[test]
fn test_pac_row_mapping() {
assert_eq!(pac_row(0x10), 0);
assert_eq!(pac_row(0x17), 7);
assert_eq!(pac_row(0x18), 8);
assert_eq!(pac_row(0x1F), 14);
}
#[test]
fn test_map_char_basic_ascii() {
assert_eq!(map_char(0x41), 'A');
assert_eq!(map_char(0x61), 'a');
assert_eq!(map_char(0x20), ' ');
}
#[test]
fn test_map_char_special() {
assert_eq!(map_char(0x2A), 'á');
assert_eq!(map_char(0x7E), 'ñ');
}
#[test]
fn test_backspace() {
let mut dec = RealtimeCea608Decoder::new();
dec.feed(0x94, 0x20, 0); dec.feed(0x48, 0x49, 100); dec.feed(0x94, 0x21, 150); dec.feed(0x94, 0x2F, 500);
let events = dec.drain_events();
let text = events.iter().find_map(|e| {
if let CaptionEvent::DisplayCaption { text, .. } = e {
Some(text.clone())
} else {
None
}
});
let text = text.expect("should have display caption");
assert!(
!text.contains("HI"),
"I should have been erased, got: {text}"
);
}
#[test]
fn test_collect_subtitles() {
let mut dec = RealtimeCea608Decoder::new();
dec.feed(0x94, 0x20, 0);
dec.feed(0x48, 0x69, 100); dec.feed(0x94, 0x2F, 500);
let subs = dec.collect_subtitles();
assert_eq!(subs.len(), 1);
assert!(subs[0].text.contains("Hi"));
}
#[test]
fn test_null_padding_ignored() {
let mut dec = RealtimeCea608Decoder::new();
dec.feed(0x00, 0x00, 0);
dec.feed(0x80, 0x80, 10);
let events = dec.drain_events();
assert!(events.is_empty(), "null pairs should produce no events");
}
#[test]
fn test_erase_non_displayed_memory() {
let mut dec = RealtimeCea608Decoder::new();
dec.feed(0x94, 0x20, 0); dec.feed(0x48, 0x49, 100); dec.feed(0x94, 0x2E, 200); dec.feed(0x94, 0x2F, 500);
let events = dec.drain_events();
let has_nonempty_display = events.iter().any(|e| {
if let CaptionEvent::DisplayCaption { text, .. } = e {
!text.trim().is_empty()
} else {
false
}
});
assert!(
!has_nonempty_display,
"stage was erased so display should be empty"
);
}
#[test]
fn test_screen_buffer_scroll_up() {
let mut buf = ScreenBuffer::new();
buf.set(
ROWS - 2,
0,
CharCell {
ch: 'A',
..CharCell::blank()
},
);
buf.scroll_up();
assert_eq!(buf.cells[ROWS - 3][0].ch, 'A');
assert!(!buf.cells[ROWS - 1].iter().any(|c| c.is_visible()));
}
#[test]
fn test_screen_buffer_has_content() {
let mut buf = ScreenBuffer::new();
assert!(!buf.has_content());
buf.set(
0,
0,
CharCell {
ch: 'X',
..CharCell::blank()
},
);
assert!(buf.has_content());
}
}