Skip to main content

alpha2_model/
caption_import.rs

1//! SRT and WebVTT import: subtitle files to caption cues.
2//!
3//! A millisecond is exactly 120 ticks, so import is lossless. Markup that says who speaks or
4//! what a word is becomes the cue's speaker and runs: a WebVTT voice (`<v Name>`) sets the
5//! speaker and gives classes `voice` plus the voice's classes, `<c.a.b>` gives `a` and `b`,
6//! `<b>`, `<i>` and `<u>` (also in SRT) give `b`, `i` and `u`, and WebVTT inline timestamps
7//! split the text into runs timed from the cue's start. Other markup is dropped: SRT's other
8//! tags and `{\...}` overrides, WebVTT's `ruby`, `rt` and `lang` tags (text kept) and cue
9//! settings. WebVTT character references are decoded. A `<` in SRT that opens no tag is
10//! text. Blocks that do not parse are skipped and counted.
11//!
12//! A line ends at LF, CR or CRLF, as WebVTT defines; repeated CRs before an LF (line endings
13//! converted twice) end one line.
14//!
15//! Reading is linear in file size. Files over [`MAX_CAPTION_FILE_BYTES`] or with more than
16//! [`MAX_CAPTION_FILE_CUES`] cues are refused early.
17
18use 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
28/// The largest subtitle file read, in bytes. A cue's JSON is longer than its subtitle block,
29/// so a larger file holds more cues than a project can.
30pub const MAX_CAPTION_FILE_BYTES: usize = MAX_PROJECT_JSON_BYTES;
31
32/// The most cues read from one subtitle file: a day of continuous captions at one cue every
33/// 1.3 s.
34pub const MAX_CAPTION_FILE_CUES: usize = 65_536;
35
36/// Cues read from a subtitle file.
37#[derive(Clone, Debug, Default, PartialEq)]
38pub struct ImportedCaptions {
39    /// Cues in file order, sorted by start time. They may overlap; see [`caption_rows`].
40    pub cues: Vec<CaptionCue>,
41    /// Blocks that were not cues and could not be read as one.
42    pub skipped: usize,
43    /// Markup read but not kept.
44    pub notes: ImportNotes,
45}
46
47/// What an import read but could not keep, counted.
48#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
49pub struct ImportNotes {
50    /// Voices after a cue's first, whose names are not its speaker. Their text still takes the
51    /// class `voice`.
52    pub extra_voices: usize,
53    /// Inline timestamps out of order or outside their cue, which WebVTT renderers ignore.
54    pub dropped_timestamps: usize,
55    /// Class names a run cannot carry (not `A-Z a-z 0-9 _ -`, longer than 64 bytes, or past
56    /// [`MAX_RUN_CLASSES`]).
57    pub dropped_classes: usize,
58    /// Cues with more than [`MAX_TEXT_RUNS`] runs, imported as plain text.
59    pub flattened: usize,
60}
61
62/// Why a file is not a subtitle file at all.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64#[non_exhaustive]
65pub enum CaptionImportError {
66    /// A WebVTT file does not begin with `WEBVTT`.
67    MissingWebVttHeader,
68    /// The file is larger than [`MAX_CAPTION_FILE_BYTES`].
69    FileTooLarge {
70        /// The file's size in bytes.
71        bytes: usize,
72        /// The largest file read.
73        maximum: usize,
74    },
75    /// The file holds more than [`MAX_CAPTION_FILE_CUES`] cues.
76    TooManyCues {
77        /// The most cues read from a file.
78        maximum: usize,
79    },
80    /// The cues need more caption tracks than a project holds: too many overlap at once, or there
81    /// are too many to fit [`MAX_CLIPS_PER_TRACK`] to a track.
82    TooManyRows {
83        /// Caption tracks the cues need.
84        rows: usize,
85        /// Tracks a project holds.
86        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
114/// Reads SubRip (`.srt`) text.
115///
116/// # Errors
117/// Returns [`CaptionImportError::FileTooLarge`] or [`CaptionImportError::TooManyCues`] for a
118/// file past the import limits.
119pub 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        // An optional sequence number before the timing line.
128        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
151/// Reads WebVTT (`.vtt`) text. `NOTE`, `STYLE`, and `REGION` blocks are ignored.
152///
153/// # Errors
154/// Returns [`CaptionImportError::MissingWebVttHeader`] unless the text begins with `WEBVTT`,
155/// and [`CaptionImportError::FileTooLarge`] or [`CaptionImportError::TooManyCues`] for a
156/// file past the import limits.
157pub 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    // The header is the first block up to a timing line; as in the WebVTT parser, a cue may
169    // follow it without a blank line.
170    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        // An optional cue identifier before the timing line.
185        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    /// Keeps a block's cue, or counts it skipped when its timing or text is invalid. `text` is
208    /// the cue text without markup; `marked` supplies runs and speaker when the markup has any.
209    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
253/// Splits cues onto rows, one caption track each. Every cue goes to the first row with room
254/// (a track holds [`MAX_CLIPS_PER_TRACK`]) whose last cue has ended by its start.
255/// O(n log n) in the cues, however many overlap.
256///
257/// # Errors
258/// Returns [`CaptionImportError::TooManyRows`] for cues that need more rows than a project
259/// holds tracks.
260pub 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    // Rows with room, split into those whose last cue has ended by the cue being placed and the
265    // others keyed by when theirs ends. A full row is in neither.
266    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
295/// Blank-line-separated blocks of lines.
296fn 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
315/// The lines of `text`. LF, CR or CRLF ends a line, as does a run of CRs followed by LF. A CR
316/// run without a following LF ends a line per CR.
317fn 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
349/// `start --> end`, ignoring anything after the end time (WebVTT settings).
350fn 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
359/// `[hh:]mm:ss[,.]mmm`, hours any number of digits, up to
360/// [`alpha2_time::MAX_SERIALIZED_TICKS`] (a later cue could not be saved).
361fn 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
389/// Cue text with surrounding blank lines trimmed, or `None` when empty or too long.
390fn 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
395/// SRT text without `<...>` tags or `{\...}` override blocks.
396fn 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
412/// SRT text without its tags: `<`, an optional `/`, a letter, then anything but `<` up to
413/// `>`. Any other `<` is text, since SubRip has no escape for it.
414fn 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        // Each `<` looks ahead only to the next `<` or `>`, keeping a line linear in its length.
422        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
440/// WebVTT text without `<...>` tags: in WebVTT a `<` always opens one.
441fn 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
455/// Decodes WebVTT's named and numeric character references; unknown ones stay as written.
456///
457/// A reference is letters, digits, `#` and `+` closed by `;`, so each `&` looks only that
458/// far and the text is read in linear time.
459fn 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
484/// The character a reference's name, between `&` and `;`, stands for.
485fn 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
502/// What a cue's markup says: its speaker, its runs when any carries a class or a time (else
503/// the plain text stands), and what was dropped.
504struct Marked {
505    speaker: Option<String>,
506    runs: Option<Vec<TextRun>>,
507    notes: ImportNotes,
508}
509
510/// Builds a cue's runs from text pieces, each with its open classes and time segment.
511#[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    /// The runs, timed by `bounds` (each segment's start, then the end, in ticks from the cue's
533    /// start), with surrounding blank lines trimmed as for plain cue text. `None` when no run
534    /// carries a class or a time.
535    fn finish(mut self, bounds: &[i64]) -> (Option<Vec<TextRun>>, ImportNotes) {
536        // Trim leading and trailing line feeds across pieces, as `cue_text` trims the plain text.
537        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
581/// The classes a tag's dot-separated `names` give; ones a run cannot carry are counted and
582/// dropped.
583fn 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
598/// A WebVTT cue body's markup for a cue over `range`: voices, classes, bold, italic and
599/// underline as run classes, and inline timestamps splitting the runs.
600fn 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    // The tags open, each with the classes it gives, innermost last.
606    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            // An inline timestamp: kept only in order and inside the cue.
643            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        // `name.classes annotation`.
655        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
689/// An SRT cue body's markup: `<b>`, `<i>` and `<u>` as run classes; other tags and override
690/// blocks dropped.
691fn 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
742/// SRT text without its `{\...}` override blocks.
743fn 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;