Skip to main content

ebustl_parser/
fields.rs

1//! Types and validation for GSI and TTI fields that are stored as they appear in the file.
2//!
3//! The stored values are kept so that files are written back unchanged, and so that a file with
4//! an invalid value in one of these fields can still be read. The accessors of [`GsiBlock`] and
5//! [`TtiBlock`] validate a single field at a time, using the functions here, and tell a blank
6//! field apart from an invalid one.
7//!
8//! [`GsiBlock`]: crate::GsiBlock
9//! [`TtiBlock`]: crate::TtiBlock
10
11use thiserror::Error;
12
13use crate::{DiskFormatCode, Time};
14
15/// A field value that is invalid according to the spec, with the value as stored in the file.
16///
17/// `T` is the type of the stored value: `String` for fields in the GSI block, `u8` for single
18/// byte fields and [`Time`] for time codes in the TTI blocks.
19///
20/// # Example
21///
22/// ```rust
23/// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
24/// let dfc = stl.gsi.disk_format_code();
25/// for tti in &stl.ttis {
26///     // Use the time code as stored, even if it is outside the ranges in the spec
27///     let tci = tti.time_code_in(dfc).unwrap_or_else(|err| {
28///         eprintln!("{err}");
29///         err.into_value()
30///     });
31///     assert_eq!(10, tci.hours);
32/// }
33/// ```
34#[derive(Debug, Clone, PartialEq, Eq, Error)]
35#[error("Invalid {field}: {msg}")]
36pub struct InvalidFieldValue<T> {
37    field: &'static str,
38    msg: String,
39    value: T,
40}
41
42impl<T> InvalidFieldValue<T> {
43    pub(crate) fn new(field: &'static str, msg: String, value: T) -> Self {
44        Self { field, msg, value }
45    }
46
47    /// Mnemonic of the field in the spec, e.g. "CD".
48    pub fn field(&self) -> &'static str {
49        self.field
50    }
51
52    /// Why the value is invalid.
53    pub fn msg(&self) -> &str {
54        &self.msg
55    }
56
57    /// The value as stored in the file.
58    pub fn value(&self) -> &T {
59        &self.value
60    }
61
62    /// Takes the value as stored in the file.
63    pub fn into_value(self) -> T {
64        self.value
65    }
66}
67
68/// Language code of the subtitle list (LC), Tech 3264 Appendix 3.
69///
70/// # Example
71///
72/// ```rust
73/// use ebustl_parser::LanguageCode;
74///
75/// assert_eq!(Some("Swedish"), LanguageCode(0x28).name());
76/// // Reserved for national assignment
77/// assert_eq!(None, LanguageCode(0x30).name());
78/// ```
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct LanguageCode(pub u8);
81
82impl LanguageCode {
83    /// The language as named in the spec, or `None` for a code that is unassigned or reserved
84    /// for national assignment.
85    pub fn name(self) -> Option<&'static str> {
86        LATIN_LANGUAGES
87            .get(usize::from(self.0))
88            .copied()
89            .or_else(|| {
90                let index = usize::from(self.0).checked_sub(FIRST_OTHER_LANGUAGE)?;
91                OTHER_LANGUAGES.get(index).copied()
92            })
93    }
94}
95
96/// European languages written in Latin-based alphabets, codes 00h-2Bh
97const LATIN_LANGUAGES: [&str; 0x2C] = [
98    "Unknown/not applicable",
99    "Albanian",
100    "Breton",
101    "Catalan",
102    "Croatian",
103    "Welsh",
104    "Czech",
105    "Danish",
106    "German",
107    "English",
108    "Spanish",
109    "Esperanto",
110    "Estonian",
111    "Basque",
112    "Faroese",
113    "French",
114    "Frisian",
115    "Irish",
116    "Gaelic",
117    "Galician",
118    "Icelandic",
119    "Italian",
120    "Lappish",
121    "Latin",
122    "Latvian",
123    "Luxembourgian",
124    "Lithuanian",
125    "Hungarian",
126    "Maltese",
127    "Dutch",
128    "Norwegian",
129    "Occitan",
130    "Polish",
131    "Portugese",
132    "Romanian",
133    "Romansh",
134    "Serbian",
135    "Slovak",
136    "Slovenian",
137    "Finnish",
138    "Swedish",
139    "Turkish",
140    "Flemish",
141    "Wallon",
142];
143
144/// Code of the first entry in [`OTHER_LANGUAGES`]
145const FIRST_OTHER_LANGUAGE: usize = 0x45;
146
147/// Other languages, codes 45h-7Fh. The spec lists them from 7Fh downwards.
148const OTHER_LANGUAGES: [&str; 0x80 - FIRST_OTHER_LANGUAGE] = [
149    "Zulu",
150    "Vietnamese",
151    "Uzbek",
152    "Urdu",
153    "Ukrainian",
154    "Thai",
155    "Telugu",
156    "Tatar",
157    "Tamil",
158    "Tadzhik",
159    "Swahili",
160    "Sranan Tongo",
161    "Somali",
162    "Sinhalese",
163    "Shona",
164    "Serbo-croat",
165    "Ruthenian",
166    "Russian",
167    "Quechua",
168    "Pushtu",
169    "Punjabi",
170    "Persian",
171    "Papamiento",
172    "Oriya",
173    "Nepali",
174    "Ndebele",
175    "Marathi",
176    "Moldavian",
177    "Malaysian",
178    "Malagasay",
179    "Macedonian",
180    "Laotian",
181    "Korean",
182    "Khmer",
183    "Kazakh",
184    "Kannada",
185    "Japanese",
186    "Indonesian",
187    "Hindi",
188    "Hebrew",
189    "Hausa",
190    "Gurani",
191    "Gujurati",
192    "Greek",
193    "Georgian",
194    "Fulani",
195    "Dari",
196    "Churash",
197    "Chinese",
198    "Burmese",
199    "Bulgarian",
200    "Bengali",
201    "Bielorussian",
202    "Bambora",
203    "Azerbaijani",
204    "Assamese",
205    "Armenian",
206    "Arabic",
207    "Amharic",
208];
209
210/// A date in the GSI block (CD, RD), stored as YYMMDD.
211///
212/// Two-digit years 80-99 are 1980-1999 and 00-79 are 2000-2079.
213///
214/// # Example
215///
216/// ```rust
217/// use ebustl_parser::Date;
218///
219/// let date = Date { year: 2013, month: 10, day: 10 };
220/// assert_eq!(10, date.month);
221/// ```
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct Date {
224    /// The year with four digits, e.g. 1991
225    pub year: u16,
226    /// The month, 1-12
227    pub month: u8,
228    /// The day of the month, 1-31
229    pub day: u8,
230}
231
232/// Two-digit years from this one are in the 20th century
233const FIRST_YEAR_IN_1900S: u16 = 80;
234
235/// Parses a field of decimal digits only, e.g. no sign.
236pub(crate) fn parse_digits(value: &str) -> Option<u32> {
237    if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
238        return None;
239    }
240    value.parse().ok()
241}
242
243/// Returns `None` for a blank field, i.e. only spaces, which is how unused GSI bytes are set.
244fn non_blank(value: &str) -> Option<&str> {
245    if value.chars().all(|c| c == ' ') {
246        None
247    } else {
248        Some(value)
249    }
250}
251
252/// Parses a blank field as `Ok(None)`, and other values with `parse`, which returns why the
253/// value is invalid.
254pub(crate) fn parse_field<'a, T>(
255    field: &'static str,
256    value: &'a str,
257    parse: impl FnOnce(&'a str) -> Result<T, String>,
258) -> Result<Option<T>, InvalidFieldValue<String>> {
259    match non_blank(value) {
260        None => Ok(None),
261        Some(value) => parse(value)
262            .map(Some)
263            .map_err(|msg| InvalidFieldValue::new(field, msg, value.to_string())),
264    }
265}
266
267pub(crate) fn parse_language_code(value: &str) -> Result<LanguageCode, String> {
268    if value.len() != 2 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
269        return Err(format!("{value:?} is not two hexadecimal digits"));
270    }
271    let code = u8::from_str_radix(value, 16).map_err(|err| err.to_string())?;
272    // Appendix 3 defines codes 00h-7Fh
273    if code >= 0x80 {
274        return Err(format!("{code:02X}h is outside 00h-7Fh"));
275    }
276    Ok(LanguageCode(code))
277}
278
279pub(crate) fn parse_date(value: &str) -> Result<Date, String> {
280    let date = two_digit_date(value)
281        .ok_or_else(|| format!("{value:?} is not a date in the format YYMMDD"))?;
282    if !date_exists(date) {
283        return Err(format!("{value:?} is not an existing date"));
284    }
285    Ok(date)
286}
287
288/// Splits a YYMMDD value into a date, without checking that it exists.
289fn two_digit_date(value: &str) -> Option<Date> {
290    // ASCII only, so that slicing by byte index cannot split a character
291    if value.len() != 6 || !value.is_ascii() {
292        return None;
293    }
294    let two_digit_year = u16::try_from(parse_digits(&value[0..2])?).ok()?;
295    let month = u8::try_from(parse_digits(&value[2..4])?).ok()?;
296    let day = u8::try_from(parse_digits(&value[4..6])?).ok()?;
297    let year = if two_digit_year >= FIRST_YEAR_IN_1900S {
298        1900 + two_digit_year
299    } else {
300        2000 + two_digit_year
301    };
302    Some(Date { year, month, day })
303}
304
305fn date_exists(date: Date) -> bool {
306    let (Ok(year), Ok(month), Ok(day)) = (
307        i16::try_from(date.year),
308        i8::try_from(date.month),
309        i8::try_from(date.day),
310    ) else {
311        return false;
312    };
313    jiff::civil::Date::new(year, month, day).is_ok()
314}
315
316pub(crate) fn parse_revision_number(value: &str) -> Result<u8, String> {
317    parse_digits(value.trim_matches(' '))
318        .and_then(|number| u8::try_from(number).ok())
319        .ok_or_else(|| format!("{value:?} is not a number 0-99"))
320}
321
322/// Parses a time code stored as HHMMSSFF. Hours may exceed 23, e.g. for a programme that
323/// continues past midnight.
324pub(crate) fn parse_time_code(value: &str, fps: usize) -> Result<Time, String> {
325    let time = split_time_code(value)
326        .ok_or_else(|| format!("{value:?} is not a time code in the format HHMMSSFF"))?;
327    match time.range_error(fps, false) {
328        Some(msg) => Err(msg),
329        None => Ok(time),
330    }
331}
332
333/// Splits a HHMMSSFF value into a time, without checking the ranges.
334fn split_time_code(value: &str) -> Option<Time> {
335    // ASCII only, so that slicing by byte index cannot split a character
336    if value.len() != 8 || !value.is_ascii() {
337        return None;
338    }
339    let element = |range| u8::try_from(parse_digits(&value[range])?).ok();
340    Some(Time {
341        hours: element(0..2)?,
342        minutes: element(2..4)?,
343        seconds: element(4..6)?,
344        frames: element(6..8)?,
345    })
346}
347
348/// Checks a Time Code In or Out against the ranges in the spec, see [`Time::is_valid`].
349pub(crate) fn checked_time_code(
350    field: &'static str,
351    time: &Time,
352    dfc: &DiskFormatCode,
353) -> Result<Time, InvalidFieldValue<Time>> {
354    match time.range_error(dfc.fps(), true) {
355        Some(msg) => Err(InvalidFieldValue::new(field, msg, *time)),
356        None => Ok(*time),
357    }
358}
359
360pub(crate) fn parse_country_code(value: &str) -> Result<&str, String> {
361    if value.len() == 3 && value.bytes().all(|b| b.is_ascii_uppercase()) {
362        Ok(value)
363    } else {
364        Err(format!("{value:?} is not three upper case letters"))
365    }
366}
367
368/// Free text without the trailing space padding, `None` if the field is blank.
369pub(crate) fn text(value: &str) -> Option<&str> {
370    non_blank(value).map(|value| value.trim_end_matches(' '))
371}
372
373/// Justification Code (JC), the horizontal alignment of a subtitle.
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub enum Justification {
376    /// Positioned by the spaces and control codes in the Text Field
377    Unchanged,
378    /// Left-justified text
379    Left,
380    /// Centred text
381    Centered,
382    /// Right-justified text
383    Right,
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::{GsiBlock, parse_stl_from_file};
390
391    fn gsi() -> GsiBlock {
392        parse_stl_from_file("stls/test.stl").unwrap().gsi
393    }
394
395    /// Asserts that `result` is an error for `field` that carries `value`
396    fn assert_invalid<T: std::fmt::Debug, V: std::fmt::Debug + PartialEq>(
397        result: Result<T, InvalidFieldValue<V>>,
398        field: &str,
399        value: V,
400    ) {
401        let err = result.expect_err("invalid value");
402        assert_eq!(field, err.field());
403        assert_eq!(&value, err.value());
404        assert!(!err.msg().is_empty());
405    }
406
407    fn time(hours: u8, minutes: u8, seconds: u8, frames: u8) -> Time {
408        Time {
409            hours,
410            minutes,
411            seconds,
412            frames,
413        }
414    }
415
416    #[test]
417    fn language_names() {
418        assert_eq!(Some("Unknown/not applicable"), LanguageCode(0x00).name());
419        assert_eq!(Some("French"), LanguageCode(0x0F).name());
420        assert_eq!(Some("Wallon"), LanguageCode(0x2B).name());
421        assert_eq!(Some("Zulu"), LanguageCode(0x45).name());
422        assert_eq!(Some("Russian"), LanguageCode(0x56).name());
423        assert_eq!(Some("Amharic"), LanguageCode(0x7F).name());
424    }
425
426    #[test]
427    fn unassigned_language_codes_have_no_name() {
428        for code in [0x2C, 0x2F, 0x3F, 0x40, 0x44, 0x80, 0xFF] {
429            assert_eq!(None, LanguageCode(code).name(), "code {code:02X}");
430        }
431    }
432
433    #[test]
434    fn language_code() {
435        let mut gsi = gsi();
436        gsi.lc = "0F".to_string();
437        assert_eq!(Ok(Some(LanguageCode(0x0F))), gsi.language());
438        gsi.lc = "7f".to_string();
439        assert_eq!(Ok(Some(LanguageCode(0x7F))), gsi.language());
440        gsi.lc = "  ".to_string();
441        assert_eq!(Ok(None), gsi.language());
442        for lc in ["80", "+F", "F ", "XY", "\0\0"] {
443            gsi.lc = lc.to_string();
444            assert_invalid(gsi.language(), "LC", lc.to_string());
445        }
446    }
447
448    #[test]
449    fn dates() {
450        let mut gsi = gsi();
451        gsi.cd = "910215".to_string();
452        let date = Date {
453            year: 1991,
454            month: 2,
455            day: 15,
456        };
457        assert_eq!(Ok(Some(date)), gsi.creation_date());
458        gsi.rd = "240229".to_string();
459        let date = Date {
460            year: 2024,
461            month: 2,
462            day: 29,
463        };
464        assert_eq!(Ok(Some(date)), gsi.revision_date());
465        gsi.rd = "      ".to_string();
466        assert_eq!(Ok(None), gsi.revision_date());
467    }
468
469    #[test]
470    fn century_of_two_digit_year() {
471        let mut gsi = gsi();
472        gsi.cd = "790101".to_string();
473        assert_eq!(2079, gsi.creation_date().unwrap().unwrap().year);
474        gsi.cd = "800101".to_string();
475        assert_eq!(1980, gsi.creation_date().unwrap().unwrap().year);
476    }
477
478    #[test]
479    fn invalid_dates() {
480        let mut gsi = gsi();
481        for cd in [
482            "911341",
483            "910230",
484            "230229",
485            "9102  ",
486            "91-2-1",
487            "\0\0\0\0\0\0",
488            // Six bytes, where slicing by byte index would split 'é'
489            "1é234",
490        ] {
491            gsi.cd = cd.to_string();
492            assert_invalid(gsi.creation_date(), "CD", cd.to_string());
493        }
494    }
495
496    #[test]
497    fn revision_number() {
498        let mut gsi = gsi();
499        gsi.rn = "07".to_string();
500        assert_eq!(Ok(Some(7)), gsi.revision_number());
501        gsi.rn = " 7".to_string();
502        assert_eq!(Ok(Some(7)), gsi.revision_number());
503        for rn in ["-1", "+1", "x1"] {
504            gsi.rn = rn.to_string();
505            assert_invalid(gsi.revision_number(), "RN", rn.to_string());
506        }
507    }
508
509    #[test]
510    fn time_codes() {
511        let mut gsi = gsi();
512        gsi.tcp = "23595924".to_string();
513        assert_eq!(
514            Ok(Some(time(23, 59, 59, 24))),
515            gsi.time_code_start_of_programme()
516        );
517        gsi.tcf = "        ".to_string();
518        assert_eq!(Ok(None), gsi.time_code_first_in_cue());
519        for tcp in [
520            "00600000", "00006000", "00000025", "1000000", "10:00:00", "1é00000",
521        ] {
522            gsi.tcp = tcp.to_string();
523            assert_invalid(gsi.time_code_start_of_programme(), "TCP", tcp.to_string());
524        }
525    }
526
527    #[test]
528    fn start_of_programme_and_first_in_cue_allow_hours_past_midnight() {
529        let mut gsi = gsi();
530        gsi.tcp = "24000000".to_string();
531        assert_eq!(
532            Ok(Some(time(24, 0, 0, 0))),
533            gsi.time_code_start_of_programme()
534        );
535        gsi.tcf = "99595924".to_string();
536        assert_eq!(Ok(Some(time(99, 59, 59, 24))), gsi.time_code_first_in_cue());
537    }
538
539    #[test]
540    fn time_code_in_and_out() {
541        let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
542        let tti = &mut stl.ttis[0];
543        tti.tci = time(23, 59, 59, 24);
544        tti.tco = time(0, 0, 0, 0);
545        assert_eq!(Ok(time(23, 59, 59, 24)), tti.time_code_in(&stl.gsi.dfc));
546        assert_eq!(Ok(time(0, 0, 0, 0)), tti.time_code_out(&stl.gsi.dfc));
547    }
548
549    #[test]
550    fn time_code_in_and_out_outside_spec_are_errors() {
551        let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
552        let dfc = DiskFormatCode::STL25_01;
553        let tti = &mut stl.ttis[0];
554        for (tci, msg) in [
555            (time(24, 0, 0, 0), "24:00:00:00 has hours 24 above 23"),
556            (time(0, 60, 0, 0), "00:60:00:00 has minutes 60 above 59"),
557            (time(0, 0, 60, 0), "00:00:60:00 has seconds 60 above 59"),
558            (
559                time(0, 0, 0, 25),
560                "00:00:00:25 has frame 25 above 24 at 25 frames per second",
561            ),
562        ] {
563            tti.tci = tci;
564            let err = tti.time_code_in(&dfc).unwrap_err();
565            assert_eq!(("TCI", msg, &tci), (err.field(), err.msg(), err.value()));
566        }
567        tti.tco = time(0, 0, 0, 25);
568        assert_invalid(tti.time_code_out(&dfc), "TCO", time(0, 0, 0, 25));
569        // Frames up to 29 at 30 frames per second
570        assert!(tti.time_code_out(&DiskFormatCode::STL30_01).is_ok());
571    }
572
573    #[test]
574    fn time_code_frames_depend_on_disk_format_code() {
575        let mut gsi = gsi();
576        gsi.tcf = "00000029".to_string();
577        assert!(gsi.time_code_first_in_cue().is_err());
578        gsi.dfc = DiskFormatCode::STL30_01;
579        assert_eq!(Ok(Some(time(0, 0, 0, 29))), gsi.time_code_first_in_cue());
580        gsi.tcf = "00000030".to_string();
581        assert!(gsi.time_code_first_in_cue().is_err());
582    }
583
584    #[test]
585    fn country_of_origin() {
586        let mut gsi = gsi();
587        gsi.co = "   ".to_string();
588        assert_eq!(Ok(None), gsi.country_of_origin());
589        for co in ["fra", "FR ", "F1A"] {
590            gsi.co = co.to_string();
591            assert_invalid(gsi.country_of_origin(), "CO", co.to_string());
592        }
593    }
594
595    #[test]
596    fn free_text_keeps_leading_spaces() {
597        let mut gsi = gsi();
598        gsi.opt = "  Title with leading spaces     ".to_string();
599        assert_eq!(
600            Some("  Title with leading spaces"),
601            gsi.original_programme_title()
602        );
603    }
604
605    #[test]
606    fn justification_code() {
607        let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
608        let tti = &mut stl.ttis[0];
609        for (jc, expected) in [
610            (0, Justification::Unchanged),
611            (1, Justification::Left),
612            (2, Justification::Centered),
613            (3, Justification::Right),
614        ] {
615            tti.jc = jc;
616            assert_eq!(Ok(expected), tti.justification());
617        }
618        tti.jc = 0x20;
619        assert_invalid(tti.justification(), "JC", 0x20);
620    }
621
622    #[test]
623    fn comment_flag() {
624        let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
625        let tti = &mut stl.ttis[0];
626        tti.cf = 1;
627        assert_eq!(Ok(true), tti.comment_flag());
628        tti.cf = 2;
629        assert_invalid(tti.comment_flag(), "CF", 2);
630    }
631
632    #[test]
633    fn error_messages() {
634        let mut gsi = gsi();
635        gsi.cd = "911341".to_string();
636        assert_eq!(
637            "Invalid CD: \"911341\" is not an existing date",
638            gsi.creation_date().unwrap_err().to_string()
639        );
640        gsi.cd = "91-2-1".to_string();
641        assert_eq!(
642            "Invalid CD: \"91-2-1\" is not a date in the format YYMMDD",
643            gsi.creation_date().unwrap_err().to_string()
644        );
645        gsi.lc = "80".to_string();
646        assert_eq!(
647            "Invalid LC: 80h is outside 00h-7Fh",
648            gsi.language().unwrap_err().to_string()
649        );
650        gsi.tcp = "00000025".to_string();
651        assert_eq!(
652            "Invalid TCP: 00:00:00:25 has frame 25 above 24 at 25 frames per second",
653            gsi.time_code_start_of_programme().unwrap_err().to_string()
654        );
655    }
656
657    #[test]
658    fn invalid_value_can_be_taken() {
659        let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
660        let tti = &mut stl.ttis[0];
661        tti.tci = time(10, 2, 31, 25);
662
663        let tci = tti
664            .time_code_in(&stl.gsi.dfc)
665            .unwrap_or_else(|err| err.into_value());
666        assert_eq!(time(10, 2, 31, 25), tci);
667    }
668
669    #[test]
670    fn invalid_value_converts_into_parse_error() {
671        let mut gsi = gsi();
672        gsi.co = "fra".to_string();
673
674        let err = gsi.country_of_origin().unwrap_err();
675        let msg = err.to_string();
676        assert!(matches!(
677            crate::ParseError::from(err),
678            crate::ParseError::InvalidFieldValue(converted) if converted == msg
679        ));
680    }
681}