#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TtColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
}
impl TtColor {
#[must_use]
pub fn from_code(code: u8) -> Option<Self> {
match code {
0 => Some(Self::Black),
1 => Some(Self::Red),
2 => Some(Self::Green),
3 => Some(Self::Yellow),
4 => Some(Self::Blue),
5 => Some(Self::Magenta),
6 => Some(Self::Cyan),
7 => Some(Self::White),
_ => None,
}
}
#[must_use]
pub fn to_code(self) -> u8 {
match self {
Self::Black => 0,
Self::Red => 1,
Self::Green => 2,
Self::Yellow => 3,
Self::Blue => 4,
Self::Magenta => 5,
Self::Cyan => 6,
Self::White => 7,
}
}
}
impl Default for TtColor {
fn default() -> Self {
Self::White
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TeletextCell {
pub character: char,
pub foreground_color: TtColor,
pub background_color: TtColor,
pub double_height: bool,
}
impl TeletextCell {
#[must_use]
pub fn plain(ch: char) -> Self {
Self {
character: ch,
foreground_color: TtColor::White,
background_color: TtColor::Black,
double_height: false,
}
}
#[must_use]
pub fn with_colors(
ch: char,
fg: TtColor,
bg: TtColor,
double_height: bool,
) -> Self {
Self {
character: ch,
foreground_color: fg,
background_color: bg,
double_height,
}
}
}
#[derive(Debug, Clone)]
pub struct TeletextRow {
pub row: u8,
pub cells: Vec<TeletextCell>,
}
impl TeletextRow {
#[must_use]
pub fn new(row: u8) -> Self {
Self {
row,
cells: Vec::new(),
}
}
#[must_use]
pub fn text(&self) -> String {
let raw: String = self.cells.iter().map(|c| c.character).collect();
raw.trim_end().to_string()
}
}
#[derive(Debug, Clone)]
pub struct TeletextPage {
pub magazine: u8,
pub page: u8,
pub subpage: u16,
pub rows: Vec<TeletextRow>,
}
impl TeletextPage {
#[must_use]
pub fn new(magazine: u8, page: u8) -> Self {
Self {
magazine,
page,
subpage: 0,
rows: Vec::new(),
}
}
#[must_use]
pub fn decimal_page_number(&self) -> u16 {
let tens = (self.page >> 4) as u16;
let units = (self.page & 0x0F) as u16;
self.magazine as u16 * 100 + tens * 10 + units
}
}
#[derive(Debug, Clone)]
pub struct TeletextSubtitle {
pub page_number: u16,
pub start_ms: u64,
pub end_ms: u64,
pub lines: Vec<String>,
}
impl TeletextSubtitle {
#[must_use]
pub fn text(&self) -> String {
self.lines.join("\n")
}
#[must_use]
pub fn duration_ms(&self) -> u64 {
self.end_ms.saturating_sub(self.start_ms)
}
}
pub struct TeletextParser;
impl TeletextParser {
#[must_use]
pub fn parse_page(page: &TeletextPage) -> Option<TeletextSubtitle> {
let page_number = page.decimal_page_number();
if !(800..=899).contains(&page_number) {
return None;
}
let lines: Vec<String> = page
.rows
.iter()
.filter(|r| r.row > 0) .map(|r| r.text())
.filter(|t| !t.is_empty())
.collect();
if lines.is_empty() {
return None;
}
Some(TeletextSubtitle {
page_number,
start_ms: 0,
end_ms: 0,
lines,
})
}
}
pub struct TeletextConverter;
impl TeletextConverter {
#[must_use]
pub fn to_srt(subtitles: &[TeletextSubtitle]) -> String {
let mut out = String::new();
for (idx, sub) in subtitles.iter().enumerate() {
let seq = idx + 1;
let start = format_srt_time(sub.start_ms);
let end = format_srt_time(sub.end_ms);
let text = sub.text();
out.push_str(&format!("{seq}\n{start} --> {end}\n{text}\n\n"));
}
out
}
}
fn format_srt_time(ms: u64) -> String {
let total_s = ms / 1_000;
let millis = ms % 1_000;
let h = total_s / 3_600;
let m = (total_s % 3_600) / 60;
let s = total_s % 60;
format!("{h:02}:{m:02}:{s:02},{millis:03}")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_page_with_rows(magazine: u8, page: u8, row_texts: &[&str]) -> TeletextPage {
let mut p = TeletextPage::new(magazine, page);
for (i, &text) in row_texts.iter().enumerate() {
let mut row = TeletextRow::new(i as u8);
for ch in text.chars() {
row.cells.push(TeletextCell::plain(ch));
}
p.rows.push(row);
}
p
}
#[test]
fn test_tt_color_from_code_valid() {
assert_eq!(TtColor::from_code(0), Some(TtColor::Black));
assert_eq!(TtColor::from_code(1), Some(TtColor::Red));
assert_eq!(TtColor::from_code(7), Some(TtColor::White));
}
#[test]
fn test_tt_color_from_code_invalid() {
assert_eq!(TtColor::from_code(8), None);
assert_eq!(TtColor::from_code(255), None);
}
#[test]
fn test_tt_color_round_trip() {
for code in 0u8..=7 {
let color = TtColor::from_code(code).expect("valid code");
assert_eq!(color.to_code(), code);
}
}
#[test]
fn test_tt_color_default_is_white() {
assert_eq!(TtColor::default(), TtColor::White);
}
#[test]
fn test_decimal_page_number_888() {
let page = TeletextPage::new(8, 0x88);
assert_eq!(page.decimal_page_number(), 888);
}
#[test]
fn test_decimal_page_number_777() {
let page = TeletextPage::new(7, 0x77);
assert_eq!(page.decimal_page_number(), 777);
}
#[test]
fn test_decimal_page_number_100() {
let page = TeletextPage::new(1, 0x00);
assert_eq!(page.decimal_page_number(), 100);
}
#[test]
fn test_page_in_subtitle_range_accepted() {
let page = make_page_with_rows(
8, 0x88,
&["", "Foreign dialogue line one", "Foreign dialogue line two"],
);
let result = TeletextParser::parse_page(&page);
assert!(result.is_some(), "page 888 should be accepted");
let sub = result.unwrap();
assert_eq!(sub.page_number, 888);
assert_eq!(sub.lines.len(), 2);
}
#[test]
fn test_page_out_of_range_ignored_low() {
let page = make_page_with_rows(1, 0x00, &["", "Some text on a non-subtitle page"]);
let result = TeletextParser::parse_page(&page);
assert!(result.is_none(), "page 100 is not a subtitle page");
}
#[test]
fn test_page_out_of_range_ignored_high() {
let page = make_page_with_rows(9, 0x00, &["", "Text"]);
let result = TeletextParser::parse_page(&page);
assert!(result.is_none(), "page >= 900 is not a subtitle page");
}
#[test]
fn test_page_boundary_800_accepted() {
let page = make_page_with_rows(8, 0x00, &["", "Boundary subtitle"]);
let result = TeletextParser::parse_page(&page);
assert!(result.is_some(), "page 800 is the lower boundary");
}
#[test]
fn test_empty_subtitle_page_returns_none() {
let mut page = TeletextPage::new(8, 0x88);
let empty_row = TeletextRow::new(1); page.rows.push(empty_row);
let result = TeletextParser::parse_page(&page);
assert!(result.is_none(), "empty page should return None");
}
#[test]
fn test_double_height_ignored_in_text_extraction() {
let mut page = TeletextPage::new(8, 0x88);
let mut row = TeletextRow::new(1);
row.cells.push(TeletextCell::with_colors(
'H', TtColor::White, TtColor::Black, true,
));
row.cells.push(TeletextCell::with_colors(
'i', TtColor::White, TtColor::Black, false,
));
page.rows.push(row);
let result = TeletextParser::parse_page(&page);
assert!(result.is_some());
let sub = result.unwrap();
assert_eq!(sub.lines[0], "Hi", "double_height should not alter character extraction");
}
#[test]
fn test_srt_output_format() {
let subs = vec![TeletextSubtitle {
page_number: 888,
start_ms: 1_000,
end_ms: 4_000,
lines: vec!["Hello Teletext".to_string()],
}];
let srt = TeletextConverter::to_srt(&subs);
assert!(srt.contains("1\n"), "SRT must start with sequence number");
assert!(srt.contains("00:00:01,000 --> 00:00:04,000"), "SRT timing must be correct");
assert!(srt.contains("Hello Teletext"), "SRT must contain subtitle text");
}
#[test]
fn test_srt_multi_line_output() {
let subs = vec![TeletextSubtitle {
page_number: 888,
start_ms: 5_500,
end_ms: 9_000,
lines: vec!["Line one".to_string(), "Line two".to_string()],
}];
let srt = TeletextConverter::to_srt(&subs);
assert!(srt.contains("00:00:05,500 --> 00:00:09,000"));
assert!(srt.contains("Line one\nLine two"));
}
#[test]
fn test_srt_empty_input() {
let srt = TeletextConverter::to_srt(&[]);
assert!(srt.is_empty());
}
#[test]
fn test_srt_sequence_numbers() {
let subs: Vec<TeletextSubtitle> = (0..3)
.map(|i| TeletextSubtitle {
page_number: 888,
start_ms: i * 5_000,
end_ms: i * 5_000 + 4_000,
lines: vec![format!("Cue {i}")],
})
.collect();
let srt = TeletextConverter::to_srt(&subs);
assert!(srt.contains("1\n"));
assert!(srt.contains("2\n"));
assert!(srt.contains("3\n"));
}
#[test]
fn test_format_srt_time_zero() {
assert_eq!(format_srt_time(0), "00:00:00,000");
}
#[test]
fn test_format_srt_time_hours() {
assert_eq!(format_srt_time(3_661_500), "01:01:01,500");
}
#[test]
fn test_row_text_trims_trailing_spaces() {
let mut row = TeletextRow::new(1);
for ch in "Hello ".chars() {
row.cells.push(TeletextCell::plain(ch));
}
assert_eq!(row.text(), "Hello");
}
#[test]
fn test_subtitle_text_joins_lines() {
let sub = TeletextSubtitle {
page_number: 888,
start_ms: 0,
end_ms: 3_000,
lines: vec!["First".to_string(), "Second".to_string()],
};
assert_eq!(sub.text(), "First\nSecond");
}
#[test]
fn test_subtitle_duration() {
let sub = TeletextSubtitle {
page_number: 888,
start_ms: 1_000,
end_ms: 4_000,
lines: vec!["Test".to_string()],
};
assert_eq!(sub.duration_ms(), 3_000);
}
}