1use std::cmp::Reverse;
19use std::collections::{BTreeSet, BinaryHeap};
20
21use crate::{
22 CaptionCue, MAX_CLIPS_PER_TRACK, MAX_PROJECT_JSON_BYTES, MAX_RUN_CLASSES,
23 MAX_TEXT_CONTENT_BYTES, MAX_TEXT_RUNS, MAX_TRACKS, RunTime, TextContent, TextRun,
24 valid_class_name,
25};
26use alpha2_time::{MediaTime, TICKS_PER_SECOND, TimeRange};
27
28pub const MAX_CAPTION_FILE_BYTES: usize = MAX_PROJECT_JSON_BYTES;
31
32pub const MAX_CAPTION_FILE_CUES: usize = 65_536;
35
36#[derive(Clone, Debug, Default, PartialEq)]
38pub struct ImportedCaptions {
39 pub cues: Vec<CaptionCue>,
41 pub skipped: usize,
43 pub notes: ImportNotes,
45}
46
47#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
49pub struct ImportNotes {
50 pub extra_voices: usize,
53 pub dropped_timestamps: usize,
55 pub dropped_classes: usize,
58 pub flattened: usize,
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64#[non_exhaustive]
65pub enum CaptionImportError {
66 MissingWebVttHeader,
68 FileTooLarge {
70 bytes: usize,
72 maximum: usize,
74 },
75 TooManyCues {
77 maximum: usize,
79 },
80 TooManyRows {
83 rows: usize,
85 maximum: usize,
87 },
88}
89
90impl std::fmt::Display for CaptionImportError {
91 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 match self {
93 Self::MissingWebVttHeader => formatter.write_str("a WebVTT file begins with WEBVTT"),
94 Self::FileTooLarge { bytes, maximum } => write!(
95 formatter,
96 "the subtitle file is {bytes} bytes; at most {maximum} are read"
97 ),
98 Self::TooManyCues { maximum } => write!(
99 formatter,
100 "the subtitle file holds more than {maximum} cues"
101 ),
102 Self::TooManyRows { rows, maximum } => write!(
103 formatter,
104 "the captions need {rows} caption tracks; a project holds at most {maximum} tracks"
105 ),
106 }
107 }
108}
109
110impl std::error::Error for CaptionImportError {}
111
112const TICKS_PER_MILLISECOND: i64 = TICKS_PER_SECOND / 1_000;
113
114pub fn parse_srt(text: &str) -> Result<ImportedCaptions, CaptionImportError> {
120 check_file_size(text)?;
121 let mut imported = ImportedCaptions::default();
122 for block in blocks(text) {
123 let mut lines = block.iter().copied();
124 let Some(mut first) = lines.next() else {
125 continue;
126 };
127 if !first.contains("-->") {
129 match lines.next() {
130 Some(line) if first.trim().chars().all(|c| c.is_ascii_digit()) => first = line,
131 _ => {
132 imported.skipped += 1;
133 continue;
134 }
135 }
136 }
137 let body = lines.collect::<Vec<_>>();
138 let text = body
139 .iter()
140 .map(|line| strip_srt_markup(line))
141 .collect::<Vec<_>>()
142 .join("\n");
143 let range = timing(first);
144 let marked = range.map(|_| srt_markup(&body));
145 imported.read(range, &text, marked)?;
146 }
147 imported.cues.sort_by_key(|cue| cue.timeline_range.start());
148 Ok(imported)
149}
150
151pub fn parse_webvtt(text: &str) -> Result<ImportedCaptions, CaptionImportError> {
158 check_file_size(text)?;
159 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
160 let header_ok = text
161 .strip_prefix("WEBVTT")
162 .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t', '\n', '\r']));
163 if !header_ok {
164 return Err(CaptionImportError::MissingWebVttHeader);
165 }
166 let mut imported = ImportedCaptions::default();
167 let mut blocks = blocks(text).into_iter();
168 let header = blocks.next().unwrap_or_default();
171 let cue_under_header = header
172 .iter()
173 .position(|line| line.contains("-->"))
174 .map(|start| header[start..].to_vec());
175 for block in cue_under_header.into_iter().chain(blocks) {
176 let first = block[0];
177 if ["NOTE", "STYLE", "REGION"].iter().any(|keyword| {
178 first
179 .strip_prefix(keyword)
180 .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t']))
181 }) {
182 continue;
183 }
184 let (timing_line, body) = if first.contains("-->") {
186 (first, &block[1..])
187 } else if block.len() > 1 && block[1].contains("-->") {
188 (block[1], &block[2..])
189 } else {
190 imported.skipped += 1;
191 continue;
192 };
193 let text = body
194 .iter()
195 .map(|line| decode_references(&strip_tags(line)))
196 .collect::<Vec<_>>()
197 .join("\n");
198 let range = timing(timing_line);
199 let marked = range.map(|range| vtt_markup(&body.join("\n"), range));
200 imported.read(range, &text, marked)?;
201 }
202 imported.cues.sort_by_key(|cue| cue.timeline_range.start());
203 Ok(imported)
204}
205
206impl ImportedCaptions {
207 fn read(
210 &mut self,
211 range: Option<TimeRange>,
212 text: &str,
213 marked: Option<Marked>,
214 ) -> Result<(), CaptionImportError> {
215 match (range, cue_text(text)) {
216 (Some(range), Some(text)) => {
217 if self.cues.len() == MAX_CAPTION_FILE_CUES {
218 return Err(CaptionImportError::TooManyCues {
219 maximum: MAX_CAPTION_FILE_CUES,
220 });
221 }
222 let mut cue = CaptionCue::new(range, text);
223 if let Some(marked) = marked {
224 let notes = &mut self.notes;
225 notes.extra_voices += marked.notes.extra_voices;
226 notes.dropped_timestamps += marked.notes.dropped_timestamps;
227 notes.dropped_classes += marked.notes.dropped_classes;
228 cue.speaker = marked.speaker;
229 match marked.runs {
230 Some(runs) if runs.len() > MAX_TEXT_RUNS => notes.flattened += 1,
231 Some(runs) => cue.text = TextContent::Runs(runs),
232 None => {}
233 }
234 }
235 self.cues.push(cue);
236 }
237 _ => self.skipped += 1,
238 }
239 Ok(())
240 }
241}
242
243fn check_file_size(text: &str) -> Result<(), CaptionImportError> {
244 if text.len() > MAX_CAPTION_FILE_BYTES {
245 return Err(CaptionImportError::FileTooLarge {
246 bytes: text.len(),
247 maximum: MAX_CAPTION_FILE_BYTES,
248 });
249 }
250 Ok(())
251}
252
253pub fn caption_rows(cues: &[CaptionCue]) -> Result<Vec<Vec<CaptionCue>>, CaptionImportError> {
261 let mut sorted = cues.to_vec();
262 sorted.sort_by_key(|cue| cue.timeline_range.start());
263 let mut rows: Vec<Vec<CaptionCue>> = Vec::new();
264 let mut ended = BTreeSet::<usize>::new();
267 let mut ending = BinaryHeap::<Reverse<(MediaTime, usize)>>::new();
268 for cue in sorted {
269 let start = cue.timeline_range.start();
270 while let Some(&Reverse((end, row))) = ending.peek()
271 && end <= start
272 {
273 ending.pop();
274 ended.insert(row);
275 }
276 let row = ended.pop_first().unwrap_or_else(|| {
277 rows.push(Vec::new());
278 rows.len() - 1
279 });
280 let end = cue.timeline_range.end();
281 rows[row].push(cue);
282 if rows[row].len() < MAX_CLIPS_PER_TRACK {
283 ending.push(Reverse((end, row)));
284 }
285 }
286 if rows.len() > MAX_TRACKS {
287 return Err(CaptionImportError::TooManyRows {
288 rows: rows.len(),
289 maximum: MAX_TRACKS,
290 });
291 }
292 Ok(rows)
293}
294
295fn blocks(text: &str) -> Vec<Vec<&str>> {
297 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
298 let mut blocks = Vec::new();
299 let mut current = Vec::new();
300 for line in lines(text) {
301 if line.trim().is_empty() {
302 if !current.is_empty() {
303 blocks.push(std::mem::take(&mut current));
304 }
305 } else {
306 current.push(line);
307 }
308 }
309 if !current.is_empty() {
310 blocks.push(current);
311 }
312 blocks
313}
314
315fn lines(text: &str) -> Vec<&str> {
318 let bytes = text.as_bytes();
319 let mut lines = Vec::new();
320 let (mut start, mut at) = (0, 0);
321 while at < bytes.len() {
322 match bytes[at] {
323 b'\n' => {
324 lines.push(&text[start..at]);
325 at += 1;
326 start = at;
327 }
328 b'\r' => {
329 let returns = bytes[at..]
330 .iter()
331 .take_while(|&&byte| byte == b'\r')
332 .count();
333 lines.push(&text[start..at]);
334 at += returns;
335 if bytes.get(at) == Some(&b'\n') {
336 at += 1;
337 } else {
338 lines.extend(std::iter::repeat_n("", returns - 1));
339 }
340 start = at;
341 }
342 _ => at += 1,
343 }
344 }
345 lines.push(&text[start..]);
346 lines
347}
348
349fn timing(line: &str) -> Option<TimeRange> {
351 let (start, rest) = line.split_once("-->")?;
352 let end = rest.split_whitespace().next()?;
353 let (start, end) = (timestamp(start.trim())?, timestamp(end)?);
354 TimeRange::new(start, end)
355 .ok()
356 .filter(|range| !range.is_empty())
357}
358
359fn timestamp(text: &str) -> Option<MediaTime> {
362 let (clock, millis) = text.split_once([',', '.'])?;
363 if millis.len() != 3 || !millis.bytes().all(|byte| byte.is_ascii_digit()) {
364 return None;
365 }
366 let fields = clock.split(':').collect::<Vec<_>>();
367 let number = |field: &str| {
368 (!field.is_empty() && field.bytes().all(|byte| byte.is_ascii_digit()))
369 .then(|| field.parse::<i64>().ok())
370 .flatten()
371 };
372 let (hours, minutes, seconds) = match fields.as_slice() {
373 [hours, minutes, seconds] => (number(hours)?, number(minutes)?, number(seconds)?),
374 [minutes, seconds] => (0, number(minutes)?, number(seconds)?),
375 _ => return None,
376 };
377 if minutes > 59 || seconds > 59 {
378 return None;
379 }
380 let milliseconds = ((hours.checked_mul(60)?.checked_add(minutes)?)
381 .checked_mul(60)?
382 .checked_add(seconds)?)
383 .checked_mul(1_000)?
384 .checked_add(millis.parse::<i64>().ok()?)?;
385 let ticks = milliseconds.checked_mul(TICKS_PER_MILLISECOND)?;
386 (ticks <= alpha2_time::MAX_SERIALIZED_TICKS).then(|| MediaTime::from_ticks(ticks))
387}
388
389fn cue_text(text: &str) -> Option<String> {
391 let text = text.trim_matches('\n').to_owned();
392 (!text.trim().is_empty() && text.len() <= MAX_TEXT_CONTENT_BYTES).then_some(text)
393}
394
395fn strip_srt_markup(line: &str) -> String {
397 let mut output = String::with_capacity(line.len());
398 let mut rest = line;
399 while let Some(start) = rest.find("{\\") {
400 output.push_str(&rest[..start]);
401 match rest[start..].find('}') {
402 Some(end) => rest = &rest[start + end + 1..],
403 None => {
404 rest = "";
405 }
406 }
407 }
408 output.push_str(rest);
409 strip_srt_tags(&output)
410}
411
412fn strip_srt_tags(line: &str) -> String {
415 let mut output = String::with_capacity(line.len());
416 let mut rest = line;
417 while let Some(start) = rest.find('<') {
418 output.push_str(&rest[..start]);
419 let tag = &rest[start + 1..];
420 let name = tag.strip_prefix('/').unwrap_or(tag);
421 let end = tag.find(['<', '>']);
423 match end {
424 Some(end)
425 if tag.as_bytes()[end] == b'>'
426 && name.starts_with(|character: char| character.is_ascii_alphabetic()) =>
427 {
428 rest = &tag[end + 1..];
429 }
430 _ => {
431 output.push('<');
432 rest = tag;
433 }
434 }
435 }
436 output.push_str(rest);
437 output
438}
439
440fn strip_tags(line: &str) -> String {
442 let mut output = String::with_capacity(line.len());
443 let mut depth = false;
444 for character in line.chars() {
445 match character {
446 '<' => depth = true,
447 '>' if depth => depth = false,
448 _ if !depth => output.push(character),
449 _ => {}
450 }
451 }
452 output
453}
454
455fn decode_references(text: &str) -> String {
460 let mut output = String::with_capacity(text.len());
461 let mut rest = text;
462 while let Some(start) = rest.find('&') {
463 output.push_str(&rest[..start]);
464 rest = &rest[start + 1..];
465 let length = rest
466 .bytes()
467 .take_while(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'#' | b'+'))
468 .count();
469 let decoded = (rest.as_bytes().get(length) == Some(&b';'))
470 .then(|| reference(&rest[..length]))
471 .flatten();
472 match decoded {
473 Some(character) => {
474 output.push(character);
475 rest = &rest[length + 1..];
476 }
477 None => output.push('&'),
478 }
479 }
480 output.push_str(rest);
481 output
482}
483
484fn reference(name: &str) -> Option<char> {
486 match name {
487 "amp" => Some('&'),
488 "lt" => Some('<'),
489 "gt" => Some('>'),
490 "nbsp" => Some('\u{a0}'),
491 "lrm" => Some('\u{200e}'),
492 "rlm" => Some('\u{200f}'),
493 _ => name
494 .strip_prefix("#x")
495 .or_else(|| name.strip_prefix("#X"))
496 .and_then(|hex| u32::from_str_radix(hex, 16).ok())
497 .or_else(|| name.strip_prefix('#').and_then(|dec| dec.parse().ok()))
498 .and_then(char::from_u32),
499 }
500}
501
502struct Marked {
505 speaker: Option<String>,
506 runs: Option<Vec<TextRun>>,
507 notes: ImportNotes,
508}
509
510#[derive(Default)]
512struct Runs {
513 pieces: Vec<(String, Vec<String>, usize)>,
514 notes: ImportNotes,
515}
516
517impl Runs {
518 fn push(&mut self, text: &str, classes: &[String], segment: usize) {
519 if text.is_empty() {
520 return;
521 }
522 match self.pieces.last_mut() {
523 Some((held, open, at)) if open.as_slice() == classes && *at == segment => {
524 held.push_str(text);
525 }
526 _ => self
527 .pieces
528 .push((text.to_owned(), classes.to_vec(), segment)),
529 }
530 }
531
532 fn finish(mut self, bounds: &[i64]) -> (Option<Vec<TextRun>>, ImportNotes) {
536 while let Some(first) = self.pieces.first_mut() {
538 let trimmed = first.0.trim_start_matches('\n').len();
539 first.0.drain(..first.0.len() - trimmed);
540 if first.0.is_empty() {
541 self.pieces.remove(0);
542 } else {
543 break;
544 }
545 }
546 while let Some(last) = self.pieces.last_mut() {
547 let kept = last.0.trim_end_matches('\n').len();
548 last.0.truncate(kept);
549 if last.0.is_empty() {
550 self.pieces.pop();
551 } else {
552 break;
553 }
554 }
555 let timed = bounds.len() > 2;
556 if !timed && self.pieces.iter().all(|(_, classes, _)| classes.is_empty()) {
557 return (None, self.notes);
558 }
559 let runs = self
560 .pieces
561 .into_iter()
562 .map(|(text, mut class, segment)| {
563 if class.len() > MAX_RUN_CLASSES {
564 self.notes.dropped_classes += class.len() - MAX_RUN_CLASSES;
565 class.truncate(MAX_RUN_CLASSES);
566 }
567 TextRun {
568 text,
569 class,
570 time: timed.then(|| RunTime {
571 start: MediaTime(bounds[segment]),
572 end: MediaTime(bounds[segment + 1]),
573 }),
574 }
575 })
576 .collect();
577 (Some(runs), self.notes)
578 }
579}
580
581fn tag_classes(names: &str, notes: &mut ImportNotes) -> Vec<String> {
584 names
585 .split('.')
586 .filter(|name| !name.is_empty())
587 .filter_map(|name| {
588 if valid_class_name(name) {
589 Some(name.to_owned())
590 } else {
591 notes.dropped_classes += 1;
592 None
593 }
594 })
595 .collect()
596}
597
598fn vtt_markup(body: &str, range: TimeRange) -> Marked {
601 let start = range.start().ticks();
602 let duration = range.duration().map_or(0, MediaTime::ticks);
603 let mut runs = Runs::default();
604 let mut speaker = None;
605 let mut open: Vec<(String, Vec<String>)> = Vec::new();
607 let mut bounds = vec![0_i64];
608 let mut rest = body;
609 let classes = |open: &[(String, Vec<String>)]| {
610 open.iter()
611 .flat_map(|(_, classes)| classes.iter().cloned())
612 .fold(Vec::<String>::new(), |mut all, class| {
613 if !all.contains(&class) {
614 all.push(class);
615 }
616 all
617 })
618 };
619 loop {
620 let Some(at) = rest.find('<') else {
621 runs.push(&decode_references(rest), &classes(&open), bounds.len() - 1);
622 break;
623 };
624 runs.push(
625 &decode_references(&rest[..at]),
626 &classes(&open),
627 bounds.len() - 1,
628 );
629 let Some(end) = rest[at..].find('>') else {
630 break;
631 };
632 let tag = &rest[at + 1..at + end];
633 rest = &rest[at + end + 1..];
634 if let Some(name) = tag.strip_prefix('/') {
635 let name = name.trim();
636 if let Some(index) = open.iter().rposition(|(open, _)| open == name) {
637 open.truncate(index);
638 }
639 continue;
640 }
641 if tag.starts_with(|character: char| character.is_ascii_digit()) {
642 match timestamp(tag.trim()) {
644 Some(time)
645 if time.ticks() - start > *bounds.last().unwrap_or(&0)
646 && time.ticks() - start < duration =>
647 {
648 bounds.push(time.ticks() - start);
649 }
650 _ => runs.notes.dropped_timestamps += 1,
651 }
652 continue;
653 }
654 let (head, annotation) = tag
656 .split_once([' ', '\t'])
657 .map_or((tag, ""), |(head, annotation)| (head, annotation.trim()));
658 let (name, names) = head.split_once('.').unwrap_or((head, ""));
659 let given = match name {
660 "v" => {
661 if speaker.is_none() && !annotation.is_empty() {
662 speaker = Some(annotation.to_owned());
663 } else if !annotation.is_empty() {
664 runs.notes.extra_voices += 1;
665 }
666 let mut given = vec!["voice".to_owned()];
667 given.extend(tag_classes(names, &mut runs.notes));
668 given
669 }
670 "c" => tag_classes(names, &mut runs.notes),
671 "b" | "i" | "u" => {
672 let mut given = vec![name.to_owned()];
673 given.extend(tag_classes(names, &mut runs.notes));
674 given
675 }
676 _ => Vec::new(),
677 };
678 open.push((name.to_owned(), given));
679 }
680 bounds.push(duration);
681 let (runs, notes) = runs.finish(&bounds);
682 Marked {
683 speaker: speaker.filter(|speaker| speaker.len() <= crate::MAX_CAPTION_NAME_BYTES),
684 runs,
685 notes,
686 }
687}
688
689fn srt_markup(body: &[&str]) -> Marked {
692 let mut runs = Runs::default();
693 let mut open: Vec<String> = Vec::new();
694 for (index, line) in body.iter().enumerate() {
695 if index > 0 {
696 runs.push("\n", &open, 0);
697 }
698 let line = strip_overrides(line);
699 let mut rest = line.as_str();
700 while let Some(start) = rest.find('<') {
701 runs.push(&rest[..start], &open, 0);
702 let tag = &rest[start + 1..];
703 let closing = tag.starts_with('/');
704 let name = tag.strip_prefix('/').unwrap_or(tag);
705 match tag.find(['<', '>']) {
706 Some(end)
707 if tag.as_bytes()[end] == b'>'
708 && name.starts_with(|character: char| character.is_ascii_alphabetic()) =>
709 {
710 let tag_name = name[..name.len().min(end - usize::from(closing))]
711 .split([' ', '\t'])
712 .next()
713 .unwrap_or_default()
714 .to_ascii_lowercase();
715 if matches!(tag_name.as_str(), "b" | "i" | "u") {
716 if closing {
717 if let Some(index) = open.iter().rposition(|open| *open == tag_name) {
718 open.remove(index);
719 }
720 } else if !open.contains(&tag_name) {
721 open.push(tag_name);
722 }
723 }
724 rest = &tag[end + 1..];
725 }
726 _ => {
727 runs.push("<", &open, 0);
728 rest = tag;
729 }
730 }
731 }
732 runs.push(rest, &open, 0);
733 }
734 let (runs, notes) = runs.finish(&[0, 0]);
735 Marked {
736 speaker: None,
737 runs,
738 notes,
739 }
740}
741
742fn strip_overrides(line: &str) -> String {
744 let mut output = String::with_capacity(line.len());
745 let mut rest = line;
746 while let Some(start) = rest.find("{\\") {
747 output.push_str(&rest[..start]);
748 match rest[start..].find('}') {
749 Some(end) => rest = &rest[start + end + 1..],
750 None => {
751 rest = "";
752 }
753 }
754 }
755 output.push_str(rest);
756 output
757}
758
759#[cfg(test)]
760#[path = "caption_import_tests.rs"]
761mod tests;