1use thiserror::Error;
12
13use crate::{DiskFormatCode, Time};
14
15#[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 pub fn field(&self) -> &'static str {
49 self.field
50 }
51
52 pub fn msg(&self) -> &str {
54 &self.msg
55 }
56
57 pub fn value(&self) -> &T {
59 &self.value
60 }
61
62 pub fn into_value(self) -> T {
64 self.value
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct LanguageCode(pub u8);
81
82impl LanguageCode {
83 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
96const 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
144const FIRST_OTHER_LANGUAGE: usize = 0x45;
146
147const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct Date {
224 pub year: u16,
226 pub month: u8,
228 pub day: u8,
230}
231
232const FIRST_YEAR_IN_1900S: u16 = 80;
234
235pub(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
243fn non_blank(value: &str) -> Option<&str> {
245 if value.chars().all(|c| c == ' ') {
246 None
247 } else {
248 Some(value)
249 }
250}
251
252pub(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 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
288fn two_digit_date(value: &str) -> Option<Date> {
290 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
322pub(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
333fn split_time_code(value: &str) -> Option<Time> {
335 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
348pub(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
368pub(crate) fn text(value: &str) -> Option<&str> {
370 non_blank(value).map(|value| value.trim_end_matches(' '))
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub enum Justification {
376 Unchanged,
378 Left,
380 Centered,
382 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 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 "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 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}