use thiserror::Error;
use crate::{DiskFormatCode, Time};
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("Invalid {field}: {msg}")]
pub struct InvalidFieldValue<T> {
field: &'static str,
msg: String,
value: T,
}
impl<T> InvalidFieldValue<T> {
pub(crate) fn new(field: &'static str, msg: String, value: T) -> Self {
Self { field, msg, value }
}
pub fn field(&self) -> &'static str {
self.field
}
pub fn msg(&self) -> &str {
&self.msg
}
pub fn value(&self) -> &T {
&self.value
}
pub fn into_value(self) -> T {
self.value
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LanguageCode(pub u8);
impl LanguageCode {
pub fn name(self) -> Option<&'static str> {
LATIN_LANGUAGES
.get(usize::from(self.0))
.copied()
.or_else(|| {
let index = usize::from(self.0).checked_sub(FIRST_OTHER_LANGUAGE)?;
OTHER_LANGUAGES.get(index).copied()
})
}
}
const LATIN_LANGUAGES: [&str; 0x2C] = [
"Unknown/not applicable",
"Albanian",
"Breton",
"Catalan",
"Croatian",
"Welsh",
"Czech",
"Danish",
"German",
"English",
"Spanish",
"Esperanto",
"Estonian",
"Basque",
"Faroese",
"French",
"Frisian",
"Irish",
"Gaelic",
"Galician",
"Icelandic",
"Italian",
"Lappish",
"Latin",
"Latvian",
"Luxembourgian",
"Lithuanian",
"Hungarian",
"Maltese",
"Dutch",
"Norwegian",
"Occitan",
"Polish",
"Portugese",
"Romanian",
"Romansh",
"Serbian",
"Slovak",
"Slovenian",
"Finnish",
"Swedish",
"Turkish",
"Flemish",
"Wallon",
];
const FIRST_OTHER_LANGUAGE: usize = 0x45;
const OTHER_LANGUAGES: [&str; 0x80 - FIRST_OTHER_LANGUAGE] = [
"Zulu",
"Vietnamese",
"Uzbek",
"Urdu",
"Ukrainian",
"Thai",
"Telugu",
"Tatar",
"Tamil",
"Tadzhik",
"Swahili",
"Sranan Tongo",
"Somali",
"Sinhalese",
"Shona",
"Serbo-croat",
"Ruthenian",
"Russian",
"Quechua",
"Pushtu",
"Punjabi",
"Persian",
"Papamiento",
"Oriya",
"Nepali",
"Ndebele",
"Marathi",
"Moldavian",
"Malaysian",
"Malagasay",
"Macedonian",
"Laotian",
"Korean",
"Khmer",
"Kazakh",
"Kannada",
"Japanese",
"Indonesian",
"Hindi",
"Hebrew",
"Hausa",
"Gurani",
"Gujurati",
"Greek",
"Georgian",
"Fulani",
"Dari",
"Churash",
"Chinese",
"Burmese",
"Bulgarian",
"Bengali",
"Bielorussian",
"Bambora",
"Azerbaijani",
"Assamese",
"Armenian",
"Arabic",
"Amharic",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Date {
pub year: u16,
pub month: u8,
pub day: u8,
}
const FIRST_YEAR_IN_1900S: u16 = 80;
pub(crate) fn parse_digits(value: &str) -> Option<u32> {
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
value.parse().ok()
}
fn non_blank(value: &str) -> Option<&str> {
if value.chars().all(|c| c == ' ') {
None
} else {
Some(value)
}
}
pub(crate) fn parse_field<'a, T>(
field: &'static str,
value: &'a str,
parse: impl FnOnce(&'a str) -> Result<T, String>,
) -> Result<Option<T>, InvalidFieldValue<String>> {
match non_blank(value) {
None => Ok(None),
Some(value) => parse(value)
.map(Some)
.map_err(|msg| InvalidFieldValue::new(field, msg, value.to_string())),
}
}
pub(crate) fn parse_language_code(value: &str) -> Result<LanguageCode, String> {
if value.len() != 2 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!("{value:?} is not two hexadecimal digits"));
}
let code = u8::from_str_radix(value, 16).map_err(|err| err.to_string())?;
if code >= 0x80 {
return Err(format!("{code:02X}h is outside 00h-7Fh"));
}
Ok(LanguageCode(code))
}
pub(crate) fn parse_date(value: &str) -> Result<Date, String> {
let date = two_digit_date(value)
.ok_or_else(|| format!("{value:?} is not a date in the format YYMMDD"))?;
if !date_exists(date) {
return Err(format!("{value:?} is not an existing date"));
}
Ok(date)
}
fn two_digit_date(value: &str) -> Option<Date> {
if value.len() != 6 || !value.is_ascii() {
return None;
}
let two_digit_year = u16::try_from(parse_digits(&value[0..2])?).ok()?;
let month = u8::try_from(parse_digits(&value[2..4])?).ok()?;
let day = u8::try_from(parse_digits(&value[4..6])?).ok()?;
let year = if two_digit_year >= FIRST_YEAR_IN_1900S {
1900 + two_digit_year
} else {
2000 + two_digit_year
};
Some(Date { year, month, day })
}
fn date_exists(date: Date) -> bool {
let (Ok(year), Ok(month), Ok(day)) = (
i16::try_from(date.year),
i8::try_from(date.month),
i8::try_from(date.day),
) else {
return false;
};
jiff::civil::Date::new(year, month, day).is_ok()
}
pub(crate) fn parse_revision_number(value: &str) -> Result<u8, String> {
parse_digits(value.trim_matches(' '))
.and_then(|number| u8::try_from(number).ok())
.ok_or_else(|| format!("{value:?} is not a number 0-99"))
}
pub(crate) fn parse_time_code(value: &str, fps: usize) -> Result<Time, String> {
let time = split_time_code(value)
.ok_or_else(|| format!("{value:?} is not a time code in the format HHMMSSFF"))?;
match time.range_error(fps, false) {
Some(msg) => Err(msg),
None => Ok(time),
}
}
fn split_time_code(value: &str) -> Option<Time> {
if value.len() != 8 || !value.is_ascii() {
return None;
}
let element = |range| u8::try_from(parse_digits(&value[range])?).ok();
Some(Time {
hours: element(0..2)?,
minutes: element(2..4)?,
seconds: element(4..6)?,
frames: element(6..8)?,
})
}
pub(crate) fn checked_time_code(
field: &'static str,
time: &Time,
dfc: &DiskFormatCode,
) -> Result<Time, InvalidFieldValue<Time>> {
match time.range_error(dfc.fps(), true) {
Some(msg) => Err(InvalidFieldValue::new(field, msg, *time)),
None => Ok(*time),
}
}
pub(crate) fn parse_country_code(value: &str) -> Result<&str, String> {
if value.len() == 3 && value.bytes().all(|b| b.is_ascii_uppercase()) {
Ok(value)
} else {
Err(format!("{value:?} is not three upper case letters"))
}
}
pub(crate) fn text(value: &str) -> Option<&str> {
non_blank(value).map(|value| value.trim_end_matches(' '))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Justification {
Unchanged,
Left,
Centered,
Right,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{GsiBlock, parse_stl_from_file};
fn gsi() -> GsiBlock {
parse_stl_from_file("stls/test.stl").unwrap().gsi
}
fn assert_invalid<T: std::fmt::Debug, V: std::fmt::Debug + PartialEq>(
result: Result<T, InvalidFieldValue<V>>,
field: &str,
value: V,
) {
let err = result.expect_err("invalid value");
assert_eq!(field, err.field());
assert_eq!(&value, err.value());
assert!(!err.msg().is_empty());
}
fn time(hours: u8, minutes: u8, seconds: u8, frames: u8) -> Time {
Time {
hours,
minutes,
seconds,
frames,
}
}
#[test]
fn language_names() {
assert_eq!(Some("Unknown/not applicable"), LanguageCode(0x00).name());
assert_eq!(Some("French"), LanguageCode(0x0F).name());
assert_eq!(Some("Wallon"), LanguageCode(0x2B).name());
assert_eq!(Some("Zulu"), LanguageCode(0x45).name());
assert_eq!(Some("Russian"), LanguageCode(0x56).name());
assert_eq!(Some("Amharic"), LanguageCode(0x7F).name());
}
#[test]
fn unassigned_language_codes_have_no_name() {
for code in [0x2C, 0x2F, 0x3F, 0x40, 0x44, 0x80, 0xFF] {
assert_eq!(None, LanguageCode(code).name(), "code {code:02X}");
}
}
#[test]
fn language_code() {
let mut gsi = gsi();
gsi.lc = "0F".to_string();
assert_eq!(Ok(Some(LanguageCode(0x0F))), gsi.language());
gsi.lc = "7f".to_string();
assert_eq!(Ok(Some(LanguageCode(0x7F))), gsi.language());
gsi.lc = " ".to_string();
assert_eq!(Ok(None), gsi.language());
for lc in ["80", "+F", "F ", "XY", "\0\0"] {
gsi.lc = lc.to_string();
assert_invalid(gsi.language(), "LC", lc.to_string());
}
}
#[test]
fn dates() {
let mut gsi = gsi();
gsi.cd = "910215".to_string();
let date = Date {
year: 1991,
month: 2,
day: 15,
};
assert_eq!(Ok(Some(date)), gsi.creation_date());
gsi.rd = "240229".to_string();
let date = Date {
year: 2024,
month: 2,
day: 29,
};
assert_eq!(Ok(Some(date)), gsi.revision_date());
gsi.rd = " ".to_string();
assert_eq!(Ok(None), gsi.revision_date());
}
#[test]
fn century_of_two_digit_year() {
let mut gsi = gsi();
gsi.cd = "790101".to_string();
assert_eq!(2079, gsi.creation_date().unwrap().unwrap().year);
gsi.cd = "800101".to_string();
assert_eq!(1980, gsi.creation_date().unwrap().unwrap().year);
}
#[test]
fn invalid_dates() {
let mut gsi = gsi();
for cd in [
"911341",
"910230",
"230229",
"9102 ",
"91-2-1",
"\0\0\0\0\0\0",
"1é234",
] {
gsi.cd = cd.to_string();
assert_invalid(gsi.creation_date(), "CD", cd.to_string());
}
}
#[test]
fn revision_number() {
let mut gsi = gsi();
gsi.rn = "07".to_string();
assert_eq!(Ok(Some(7)), gsi.revision_number());
gsi.rn = " 7".to_string();
assert_eq!(Ok(Some(7)), gsi.revision_number());
for rn in ["-1", "+1", "x1"] {
gsi.rn = rn.to_string();
assert_invalid(gsi.revision_number(), "RN", rn.to_string());
}
}
#[test]
fn time_codes() {
let mut gsi = gsi();
gsi.tcp = "23595924".to_string();
assert_eq!(
Ok(Some(time(23, 59, 59, 24))),
gsi.time_code_start_of_programme()
);
gsi.tcf = " ".to_string();
assert_eq!(Ok(None), gsi.time_code_first_in_cue());
for tcp in [
"00600000", "00006000", "00000025", "1000000", "10:00:00", "1é00000",
] {
gsi.tcp = tcp.to_string();
assert_invalid(gsi.time_code_start_of_programme(), "TCP", tcp.to_string());
}
}
#[test]
fn start_of_programme_and_first_in_cue_allow_hours_past_midnight() {
let mut gsi = gsi();
gsi.tcp = "24000000".to_string();
assert_eq!(
Ok(Some(time(24, 0, 0, 0))),
gsi.time_code_start_of_programme()
);
gsi.tcf = "99595924".to_string();
assert_eq!(Ok(Some(time(99, 59, 59, 24))), gsi.time_code_first_in_cue());
}
#[test]
fn time_code_in_and_out() {
let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
let tti = &mut stl.ttis[0];
tti.tci = time(23, 59, 59, 24);
tti.tco = time(0, 0, 0, 0);
assert_eq!(Ok(time(23, 59, 59, 24)), tti.time_code_in(&stl.gsi.dfc));
assert_eq!(Ok(time(0, 0, 0, 0)), tti.time_code_out(&stl.gsi.dfc));
}
#[test]
fn time_code_in_and_out_outside_spec_are_errors() {
let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
let dfc = DiskFormatCode::STL25_01;
let tti = &mut stl.ttis[0];
for (tci, msg) in [
(time(24, 0, 0, 0), "24:00:00:00 has hours 24 above 23"),
(time(0, 60, 0, 0), "00:60:00:00 has minutes 60 above 59"),
(time(0, 0, 60, 0), "00:00:60:00 has seconds 60 above 59"),
(
time(0, 0, 0, 25),
"00:00:00:25 has frame 25 above 24 at 25 frames per second",
),
] {
tti.tci = tci;
let err = tti.time_code_in(&dfc).unwrap_err();
assert_eq!(("TCI", msg, &tci), (err.field(), err.msg(), err.value()));
}
tti.tco = time(0, 0, 0, 25);
assert_invalid(tti.time_code_out(&dfc), "TCO", time(0, 0, 0, 25));
assert!(tti.time_code_out(&DiskFormatCode::STL30_01).is_ok());
}
#[test]
fn time_code_frames_depend_on_disk_format_code() {
let mut gsi = gsi();
gsi.tcf = "00000029".to_string();
assert!(gsi.time_code_first_in_cue().is_err());
gsi.dfc = DiskFormatCode::STL30_01;
assert_eq!(Ok(Some(time(0, 0, 0, 29))), gsi.time_code_first_in_cue());
gsi.tcf = "00000030".to_string();
assert!(gsi.time_code_first_in_cue().is_err());
}
#[test]
fn country_of_origin() {
let mut gsi = gsi();
gsi.co = " ".to_string();
assert_eq!(Ok(None), gsi.country_of_origin());
for co in ["fra", "FR ", "F1A"] {
gsi.co = co.to_string();
assert_invalid(gsi.country_of_origin(), "CO", co.to_string());
}
}
#[test]
fn free_text_keeps_leading_spaces() {
let mut gsi = gsi();
gsi.opt = " Title with leading spaces ".to_string();
assert_eq!(
Some(" Title with leading spaces"),
gsi.original_programme_title()
);
}
#[test]
fn justification_code() {
let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
let tti = &mut stl.ttis[0];
for (jc, expected) in [
(0, Justification::Unchanged),
(1, Justification::Left),
(2, Justification::Centered),
(3, Justification::Right),
] {
tti.jc = jc;
assert_eq!(Ok(expected), tti.justification());
}
tti.jc = 0x20;
assert_invalid(tti.justification(), "JC", 0x20);
}
#[test]
fn comment_flag() {
let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
let tti = &mut stl.ttis[0];
tti.cf = 1;
assert_eq!(Ok(true), tti.comment_flag());
tti.cf = 2;
assert_invalid(tti.comment_flag(), "CF", 2);
}
#[test]
fn error_messages() {
let mut gsi = gsi();
gsi.cd = "911341".to_string();
assert_eq!(
"Invalid CD: \"911341\" is not an existing date",
gsi.creation_date().unwrap_err().to_string()
);
gsi.cd = "91-2-1".to_string();
assert_eq!(
"Invalid CD: \"91-2-1\" is not a date in the format YYMMDD",
gsi.creation_date().unwrap_err().to_string()
);
gsi.lc = "80".to_string();
assert_eq!(
"Invalid LC: 80h is outside 00h-7Fh",
gsi.language().unwrap_err().to_string()
);
gsi.tcp = "00000025".to_string();
assert_eq!(
"Invalid TCP: 00:00:00:25 has frame 25 above 24 at 25 frames per second",
gsi.time_code_start_of_programme().unwrap_err().to_string()
);
}
#[test]
fn invalid_value_can_be_taken() {
let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
let tti = &mut stl.ttis[0];
tti.tci = time(10, 2, 31, 25);
let tci = tti
.time_code_in(&stl.gsi.dfc)
.unwrap_or_else(|err| err.into_value());
assert_eq!(time(10, 2, 31, 25), tci);
}
#[test]
fn invalid_value_converts_into_parse_error() {
let mut gsi = gsi();
gsi.co = "fra".to_string();
let err = gsi.country_of_origin().unwrap_err();
let msg = err.to_string();
assert!(matches!(
crate::ParseError::from(err),
crate::ParseError::InvalidFieldValue(converted) if converted == msg
));
}
}