#![warn(missing_docs)]
use std::fs::File;
use std::io::prelude::*;
use std::{fmt, path::Path};
mod fields;
mod gsi;
mod latin;
pub mod parser;
mod subtitle;
mod text_field;
mod time;
mod tti;
mod validate;
pub use crate::fields::{Date, InvalidFieldValue, Justification, LanguageCode};
use crate::gsi::MAX_TNB;
pub use crate::gsi::{
CharacterCodeTable, CodePageNumber, DiskFormatCode, DisplayStandardCode, GsiBlock,
TimeCodeStatus,
};
pub use crate::parser::ParseError;
pub use crate::subtitle::{Subtitle, Subtitles};
pub use crate::text_field::{Color, OpenControl, TeletextControl, TextElement};
pub use crate::time::Time;
pub use crate::tti::{CumulativeStatus, TtiBlock, TtiFormat};
use crate::tti::{EBN_LAST_BLOCK, MAX_TEXT_BLOCKS};
pub use crate::validate::ValidationIssue;
use crate::parser::parse_stl_from_slice;
use crate::text_field::Presentation;
#[derive(Debug, PartialEq, Eq)]
pub struct Stl {
pub gsi: GsiBlock,
pub ttis: Vec<TtiBlock>,
}
impl fmt::Display for Stl {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}\n{:?}\n", self.gsi, self.ttis)
}
}
impl Stl {
pub fn new() -> Stl {
Stl {
gsi: GsiBlock::new(),
ttis: vec![],
}
}
pub fn write_to_file<P: AsRef<Path>>(&self, filename: P) -> Result<(), ParseError> {
let mut f = File::create(filename)?;
f.write_all(&self.gsi.serialize()?)?;
for tti in self.ttis.iter() {
f.write_all(&tti.serialize())?;
}
Ok(())
}
pub fn add_sub(
&mut self,
tci: Time,
tco: Time,
txt: &str,
opt: TtiFormat,
) -> Result<(), ParseError> {
let presentation = match self.gsi.dsc {
DisplayStandardCode::OpenSubtitling => Presentation::Open,
_ => Presentation::Teletext {
double_height: opt.dh,
},
};
let text_fields = text_field::encode_text_fields(txt, presentation, self.gsi.cct);
if text_fields.len() > MAX_TEXT_BLOCKS {
return Err(ParseError::SubtitleTextTooLong {
blocks: text_fields.len(),
});
}
let tnb = u32::try_from(text_fields.len())
.ok()
.and_then(|blocks| self.gsi.tnb.checked_add(blocks))
.filter(|&tnb| tnb <= MAX_TNB)
.ok_or(ParseError::TooManyTtiBlocks)?;
let sn = match self.ttis.last() {
Some(last) => last
.sn
.checked_add(1)
.ok_or(ParseError::SubtitleNumberOverflow)?,
None => 1,
};
if self.ttis.is_empty() {
self.gsi.tcf = format!(
"{:02}{:02}{:02}{:02}",
tci.hours, tci.minutes, tci.seconds, tci.frames
);
}
let last_block = text_fields.len() - 1;
for (i, (tf, ebn)) in text_fields.into_iter().zip(0_u8..).enumerate() {
self.ttis.push(TtiBlock {
sgn: 0,
sn,
ebn: if i == last_block { EBN_LAST_BLOCK } else { ebn },
cs: CumulativeStatus::NotPartOfASet,
tci,
tco,
vp: opt.vp,
jc: opt.jc,
cf: 0,
tf,
cct: self.gsi.cct,
});
}
self.gsi.tnb = tnb;
self.gsi.tns += 1;
Ok(())
}
pub fn subtitles(&self) -> Subtitles<'_> {
Subtitles::new(&self.ttis)
}
}
impl Default for Stl {
fn default() -> Self {
Self::new()
}
}
pub fn parse_stl_from_file<P: AsRef<Path>>(filename: P) -> Result<Stl, ParseError> {
let mut f = File::open(filename)?;
let mut buffer = vec![];
f.read_to_end(&mut buffer)?;
Ok(parse_stl_from_slice(&mut buffer.as_slice())?)
}
#[cfg(test)]
mod tests {
use super::*;
fn time(seconds: u8) -> Time {
Time {
hours: 10,
minutes: 0,
seconds,
frames: 0,
}
}
fn format(dh: bool) -> TtiFormat {
TtiFormat { jc: 2, vp: 20, dh }
}
fn add(stl: &mut Stl, text: &str) -> Result<(), ParseError> {
stl.add_sub(time(1), time(2), text, format(false))
}
fn ebns(stl: &Stl) -> Vec<u8> {
stl.ttis.iter().map(|tti| tti.ebn).collect()
}
#[test]
fn add_sub_with_rows() {
let mut stl = Stl::new();
add(&mut stl, "One\nTwo").unwrap();
stl.add_sub(time(3), time(4), "Three\nFour", format(true))
.unwrap();
let texts: Vec<String> = stl.subtitles().map(|s| s.text()).collect();
assert_eq!(vec!["One\r\nTwo", "Three\r\n\r\nFour"], texts);
assert_eq!(vec![0xFF, 0xFF], ebns(&stl));
}
#[test]
fn add_sub_numbers_subtitles_from_one() {
let mut stl = Stl::new();
add(&mut stl, "One").unwrap();
add(&mut stl, "Two").unwrap();
let numbers: Vec<u16> = stl.ttis.iter().map(|tti| tti.sn).collect();
assert_eq!(vec![1, 2], numbers);
}
#[test]
fn add_sub_continues_subtitle_number_of_last_block() {
let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
let last_sn = stl.ttis.last().unwrap().sn;
add(&mut stl, "Added").unwrap();
assert_eq!(last_sn + 1, stl.ttis.last().unwrap().sn);
}
#[test]
fn add_sub_long_text_uses_extension_blocks() {
let text = "A long subtitle text, ".repeat(12);
let mut stl = Stl::new();
add(&mut stl, &text).unwrap();
add(&mut stl, "Next").unwrap();
assert_eq!(vec![0x00, 0x01, 0xFF, 0xFF], ebns(&stl));
assert_eq!(
vec![1, 1, 1, 2],
stl.ttis.iter().map(|t| t.sn).collect::<Vec<_>>()
);
assert_eq!(4, stl.gsi.tnb);
assert_eq!(2, stl.gsi.tns);
let subtitle = stl.subtitles().next().unwrap();
assert_eq!(text.trim_end(), subtitle.text().trim_end());
assert!(stl.ttis.iter().all(|tti| tti.tci == time(1)));
}
#[test]
fn add_sub_open_subtitles_have_no_teletext_codes() {
let mut stl = Stl::new();
stl.gsi.dsc = DisplayStandardCode::OpenSubtitling;
stl.add_sub(time(1), time(2), "One\nTwo", format(true))
.unwrap();
assert_eq!(b"One\x8ATwo\x8F", &stl.ttis[0].tf[..8]);
}
#[test]
fn add_sub_too_long_text_is_an_error() {
let mut stl = Stl::new();
let text = "a".repeat(MAX_TEXT_BLOCKS * text_field::TF_LENGTH);
let err = add(&mut stl, &text).unwrap_err();
assert!(
matches!(err, ParseError::SubtitleTextTooLong { blocks: 242 }),
"{err:?}"
);
assert!(stl.ttis.is_empty());
assert_eq!(0, stl.gsi.tnb);
assert_eq!(0, stl.gsi.tns);
}
#[test]
fn add_sub_longest_allowed_text() {
let mut stl = Stl::new();
let text = "a".repeat(MAX_TEXT_BLOCKS * text_field::TF_LENGTH - 5);
add(&mut stl, &text).unwrap();
assert_eq!(MAX_TEXT_BLOCKS, stl.ttis.len());
assert_eq!(0xEF, stl.ttis[MAX_TEXT_BLOCKS - 2].ebn);
assert_eq!(0xFF, stl.ttis[MAX_TEXT_BLOCKS - 1].ebn);
}
#[test]
fn add_sub_tnb_overflow_is_an_error() {
let mut stl = Stl::new();
stl.gsi.tnb = MAX_TNB;
assert!(matches!(
add(&mut stl, "One"),
Err(ParseError::TooManyTtiBlocks)
));
assert!(stl.ttis.is_empty());
}
#[test]
fn add_sub_subtitle_number_overflow_is_an_error() {
let mut stl = Stl::new();
stl.ttis.push(TtiBlock::new(
u16::MAX,
time(1),
time(2),
"Last",
format(false),
CharacterCodeTable::Latin,
));
assert!(matches!(
add(&mut stl, "One"),
Err(ParseError::SubtitleNumberOverflow)
));
assert_eq!(1, stl.ttis.len());
}
#[test]
fn written_subtitles_are_parsed_back() {
let mut stl = Stl::new();
add(&mut stl, "Première ligne\nDeuxième ligne à $5").unwrap();
add(&mut stl, &"Long text ".repeat(20)).unwrap();
let path = std::env::temp_dir().join("ebustl_parser_add_sub.stl");
stl.write_to_file(&path).unwrap();
let parsed = parse_stl_from_file(&path);
std::fs::remove_file(&path).ok();
let parsed = parsed.unwrap();
assert_eq!(stl.ttis, parsed.ttis);
assert_eq!(stl.gsi.tnb, parsed.gsi.tnb);
assert_eq!(stl.gsi.tns, parsed.gsi.tns);
let texts: Vec<String> = parsed.subtitles().map(|s| s.text()).collect();
assert_eq!("Première ligne\r\nDeuxième ligne à $5", texts[0]);
assert_eq!("Long text ".repeat(20).trim_end(), texts[1].trim_end());
}
#[test]
fn tti_block_new_truncates_to_one_block() {
let tti = TtiBlock::new(
1,
time(1),
time(2),
&"a".repeat(200),
format(false),
CharacterCodeTable::Latin,
);
assert_eq!(text_field::TF_LENGTH, tti.tf.len());
assert_eq!(
text_field::TF_UNUSED_SPACE,
tti.tf[text_field::TF_LENGTH - 1]
);
assert_eq!("a".repeat(text_field::TF_LENGTH - 3), tti.text());
}
#[test]
fn renamed_getters() {
let stl = parse_stl_from_file("stls/test.stl").unwrap();
assert_eq!(1, stl.gsi.total_number_of_subtitle_groups());
assert_eq!(40, stl.gsi.max_number_of_chars_in_row());
let tti = &stl.ttis[1];
assert_eq!(tti.sn, tti.subtitle_number());
}
}