1use std::collections::{BTreeSet, HashMap};
4use std::fmt;
5
6use crate::text_field::TF_UNUSED_SPACE;
7use crate::{DisplayStandardCode, InvalidFieldValue, Stl, Subtitle, TextElement, Time};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ValidationIssue {
21 tti_index: Option<usize>,
22 field: &'static str,
23 msg: String,
24}
25
26impl ValidationIssue {
27 fn gsi(field: &'static str, msg: String) -> Self {
28 Self {
29 tti_index: None,
30 field,
31 msg,
32 }
33 }
34
35 fn tti(tti_index: usize, field: &'static str, msg: String) -> Self {
36 Self {
37 tti_index: Some(tti_index),
38 field,
39 msg,
40 }
41 }
42
43 pub fn tti_index(&self) -> Option<usize> {
45 self.tti_index
46 }
47
48 pub fn field(&self) -> &'static str {
50 self.field
51 }
52
53 pub fn msg(&self) -> &str {
55 &self.msg
56 }
57}
58
59impl fmt::Display for ValidationIssue {
60 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
61 match self.tti_index {
62 None => write!(f, "GSI block, {}: {}", self.field, self.msg),
63 Some(index) => write!(f, "TTI block {index}, {}: {}", self.field, self.msg),
64 }
65 }
66}
67
68impl Stl {
69 pub fn validate(&self) -> Vec<ValidationIssue> {
111 let mut issues = Vec::new();
112 self.check_gsi_fields(&mut issues);
113 self.check_disks(&mut issues);
114 self.check_totals(&mut issues);
115 self.check_first_in_cue(&mut issues);
116 self.check_subtitles(&mut issues);
117 issues
118 }
119
120 fn check_gsi_fields(&self, issues: &mut Vec<ValidationIssue>) {
121 let gsi = &self.gsi;
122 push_error(issues, None, gsi.language());
123 push_error(issues, None, gsi.creation_date());
124 push_error(issues, None, gsi.revision_date());
125 push_error(issues, None, gsi.revision_number());
126 push_error(issues, None, gsi.time_code_start_of_programme());
127 push_error(issues, None, gsi.time_code_first_in_cue());
128 push_error(issues, None, gsi.country_of_origin());
129 }
130
131 fn check_disks(&self, issues: &mut Vec<ValidationIssue>) {
132 let (tnd, dsn) = (self.gsi.tnd, self.gsi.dsn);
133 if !(1..=9).contains(&tnd) {
134 issues.push(ValidationIssue::gsi("TND", format!("{tnd} is not 1-9")));
135 }
136 if dsn == 0 || dsn > tnd {
137 issues.push(ValidationIssue::gsi("DSN", format!("{dsn} is not 1-{tnd}")));
138 }
139 }
140
141 fn check_totals(&self, issues: &mut Vec<ValidationIssue>) {
143 if self.gsi.tnd != 1 {
144 return;
145 }
146 let blocks = self.ttis.len();
147 if usize::try_from(self.gsi.tnb) != Ok(blocks) {
148 let msg = format!("{} but there are {blocks} TTI blocks", self.gsi.tnb);
149 issues.push(ValidationIssue::gsi("TNB", msg));
150 }
151 let subtitles: Vec<Subtitle> = self.subtitles().filter(has_text).collect();
152 if usize::try_from(self.gsi.tns) != Ok(subtitles.len()) {
153 let msg = format!(
154 "{} but there are {} subtitles",
155 self.gsi.tns,
156 subtitles.len()
157 );
158 issues.push(ValidationIssue::gsi("TNS", msg));
159 }
160 let groups: BTreeSet<u8> = subtitles
161 .iter()
162 .map(|subtitle| subtitle.header_block().sgn)
163 .collect();
164 if usize::from(self.gsi.tng) != groups.len() {
165 let msg = format!(
166 "{} but there are {} subtitle groups",
167 self.gsi.tng,
168 groups.len()
169 );
170 issues.push(ValidationIssue::gsi("TNG", msg));
171 }
172 }
173
174 fn check_first_in_cue(&self, issues: &mut Vec<ValidationIssue>) {
175 let Ok(Some(tcf)) = self.gsi.time_code_first_in_cue() else {
176 return;
177 };
178 let first = self
179 .subtitles()
180 .find(|subtitle| has_text(subtitle) && !subtitle.is_comment());
181 if let Some(first) = first {
182 let tci = first.header_block().tci;
183 if tci != tcf {
184 let msg = format!(
185 "{} is not the TCI {} of the first subtitle",
186 tcf.hhmmssff(),
187 tci.hhmmssff()
188 );
189 issues.push(ValidationIssue::gsi("TCF", msg));
190 }
191 }
192 }
193
194 fn check_subtitles(&self, issues: &mut Vec<ValidationIssue>) {
195 let mut first_index = 0;
196 let mut previous_sn = None;
197 let mut group_tci: HashMap<u8, Time> = HashMap::new();
199 for subtitle in self.subtitles() {
200 let sn = subtitle.subtitle_number();
201 if let Some(previous) = previous_sn
202 && u32::from(sn) != u32::from(previous) + 1
203 {
204 let msg = format!("{sn} does not follow {previous}");
205 issues.push(ValidationIssue::tti(first_index, "SN", msg));
206 }
207 previous_sn = Some(sn);
208
209 self.check_blocks(&subtitle, first_index, issues);
210 if has_text(&subtitle) {
211 let header_index = first_index + header_offset(&subtitle);
212 self.check_header(&subtitle, header_index, issues);
213 self.check_text_field(&subtitle, header_index, issues);
214 if !subtitle.is_comment() {
215 check_tci_order(&subtitle, header_index, &mut group_tci, issues);
216 }
217 }
218 first_index += subtitle.blocks().len();
219 }
220 }
221
222 fn check_blocks(
224 &self,
225 subtitle: &Subtitle,
226 first_index: usize,
227 issues: &mut Vec<ValidationIssue>,
228 ) {
229 let mut previous_extension = None;
230 let mut last_text_block = None;
231 for (index, block) in (first_index..).zip(subtitle.blocks()) {
232 let ebn = block.ebn;
233 if (0xF0..=0xFD).contains(&ebn) {
234 let msg = format!("{ebn:02X}h is reserved for future use");
235 issues.push(ValidationIssue::tti(index, "EBN", msg));
236 } else if !block.is_text_block() {
237 continue;
238 } else if !block.is_last_block() {
239 if previous_extension.is_some_and(|previous| ebn <= previous) {
240 let msg = format!("{ebn:02X}h is not in ascending order");
241 issues.push(ValidationIssue::tti(index, "EBN", msg));
242 }
243 previous_extension = Some(ebn);
244 }
245 if block.is_text_block() {
246 last_text_block = Some((index, block));
247 }
248 }
249 let Some((index, block)) = last_text_block else {
250 return;
251 };
252 if !block.is_last_block() {
253 let msg = format!("{:02X}h, the last block of a subtitle has FFh", block.ebn);
254 issues.push(ValidationIssue::tti(index, "EBN", msg));
255 }
256 if block.tf.last() != Some(&TF_UNUSED_SPACE) {
257 let msg = "the last block of a subtitle does not end with unused space (8Fh)";
258 issues.push(ValidationIssue::tti(index, "TF", msg.to_string()));
259 }
260 }
261
262 fn check_header(&self, subtitle: &Subtitle, index: usize, issues: &mut Vec<ValidationIssue>) {
264 let header = subtitle.header_block();
265 let dfc = &self.gsi.dfc;
266 push_error(issues, Some(index), header.justification());
267 push_error(issues, Some(index), header.comment_flag());
268 let tci = header.time_code_in(dfc);
269 let tco = header.time_code_out(dfc);
270 if let (Ok(tci), Ok(tco)) = (&tci, &tco)
271 && tco < tci
272 {
273 let msg = format!("{} is before TCI {}", tco.hhmmssff(), tci.hhmmssff());
274 issues.push(ValidationIssue::tti(index, "TCO", msg));
275 }
276 push_error(issues, Some(index), tci);
277 push_error(issues, Some(index), tco);
278
279 let vp = header.vp;
280 match self.gsi.dsc {
281 DisplayStandardCode::Level1Teletext | DisplayStandardCode::Level2Teletext
282 if !(1..=23).contains(&vp) =>
283 {
284 let msg = format!("{vp} is not a teletext row 1-23");
285 issues.push(ValidationIssue::tti(index, "VP", msg));
286 }
287 DisplayStandardCode::OpenSubtitling if u16::from(vp) > self.gsi.mnr => {
288 let msg = format!("{vp} is above MNR {}", self.gsi.mnr);
289 issues.push(ValidationIssue::tti(index, "VP", msg));
290 }
291 _ => {}
292 }
293 }
294
295 fn check_text_field(
297 &self,
298 subtitle: &Subtitle,
299 index: usize,
300 issues: &mut Vec<ValidationIssue>,
301 ) {
302 let elements = subtitle.text_elements();
303 let dsc = &self.gsi.dsc;
304 let open = *dsc == DisplayStandardCode::OpenSubtitling;
305 let teletext = matches!(
306 dsc,
307 DisplayStandardCode::Level1Teletext | DisplayStandardCode::Level2Teletext
308 );
309 if open
310 && elements
311 .iter()
312 .any(|e| matches!(e, TextElement::Teletext(_)))
313 {
314 let msg = "teletext control codes in an open subtitle".to_string();
315 issues.push(ValidationIssue::tti(index, "TF", msg));
316 }
317 if teletext && elements.iter().any(|e| matches!(e, TextElement::Open(_))) {
318 let msg = "open subtitle control codes in a teletext subtitle".to_string();
319 issues.push(ValidationIssue::tti(index, "TF", msg));
320 }
321 for element in &elements {
322 if let TextElement::Reserved(code) = element {
323 let msg = format!("{code:02X}h is reserved for future use");
324 issues.push(ValidationIssue::tti(index, "TF", msg));
325 }
326 }
327 }
328}
329
330fn check_tci_order(
332 subtitle: &Subtitle,
333 index: usize,
334 group_tci: &mut HashMap<u8, Time>,
335 issues: &mut Vec<ValidationIssue>,
336) {
337 let header = subtitle.header_block();
338 if let Some(previous) = group_tci.get(&header.sgn)
339 && header.tci < *previous
340 {
341 let msg = format!(
342 "{} is before {} of the previous subtitle in the group",
343 header.tci.hhmmssff(),
344 previous.hhmmssff()
345 );
346 issues.push(ValidationIssue::tti(index, "TCI", msg));
347 }
348 group_tci.insert(header.sgn, header.tci);
349}
350
351fn push_error<T, V>(
352 issues: &mut Vec<ValidationIssue>,
353 tti_index: Option<usize>,
354 result: Result<T, InvalidFieldValue<V>>,
355) {
356 if let Err(err) = result {
357 issues.push(ValidationIssue {
358 tti_index,
359 field: err.field(),
360 msg: err.msg().to_string(),
361 });
362 }
363}
364
365fn has_text(subtitle: &Subtitle) -> bool {
367 subtitle.text_blocks().next().is_some()
368}
369
370fn header_offset(subtitle: &Subtitle) -> usize {
372 let header = subtitle.header_block();
373 subtitle
374 .blocks()
375 .iter()
376 .position(|block| std::ptr::eq(block, header))
377 .unwrap_or(0)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::{CumulativeStatus, TtiBlock, TtiFormat};
384
385 fn time(minutes: u8, seconds: u8) -> Time {
386 Time {
387 hours: 10,
388 minutes,
389 seconds,
390 frames: 0,
391 }
392 }
393
394 fn stl(count: u8) -> Stl {
396 let mut stl = Stl::new();
397 for i in 0..count {
398 let format = TtiFormat {
399 jc: 2,
400 vp: 20,
401 dh: true,
402 };
403 let text = format!("Subtitle {i}\nSecond row");
404 stl.add_sub(time(0, 2 * i), time(0, 2 * i + 1), &text, format)
405 .unwrap();
406 }
407 stl
408 }
409
410 fn issues(stl: &Stl) -> Vec<(Option<usize>, &'static str)> {
412 stl.validate()
413 .iter()
414 .map(|issue| (issue.tti_index(), issue.field()))
415 .collect()
416 }
417
418 fn user_data_block(sn: u16) -> TtiBlock {
419 let mut block = TtiBlock::new(
420 sn,
421 time(0, 0),
422 time(0, 0),
423 "user data",
424 TtiFormat {
425 jc: 0,
426 vp: 0,
427 dh: false,
428 },
429 crate::CharacterCodeTable::Latin,
430 );
431 block.ebn = 0xFE;
432 block
433 }
434
435 #[test]
436 fn written_subtitle_list_is_valid() {
437 let mut stl = stl(3);
438 let long_text = "A subtitle long enough to need extension blocks. ".repeat(5);
439 let format = TtiFormat {
440 jc: 1,
441 vp: 1,
442 dh: false,
443 };
444 stl.add_sub(time(1, 0), time(1, 5), &long_text, format)
445 .unwrap();
446
447 assert!(stl.ttis.len() > 4, "extension blocks expected");
448 assert_eq!(Vec::<ValidationIssue>::new(), stl.validate());
449 }
450
451 #[test]
452 fn test_file_is_valid() {
453 let stl = crate::parse_stl_from_file("stls/test.stl").unwrap();
454 assert_eq!(Vec::<ValidationIssue>::new(), stl.validate());
455 }
456
457 #[test]
458 fn invalid_gsi_field() {
459 let mut stl = stl(1);
460 stl.gsi.cd = "911341".to_string();
461 let issues = stl.validate();
462 assert_eq!(1, issues.len());
463 assert_eq!(
464 "GSI block, CD: \"911341\" is not an existing date",
465 issues[0].to_string()
466 );
467 }
468
469 #[test]
470 fn disk_numbers() {
471 let mut stl = stl(1);
472 stl.gsi.tnd = 0;
473 stl.gsi.dsn = 0;
474 assert_eq!(vec![(None, "TND"), (None, "DSN")], issues(&stl));
475
476 stl.gsi.tnd = 2;
477 stl.gsi.dsn = 3;
478 assert_eq!(vec![(None, "DSN")], issues(&stl));
479 }
480
481 #[test]
482 fn totals() {
483 let mut stl = stl(2);
484 stl.gsi.tnb = 3;
485 stl.gsi.tns = 1;
486 stl.gsi.tng = 2;
487 assert_eq!(
488 vec![(None, "TNB"), (None, "TNS"), (None, "TNG")],
489 issues(&stl)
490 );
491 }
492
493 #[test]
494 fn totals_are_not_checked_on_several_disks() {
495 let mut stl = stl(2);
496 stl.gsi.tnb = 1;
497 stl.gsi.tnd = 2;
498 assert!(stl.validate().is_empty());
499 }
500
501 #[test]
502 fn totals_do_not_count_user_data_only_subtitles() {
503 let mut stl = stl(1);
504 stl.ttis.push(user_data_block(2));
505 stl.gsi.tnb = 2;
506 assert!(stl.validate().is_empty());
507 }
508
509 #[test]
510 fn subtitle_groups_are_counted() {
511 let mut stl = stl(2);
512 stl.ttis[1].sgn = 1;
513 assert_eq!(vec![(None, "TNG")], issues(&stl));
514 stl.gsi.tng = 2;
515 assert!(stl.validate().is_empty());
516 }
517
518 #[test]
519 fn first_in_cue() {
520 let mut stl = stl(2);
521 stl.gsi.tcf = "10000100".to_string();
522 let issues = stl.validate();
523 assert_eq!(
524 "GSI block, TCF: 10:00:01:00 is not the TCI 10:00:00:00 of the first subtitle",
525 issues[0].to_string()
526 );
527 }
528
529 #[test]
530 fn first_in_cue_skips_comments() {
531 let mut stl = stl(2);
532 stl.ttis[0].cf = 1;
533 stl.gsi.tcf = "10000200".to_string();
534 assert!(stl.validate().is_empty());
535 }
536
537 #[test]
538 fn subtitle_numbers_are_continuous() {
539 let mut stl = stl(3);
540 stl.ttis[2].sn = 5;
541 assert_eq!(vec![(Some(2), "SN")], issues(&stl));
542 }
543
544 #[test]
545 fn time_code_in_is_ascending_within_group() {
546 let mut stl = stl(3);
547 stl.ttis[2].tci = time(0, 1);
548 stl.ttis[2].tco = time(0, 2);
549 assert_eq!(vec![(Some(2), "TCI")], issues(&stl));
550
551 stl.ttis[2].sgn = 1;
553 stl.gsi.tng = 2;
554 assert!(stl.validate().is_empty());
555 }
556
557 #[test]
558 fn time_codes_passing_midnight_are_reported() {
559 let mut stl = stl(2);
560 let late = |seconds| Time {
561 hours: 23,
562 minutes: 59,
563 seconds,
564 frames: 0,
565 };
566 let early = |seconds| Time {
567 hours: 0,
568 minutes: 0,
569 seconds,
570 frames: 0,
571 };
572 stl.ttis[0].tci = late(58);
573 stl.ttis[0].tco = early(1);
574 stl.ttis[1].tci = early(2);
575 stl.ttis[1].tco = early(3);
576 stl.gsi.tcf = "23595800".to_string();
577 assert_eq!(vec![(Some(0), "TCO"), (Some(1), "TCI")], issues(&stl));
578 }
579
580 #[test]
581 fn equal_time_codes_are_allowed() {
582 let mut stl = stl(2);
583 stl.ttis[0].tco = stl.ttis[0].tci;
584 stl.ttis[1].tci = stl.ttis[0].tci;
585 stl.ttis[1].tco = stl.ttis[0].tci;
586 assert!(stl.validate().is_empty());
587 }
588
589 #[test]
590 fn time_code_out_before_in() {
591 let mut stl = stl(1);
592 stl.ttis[0].tco = Time {
593 hours: 9,
594 ..time(0, 0)
595 };
596 let issues = stl.validate();
597 assert_eq!(
598 "TTI block 0, TCO: 09:00:00:00 is before TCI 10:00:00:00",
599 issues[0].to_string()
600 );
601 }
602
603 #[test]
604 fn invalid_header_fields() {
605 let mut stl = stl(1);
606 stl.ttis[0].jc = 4;
607 stl.ttis[0].cf = 2;
608 stl.ttis[0].tci.frames = 25;
609 assert_eq!(
611 vec![
612 (None, "TCF"),
613 (Some(0), "JC"),
614 (Some(0), "CF"),
615 (Some(0), "TCI")
616 ],
617 issues(&stl)
618 );
619 }
620
621 #[test]
622 fn only_the_first_block_carries_header_fields() {
623 let mut stl = Stl::new();
624 let format = TtiFormat {
625 jc: 2,
626 vp: 20,
627 dh: false,
628 };
629 stl.add_sub(time(0, 0), time(0, 1), &"x".repeat(200), format)
630 .unwrap();
631 stl.ttis[1].jc = 0x20;
633 stl.ttis[1].tci.frames = 99;
634 stl.ttis[1].cs = CumulativeStatus::LastInSet;
635 assert!(stl.validate().is_empty());
636 }
637
638 #[test]
639 fn extension_block_numbers() {
640 let mut stl = Stl::new();
641 let format = TtiFormat {
642 jc: 2,
643 vp: 20,
644 dh: false,
645 };
646 stl.add_sub(time(0, 0), time(0, 1), &"x".repeat(300), format)
647 .unwrap();
648 assert_eq!(
649 vec![0x00, 0x01, 0xFF],
650 stl.ttis.iter().map(|b| b.ebn).collect::<Vec<_>>()
651 );
652
653 stl.ttis[1].ebn = 0x00;
654 assert_eq!(vec![(Some(1), "EBN")], issues(&stl));
655
656 stl.ttis[1].ebn = 0xF0;
657 assert_eq!(vec![(Some(1), "EBN")], issues(&stl));
658 }
659
660 #[test]
661 fn subtitle_without_last_block() {
662 let mut stl = stl(1);
663 stl.ttis[0].ebn = 0x00;
664 assert_eq!(vec![(Some(0), "EBN")], issues(&stl));
665 }
666
667 #[test]
668 fn last_block_must_end_with_unused_space() {
669 let mut stl = stl(1);
670 stl.ttis[0].tf = vec![b'x'; crate::text_field::TF_LENGTH];
671 assert_eq!(vec![(Some(0), "TF")], issues(&stl));
672 }
673
674 #[test]
675 fn vertical_position_of_teletext() {
676 let mut stl = stl(2);
677 stl.ttis[0].vp = 0;
678 stl.ttis[1].vp = 24;
679 assert_eq!(vec![(Some(0), "VP"), (Some(1), "VP")], issues(&stl));
680 }
681
682 #[test]
683 fn vertical_position_of_open_subtitles() {
684 let mut stl = open_stl();
685 stl.gsi.mnr = 10;
686 stl.ttis[0].vp = 10;
687 assert!(stl.validate().is_empty());
688 stl.ttis[0].vp = 11;
689 assert_eq!(vec![(Some(0), "VP")], issues(&stl));
690 }
691
692 fn open_stl() -> Stl {
694 let mut stl = Stl::new();
695 stl.gsi.dsc = DisplayStandardCode::OpenSubtitling;
696 let format = TtiFormat {
697 jc: 2,
698 vp: 5,
699 dh: false,
700 };
701 stl.add_sub(time(0, 0), time(0, 1), "Open", format).unwrap();
702 stl
703 }
704
705 #[test]
706 fn control_codes_must_match_display_standard() {
707 let mut stl = open_stl();
708 assert!(stl.validate().is_empty());
709 stl.ttis[0].tf[0] = 0x0D;
711 assert_eq!(vec![(Some(0), "TF")], issues(&stl));
712
713 let mut stl = stl_with_first_code(0x80);
714 assert_eq!(vec![(Some(0), "TF")], issues(&stl));
715 stl.gsi.dsc = DisplayStandardCode::Blank;
716 assert!(stl.validate().is_empty());
717 }
718
719 fn stl_with_first_code(code: u8) -> Stl {
721 let mut stl = stl(1);
722 stl.ttis[0].tf[0] = code;
723 stl
724 }
725
726 #[test]
727 fn reserved_text_field_codes() {
728 let stl = stl_with_first_code(0x90);
729 let issues = stl.validate();
730 assert_eq!(
731 "TTI block 0, TF: 90h is reserved for future use",
732 issues[0].to_string()
733 );
734 }
735}