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}