Skip to main content

servo_webvtt/
collectors.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::iter::Peekable;
6use std::str::Chars;
7use std::sync::LazyLock;
8
9use regex::Regex;
10
11use crate::cue::settings::{
12    WebVttCue, WebVttCueSize, WebVttLineAlignment, WebVttLineAndPositionSetting,
13    WebVttPositionAlignment, WebVttTextAlignment, WebVttWritingDirection,
14};
15
16fn collect_for_closure<F>(position: &mut Peekable<Chars<'_>>, f: F) -> String
17where
18    F: FnOnce(&char) -> bool + Copy,
19{
20    let mut string = String::new();
21    while let Some(next) = position.next_if(f) {
22        string.push(next);
23    }
24    string
25}
26
27pub(crate) fn collect_ascii_digits(position: &mut Peekable<Chars<'_>>) -> String {
28    collect_for_closure(position, char::is_ascii_digit)
29}
30
31pub(crate) fn skip_whitespace(position: &mut Peekable<Chars<'_>>) {
32    collect_for_closure(position, |c| {
33        matches!(c, '\r' | '\n' | '\t' | ' ' | '\u{000C}')
34    });
35}
36
37#[derive(Clone, Copy, Default, PartialEq)]
38enum MostSignificantUnits {
39    #[default]
40    Minutes,
41    Hours,
42}
43
44/// <https://w3c.github.io/webvtt/#collect-a-webvtt-timestamp>
45pub(crate) fn collect_webvtt_timestamp(position: &mut Peekable<Chars<'_>>) -> Option<f64> {
46    // Step 1. Let input and position be the same variables
47    // as those of the same name in the algorithm that invoked these steps.
48    //
49    // Passed in as argument
50
51    // Step 2. Let most significant units be minutes.
52    let mut most_significant_units = MostSignificantUnits::Minutes;
53    // Step 3. If position is past the end of input, return an error and abort these steps.
54    // Step 4. If the character indicated by position is not an ASCII digit,
55    // then return an error and abort these steps.
56    if !position.peek()?.is_ascii_digit() {
57        return None;
58    }
59    // Step 5. Collect a sequence of code points that are ASCII digits,
60    // and let string be the collected substring.
61    let string = collect_ascii_digits(position);
62    // Step 6. Interpret string as a base-ten integer. Let value1 be that integer.
63    let mut value_1 = string.parse::<f64>().ok()?;
64    // Step 7. If string is not exactly two characters in length,
65    // or if value1 is greater than 59, let most significant units be hours.
66    if string.len() != 2 || value_1 > 59_f64 {
67        most_significant_units = MostSignificantUnits::Hours;
68    }
69    // Step 8. If position is beyond the end of input or if the character at position is
70    // not a U+003A COLON character (:), then return an error and abort these steps.
71    // Otherwise, move position forwards one character.
72    let _ = position.next().filter(|c| *c == '\u{003A}')?;
73    // Step 9. Collect a sequence of code points that are ASCII digits,
74    // and let string be the collected substring.
75    let string = collect_ascii_digits(position);
76    // Step 10. If string is not exactly two characters in length,
77    // return an error and abort these steps.
78    if string.len() != 2 {
79        return None;
80    }
81    // Step 11. Interpret string as a base-ten integer. Let value2 be that integer.
82    let mut value_2 = string.parse::<f64>().ok()?;
83    // Step 12. If most significant units is hours,
84    // or if position is not beyond the end of input and the character
85    // at position is a U+003A COLON character (:), run these substeps:
86    let value_3: f64;
87    if most_significant_units == MostSignificantUnits::Hours ||
88        position.peek().is_some_and(|c| *c == '\u{003A}')
89    {
90        // Step 12.1. If position is beyond the end of input or if
91        // the character at position is not a U+003A COLON character (:),
92        // then return an error and abort these steps.
93        // Otherwise, move position forwards one character.
94        position.next().filter(|c| *c == '\u{003A}')?;
95        // Step 12.2. Collect a sequence of code points that are ASCII digits,
96        // and let string be the collected substring.
97        let string = collect_ascii_digits(position);
98        // Step 12.3. If string is not exactly two characters in length,
99        // return an error and abort these steps.
100        if string.len() != 2 {
101            return None;
102        }
103        // Step 12.4. Interpret string as a base-ten integer. Let value3 be that integer.
104        value_3 = string.parse::<f64>().ok()?;
105    } else {
106        // Otherwise (if most significant units is not hours,
107        // and either position is beyond the end of input,
108        // or the character at position is not a U+003A COLON character (:)),
109        // let value3 have the value of value2,
110        // then value2 have the value of value1, then let value1 equal zero.
111        value_3 = value_2;
112        value_2 = value_1;
113        value_1 = 0_f64;
114    }
115    // Step 13. If position is beyond the end of input or if the character at
116    // position is not a U+002E FULL STOP character (.),
117    // then return an error and abort these steps.
118    // Otherwise, move position forwards one character.
119    position.next().filter(|c| *c == '\u{002E}')?;
120    // Step 14. Collect a sequence of code points that are ASCII digits,
121    // and let string be the collected substring.
122    let string = collect_ascii_digits(position);
123    // Step 15. If string is not exactly three characters in length,
124    // return an error and abort these steps.
125    if string.len() != 3 {
126        return None;
127    }
128    // Step 16. Interpret string as a base-ten integer. Let value4 be that integer.
129    let value_4 = string.parse::<f64>().ok()?;
130    // Step 17. If value2 is greater than 59 or if value3 is greater than 59,
131    // return an error and abort these steps.
132    if value_2 > 59_f64 || value_3 > 59_f64 {
133        return None;
134    }
135    // Step 18. Let result be value1×60×60 + value2×60 + value3 + value4∕1000.
136    // Step 19. Return result.
137    Some(value_1 * 60_f64 * 60_f64 + value_2 * 60_f64 + value_3 + value_4 / 1000_f64)
138}
139
140/// <https://w3c.github.io/webvtt/#collect-webvtt-cue-timings-and-settings>
141pub(crate) fn collect_webvtt_cue_timings_and_settings(
142    identifier: String,
143    input: &str,
144) -> Option<WebVttCue> {
145    // Step 1. Let input be the string being parsed.
146    //
147    // Passed in as argument
148
149    // Step 2. Let position be a pointer into input,
150    // initially pointing at the start of the string.
151    let mut position = input.chars().peekable();
152    // Step 3. Skip whitespace.
153    skip_whitespace(&mut position);
154    // Step 4. Collect a WebVTT timestamp. If that algorithm fails,
155    // then abort these steps and return failure.
156    // Otherwise, let cue’s text track cue start time be the collected time.
157    let start_time = collect_webvtt_timestamp(position.by_ref())?;
158    // Step 5. Skip whitespace.
159    skip_whitespace(&mut position);
160    // Step 6. If the character at position is not a U+002D HYPHEN-MINUS character (-)
161    // then abort these steps and return failure.
162    // Otherwise, move position forwards one character.
163    let _ = position.next().filter(|c| *c == '\u{002D}')?;
164    // Step 7. If the character at position is not a U+002D HYPHEN-MINUS character (-)
165    // then abort these steps and return failure.
166    // Otherwise, move position forwards one character.
167    let _ = position.next().filter(|c| *c == '\u{002D}')?;
168    // Step 8. If the character at position is not a U+003E GREATER-THAN SIGN character (>)
169    // then abort these steps and return failure.
170    // Otherwise, move position forwards one character.
171    let _ = position.next().filter(|c| *c == '\u{003E}')?;
172    // Step 9. Skip whitespace.
173    skip_whitespace(&mut position);
174    // Step 10. Collect a WebVTT timestamp. If that algorithm fails,
175    // then abort these steps and return failure.
176    // Otherwise, let cue’s text track cue end time be the collected time.
177    let end_time = collect_webvtt_timestamp(position.by_ref())?;
178    // Step 11. Let remainder be the trailing substring of input starting at position.
179    let remainder = collect_for_closure(&mut position, |_| true);
180    // Step 12. Parse the WebVTT cue settings from remainder using regions for cue.
181    let cue = WebVttCue {
182        identifier,
183        start_time,
184        end_time,
185        ..Default::default()
186    };
187    Some(parse_the_webvtt_cue_settings(cue, remainder))
188}
189
190/// <https://w3c.github.io/webvtt/#parse-the-webvtt-cue-settings>
191fn parse_the_webvtt_cue_settings(mut cue: WebVttCue, input: String) -> WebVttCue {
192    // Step 1. Let settings be the result of splitting input on spaces.
193    let settings = input.split_ascii_whitespace();
194    // Step 2. For each token setting in the list settings, run the following substeps:
195    'next_setting: for setting in settings {
196        // Step 2.2. Let name be the leading substring of setting up to
197        // and excluding the first U+003A COLON character (:) in that string.
198        // Step 2.3. Let value be the trailing substring of setting starting from the
199        // character immediately after the first U+003A COLON character (:) in that string.
200        let Some((name, value)) = setting.split_once('\u{003A}') else {
201            // Step 2.1. If setting does not contain a U+003A COLON character (:),
202            // or if the first U+003A COLON character (:) in setting is either
203            // the first or last character of setting,
204            // then jump to the step labeled next setting.
205            //
206            // We check the first part here
207            continue 'next_setting;
208        };
209        // Step 2.1. If setting does not contain a U+003A COLON character (:),
210        // or if the first U+003A COLON character (:) in setting is either
211        // the first or last character of setting,
212        // then jump to the step labeled next setting.
213        //
214        // We check the second part here
215        if name.is_empty() || value.is_empty() {
216            continue 'next_setting;
217        }
218        // Step 2.4. Run the appropriate substeps that apply for the value of name, as follows:
219        match name {
220            // > If name is a case-sensitive match for "vertical"
221            "vertical" => {
222                // Step 2.4."vertical".1. If value is a case-sensitive match for the string "rl",
223                // then let cue’s WebVTT cue writing direction be vertical growing left.
224                if value == "rl" {
225                    cue.writing_direction = WebVttWritingDirection::VerticalGrowingLeft;
226                }
227                // Step 2.4."vertical".2. Otherwise, if value is a case-sensitive match for the string "lr",
228                // then let cue’s WebVTT cue writing direction be vertical growing right.
229                if value == "lr" {
230                    cue.writing_direction = WebVttWritingDirection::VerticalGrowingRight;
231                }
232                // Step 2.4."vertical".3. If cue’s WebVTT cue writing direction is not horizontal,
233                // let cue’s WebVTT cue region be null (there are no vertical regions).
234                // TODO
235            },
236            // > If name is a case-sensitive match for "line"
237            "line" => {
238                // Step 2.4."line".1. If value contains a U+002C COMMA character (,),
239                // then let linepos be the leading substring of value up to and excluding the
240                // first U+002C COMMA character (,) in that string and let linealign be the
241                // trailing substring of value starting from the character immediately after the
242                // first U+002C COMMA character (,) in that string.
243                // Step 2.4."line".2. Otherwise let linepos be the full value string and linealign be null.
244                let (linepos, linealign) = value
245                    .split_once('\u{002C}')
246                    .map(|(linepos, linealign)| (linepos, Some(linealign)))
247                    .unwrap_or((value, None));
248
249                // Step 2.4."line".4. If the last character in linepos is a U+0025 PERCENT SIGN character (%)
250                let last_char_is_percentage =
251                    linepos.chars().last().is_some_and(|c| c == '\u{0025}');
252                let number = if last_char_is_percentage {
253                    // If parse a percentage string from linepos doesn’t fail,
254                    // let number be the returned percentage, otherwise jump to the step labeled next setting.
255                    let Some(number) = parse_a_percentage_string(linepos) else {
256                        continue 'next_setting;
257                    };
258                    number
259                } else {
260                    let mut chars = linepos.chars().peekable();
261                    let mut has_at_least_one_dot = false;
262                    let mut last_char: Option<char> = None;
263                    let mut at_least_one_digit = false;
264                    while let Some(current_char) = chars.next() {
265                        match current_char {
266                            // Step 2.4."line".4.2. If any character in linepos other than the first character is
267                            // a U+002D HYPHEN-MINUS character (-), then jump to the step labeled next setting.
268                            '\u{002D}' => {
269                                if last_char.is_some() {
270                                    continue 'next_setting;
271                                }
272                            },
273                            '\u{002E}' => {
274                                // Step 2.4."line".4.3. If there are more than one U+002E DOT characters (.),
275                                // then jump to the step labeled next setting.
276                                if has_at_least_one_dot {
277                                    continue 'next_setting;
278                                }
279                                has_at_least_one_dot = true;
280                                // Step 2.4."line".4.4. If there is a U+002E DOT character (.)
281                                // and the character before or the character after is not an ASCII digit,
282                                // or if the U+002E DOT character (.) is the first or the last character,
283                                // then jump to the step labeled next setting.
284                                if last_char.is_none_or(|c| !c.is_ascii_digit()) ||
285                                    chars.peek().is_none_or(|c| !c.is_ascii_digit())
286                                {
287                                    continue 'next_setting;
288                                }
289                            },
290                            _ => {
291                                // Step 2.4."line".4.1. If linepos contains any characters other than
292                                // U+002D HYPHEN-MINUS characters (-), ASCII digits, and U+002E DOT character (.),
293                                // then jump to the step labeled next setting.
294                                if !current_char.is_ascii_digit() {
295                                    continue 'next_setting;
296                                }
297                                at_least_one_digit = true;
298                            },
299                        }
300                        last_char = Some(current_char);
301                    }
302                    // Step 2.4."line".3. If linepos does not contain at least one ASCII digit,
303                    // then jump to the step labeled next setting.
304                    if !at_least_one_digit {
305                        continue 'next_setting;
306                    }
307                    // Step 2.4."line".4.5. Let number be the result of parsing linepos using the
308                    // rules for parsing floating-point number values. [HTML]
309                    let Some(number) = linepos.parse::<f64>().ok() else {
310                        // Step 2.4."line".4.6. If number is an error,
311                        // then jump to the step labeled next setting.
312                        continue 'next_setting;
313                    };
314                    number
315                };
316                match linealign {
317                    // Step 2.4."line".5. If linealign is a case-sensitive match for the string "start",
318                    // then let cue’s WebVTT cue line alignment be start alignment.
319                    Some("start") => {
320                        cue.line_alignment = WebVttLineAlignment::Start;
321                    },
322                    // Step 2.4."line".6. If linealign is a case-sensitive match for the string "center",
323                    // then let cue’s WebVTT cue line alignment be center alignment.
324                    Some("center") => {
325                        cue.line_alignment = WebVttLineAlignment::Center;
326                    },
327                    // Step 2.4."line".7. If linealign is a case-sensitive match for the string "end",
328                    // then let cue’s WebVTT cue line alignment be end alignment.
329                    Some("end") => {
330                        cue.line_alignment = WebVttLineAlignment::End;
331                    },
332                    // Step 2.4."line".8. Otherwise, if linealign is not null,
333                    // then jump to the step labeled next setting.
334                    Some(_) => {
335                        continue 'next_setting;
336                    },
337                    _ => {},
338                }
339                // Step 2.4."line".9. Let cue’s WebVTT cue line be number.
340                cue.line = WebVttLineAndPositionSetting::Double(number);
341                // Step 2.4."line".10. If the last character in linepos is a U+0025 PERCENT SIGN character (%),
342                // then let cue’s WebVTT cue snap-to-lines flag be false. Otherwise, let it be true.
343                cue.snap_to_lines = (!last_char_is_percentage).into();
344                // If cue’s WebVTT cue line is not auto,
345                // let cue’s WebVTT cue region be null
346                // (the cue has been explicitly positioned with a line offset
347                // and thus drops out of the region).
348                // TODO
349            },
350            // > If name is a case-sensitive match for "position"
351            "position" => {
352                // Step 2.4."position".1. If value contains a U+002C COMMA character (,),
353                // then let colpos be the leading substring of value up to and excluding the
354                // first U+002C COMMA character (,) in that string and let colalign be the
355                // trailing substring of value starting from the character immediately after the
356                // first U+002C COMMA character (,) in that string.
357                // Step 2.4."position".2. Otherwise let colpos be the full value string and colalign be null.
358                let (colpos, colalign) = value
359                    .split_once('\u{002C}')
360                    .map(|(colpos, colalign)| (colpos, Some(colalign)))
361                    .unwrap_or((value, None));
362                // Step 2.4."position".3. If parse a percentage string from colpos doesn’t fail,
363                // let number be the returned percentage,
364                // otherwise jump to the step labeled next setting
365                // (position’s value remains the special value auto).
366                let Some(number) = parse_a_percentage_string(colpos) else {
367                    continue 'next_setting;
368                };
369                match colalign {
370                    // Step 2.4."position".4. If colalign is a case-sensitive match for the string "line-left",
371                    // then let cue’s WebVTT cue position alignment be line-left alignment.
372                    Some("line-left") => {
373                        cue.position_alignment = WebVttPositionAlignment::LineLeft;
374                    },
375                    // Step 2.4."position".5. Otherwise, if colalign is a case-sensitive match for the string "center",
376                    // then let cue’s WebVTT cue position alignment be center alignment.
377                    Some("center") => {
378                        cue.position_alignment = WebVttPositionAlignment::Center;
379                    },
380                    // Step 2.4."position".6. Otherwise, if colalign is a case-sensitive match for the string "line-right",
381                    // then let cue’s WebVTT cue position alignment be line-right alignment.
382                    Some("line-right") => {
383                        cue.position_alignment = WebVttPositionAlignment::LineRight;
384                    },
385                    // Step 2.4."position".7. Otherwise, if colalign is not null,
386                    // then jump to the step labeled next setting.
387                    Some(_) => {
388                        continue 'next_setting;
389                    },
390                    _ => {},
391                }
392                // Step 2.4."position".8. Let cue’s position be number.
393                cue.position = WebVttLineAndPositionSetting::Double(number);
394            },
395            // > If name is a case-sensitive match for "size"
396            "size" => {
397                // Step 2.4."size".1. If parse a percentage string from value doesn’t fail,
398                // let number be the returned percentage,
399                // otherwise jump to the step labeled next setting.
400                let Some(number) = parse_a_percentage_string(value) else {
401                    continue 'next_setting;
402                };
403                // Step 2.4."size".2. Let cue’s WebVTT cue size be number.
404                cue.size = WebVttCueSize(number);
405                // Step 2.4."size".3. If cue’s WebVTT cue size is not 100,
406                // let cue’s WebVTT cue region be null
407                // (the cue has been explicitly sized and thus drops out of the region).
408                // TODO
409            },
410            // > If name is a case-sensitive match for "align"
411            "align" => {
412                // Step 2.4."align".1. If value is a case-sensitive match for the string "start",
413                // then let cue’s WebVTT cue text alignment be start alignment.
414                if value == "start" {
415                    cue.text_alignment = WebVttTextAlignment::Start;
416                }
417                // Step 2.4."align".2. If value is a case-sensitive match for the string "center",
418                // then let cue’s WebVTT cue text alignment be center alignment.
419                if value == "center" {
420                    cue.text_alignment = WebVttTextAlignment::Center;
421                }
422                // Step 2.4."align".3. If value is a case-sensitive match for the string "end",
423                // then let cue’s WebVTT cue text alignment be end alignment.
424                if value == "end" {
425                    cue.text_alignment = WebVttTextAlignment::End;
426                }
427                // Step 2.4."align".4. If value is a case-sensitive match for the string "left",
428                // then let cue’s WebVTT cue text alignment be left alignment.
429                if value == "left" {
430                    cue.text_alignment = WebVttTextAlignment::Left;
431                }
432                // Step 2.4."align".5. If value is a case-sensitive match for the string "right",
433                // then let cue’s WebVTT cue text alignment be right alignment.
434                if value == "right" {
435                    cue.text_alignment = WebVttTextAlignment::Right;
436                }
437            },
438            _ => {},
439        }
440    }
441    cue
442}
443
444/// <https://w3c.github.io/webvtt/#webvtt-percentage>
445static WEB_VTT_PERCENTAGE_GRAMMAR: LazyLock<Regex> =
446    LazyLock::new(|| Regex::new(r#"^(?P<number>[0-9]+(\.[0-9]+)?)%$"#).unwrap());
447
448/// <https://w3c.github.io/webvtt/#parse-a-percentage-string>
449fn parse_a_percentage_string(input: &str) -> Option<f64> {
450    // Step 1. Let input be the string being parsed.
451    //
452    // Passed in as argument
453
454    // Step 2. If input does not match the syntax for a WebVTT percentage, then fail.
455    let captures = WEB_VTT_PERCENTAGE_GRAMMAR.captures(input)?;
456    // Step 3. Remove the last character from input.
457    let input = captures.name("number").expect("Must always have a capture");
458    // Step 4. Let percentage be the result of parsing input using the rules for parsing floating-point number values. [HTML]
459    // Step 5. If percentage is an error, is less than 0, or is greater than 100, then fail.
460    // Step 6. Return percentage.
461    input
462        .as_str()
463        .trim()
464        .parse::<f64>()
465        .ok()
466        .filter(|percentage| *percentage >= 0. && *percentage <= 100.)
467}
468
469#[cfg(test)]
470mod tests {
471    use crate::collectors::collect_webvtt_timestamp;
472    use crate::shared_test_setup::compute_result_in_seconds;
473
474    fn parse_timestamp(input: &str) -> Option<f64> {
475        collect_webvtt_timestamp(&mut input.chars().peekable())
476    }
477
478    #[test]
479    fn test_parses_start_timestamp_correctly() {
480        assert_eq!(
481            parse_timestamp("01:10:03.000"),
482            Some(compute_result_in_seconds(1., 10., 3., 0.))
483        );
484    }
485
486    #[test]
487    fn test_parses_maximum_minute_timestamp() {
488        assert_eq!(
489            parse_timestamp("10:59:03.000"),
490            Some(compute_result_in_seconds(10., 59., 3., 0.))
491        );
492    }
493
494    #[test]
495    fn test_parses_maximum_second_timestamp() {
496        assert_eq!(
497            parse_timestamp("10:04:59.000"),
498            Some(compute_result_in_seconds(10., 4., 59., 0.))
499        );
500    }
501
502    #[test]
503    fn test_hours_more_than_59_ensures_three_values() {
504        assert_eq!(parse_timestamp("60:04.000"), None);
505    }
506
507    #[test]
508    fn test_first_value_below_60_implies_minutes() {
509        assert_eq!(
510            parse_timestamp("59:04.000"),
511            Some(compute_result_in_seconds(0., 59., 4., 0.))
512        );
513    }
514
515    #[test]
516    fn test_seconds_cannot_exceed_59() {
517        assert_eq!(parse_timestamp("05:60.000"), None);
518    }
519
520    #[test]
521    fn test_minutes_cannot_exceed_59() {
522        assert_eq!(parse_timestamp("05:60:35.000"), None);
523    }
524
525    #[test]
526    fn test_hours_can_exceed_59() {
527        assert_eq!(
528            parse_timestamp("60:20:35.000"),
529            Some(compute_result_in_seconds(60., 20., 35., 0.))
530        );
531    }
532}