servo_webvtt/lib.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::marker::PhantomData;
7use std::mem;
8use std::str::Chars;
9use std::sync::LazyLock;
10
11use html5ever::buffer_queue::{BufferQueue, SetResult};
12use html5ever::tendril::StrTendril;
13use markup5ever::small_char_set;
14use regex::Regex;
15
16/// <https://w3c.github.io/webvtt/#webvtt-cue-writing-direction>
17#[derive(Debug, Default, PartialEq)]
18pub enum WebVttWritingDirection {
19 /// <https://w3c.github.io/webvtt/#webvtt-cue-horizontal-writing-direction>
20 #[default]
21 Horizontal,
22 /// <https://w3c.github.io/webvtt/#webvtt-cue-vertical-growing-left-writing-direction>
23 VerticalGrowingLeft,
24 /// <https://w3c.github.io/webvtt/#webvtt-cue-vertical-growing-right-writing-direction>
25 VerticalGrowingRight,
26}
27
28/// <https://w3c.github.io/webvtt/#webvtt-cue-text-alignment>
29#[derive(Debug, Default, PartialEq)]
30pub enum WebVttTextAlignment {
31 /// <https://w3c.github.io/webvtt/#webvtt-cue-start-alignment>
32 Start,
33 /// <https://w3c.github.io/webvtt/#webvtt-cue-center-alignment>
34 #[default]
35 Center,
36 /// <https://w3c.github.io/webvtt/#webvtt-cue-end-alignment>
37 End,
38 /// <https://w3c.github.io/webvtt/#webvtt-cue-left-alignment>
39 Left,
40 /// <https://w3c.github.io/webvtt/#webvtt-cue-right-alignment>
41 Right,
42}
43
44/// <https://w3c.github.io/webvtt/#webvtt-cue-position-alignment>
45#[derive(Debug, Default, PartialEq)]
46pub enum WebVttPositionAlignment {
47 /// <https://w3c.github.io/webvtt/#webvtt-cue-position-line-left-alignment>
48 LineLeft,
49 /// <https://w3c.github.io/webvtt/#webvtt-cue-position-center-alignment>
50 Center,
51 /// <https://w3c.github.io/webvtt/#webvtt-cue-position-line-right-alignment>
52 LineRight,
53 /// <https://w3c.github.io/webvtt/#webvtt-cue-position-automatic-alignment>
54 #[default]
55 Auto,
56}
57
58/// <https://w3c.github.io/webvtt/#webvtt-cue-position>
59#[derive(Clone, Debug, Default, PartialEq)]
60pub enum WebVttLineAndPositionSetting {
61 Double(f64),
62 #[default]
63 Auto,
64}
65
66/// <https://w3c.github.io/webvtt/#webvtt-cue-line-alignment>
67#[derive(Debug, Default, PartialEq)]
68pub enum WebVttLineAlignment {
69 /// <https://w3c.github.io/webvtt/#webvtt-cue-line-start-alignment>
70 #[default]
71 Start,
72 /// <https://w3c.github.io/webvtt/#webvtt-cue-line-center-alignment>
73 Center,
74 /// <https://w3c.github.io/webvtt/#webvtt-cue-line-end-alignment>
75 End,
76}
77
78/// <https://w3c.github.io/webvtt/#webvtt-cue-snap-to-lines-flag>
79/// This is an enum, since the default value is `true`
80#[derive(Debug, Default, PartialEq)]
81pub enum WebVttSnapToLines {
82 #[default]
83 Yes,
84 No,
85}
86
87impl From<bool> for WebVttSnapToLines {
88 fn from(boolean: bool) -> Self {
89 match boolean {
90 true => WebVttSnapToLines::Yes,
91 false => WebVttSnapToLines::No,
92 }
93 }
94}
95
96/// <https://w3c.github.io/webvtt/#webvtt-cue-size>
97/// This is a struct, since the default value is 100
98#[derive(Debug, PartialEq)]
99pub struct WebVttCueSize(pub f64);
100
101impl Default for WebVttCueSize {
102 fn default() -> Self {
103 Self(100.)
104 }
105}
106
107/// <https://w3c.github.io/webvtt/#webvtt-cue>
108#[derive(Debug, Default, PartialEq)]
109pub struct WebVttCue {
110 /// <https://html.spec.whatwg.org/multipage/#text-track-cue-identifier>
111 pub identifier: String,
112 /// <https://html.spec.whatwg.org/multipage/#text-track-cue-start-time>
113 pub start_time: f64,
114 /// <https://html.spec.whatwg.org/multipage/#text-track-cue-end-time>
115 pub end_time: f64,
116 /// <https://w3c.github.io/webvtt/#cue-text>
117 pub text: String,
118 /// <https://w3c.github.io/webvtt/#webvtt-cue-writing-direction>
119 pub writing_direction: WebVttWritingDirection,
120 /// <https://w3c.github.io/webvtt/#webvtt-cue-text-alignment>
121 pub text_alignment: WebVttTextAlignment,
122 /// <https://w3c.github.io/webvtt/#webvtt-cue-position-alignment>
123 pub position_alignment: WebVttPositionAlignment,
124 /// <https://w3c.github.io/webvtt/#webvtt-cue-position>
125 pub position: WebVttLineAndPositionSetting,
126 /// <https://w3c.github.io/webvtt/#webvtt-cue-line-alignment>
127 pub line_alignment: WebVttLineAlignment,
128 /// <https://w3c.github.io/webvtt/#webvtt-cue-line>
129 pub line: WebVttLineAndPositionSetting,
130 /// <https://w3c.github.io/webvtt/#webvtt-cue-snap-to-lines-flag>
131 pub snap_to_lines: WebVttSnapToLines,
132 /// <https://w3c.github.io/webvtt/#webvtt-cue-size>
133 pub size: WebVttCueSize,
134}
135
136#[derive(Debug, PartialEq)]
137pub enum WebVttParserError {
138 InvalidHeader,
139}
140
141impl std::fmt::Display for WebVttParserError {
142 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 WebVttParserError::InvalidHeader => write!(formatter, "Invalid WebVTT header in file"),
145 }
146 }
147}
148
149#[derive(Clone, Copy, Default, PartialEq)]
150enum MostSignificantUnits {
151 #[default]
152 Minutes,
153 Hours,
154}
155
156#[derive(Clone, Copy, Default, PartialEq)]
157enum ParserState {
158 #[default]
159 FileTag,
160 WhitespaceAfterFileTag,
161 BeforeNewlineAfterFileTag,
162 BeforeHeader,
163 InBlockLoop,
164 AfterBlockLoop,
165 Region,
166 Finished,
167}
168
169pub trait WebVttParserSink<Context> {
170 fn consume_cue(&self, cx: &mut Context, cue: WebVttCue);
171}
172
173#[derive(Default)]
174pub struct IncrementalWebVTTParser<Context, Sink: WebVttParserSink<Context>> {
175 phantom: PhantomData<Context>,
176 pub sink: Sink,
177 buffer: BufferQueue,
178
179 // Checkpoint values
180 seen_cue: bool,
181 seen_eof: bool,
182 seen_arrow: bool,
183
184 // State values
185 in_header: bool,
186 line_count: u32,
187 state: ParserState,
188
189 // Storage values
190 current_line_in_block: StrTendril,
191 current_buffer_in_block: String,
192
193 current_cue_in_block: Option<WebVttCue>,
194}
195
196pub type ParserUpdate = Result<(), WebVttParserError>;
197
198impl<Context, Sink> IncrementalWebVTTParser<Context, Sink>
199where
200 Sink: WebVttParserSink<Context>,
201{
202 /// <https://w3c.github.io/webvtt/#webvtt-parser-algorithm>
203 pub fn new(sink: Sink) -> Self {
204 Self {
205 sink,
206 phantom: Default::default(),
207 buffer: Default::default(),
208 seen_cue: Default::default(),
209 seen_eof: Default::default(),
210 seen_arrow: Default::default(),
211 in_header: Default::default(),
212 line_count: Default::default(),
213 state: Default::default(),
214 current_line_in_block: Default::default(),
215 current_buffer_in_block: Default::default(),
216 current_cue_in_block: Default::default(),
217 }
218 }
219
220 pub fn end(&mut self, cx: &mut Context) -> ParserUpdate {
221 self.seen_eof = true;
222 self.step(cx)
223 }
224
225 pub fn parse_sync(&mut self, cx: &mut Context, input: &str) -> ParserUpdate {
226 self.seen_eof = true;
227 self.parse(cx, input)
228 }
229
230 /// <https://w3c.github.io/webvtt/#webvtt-parser-algorithm>
231 pub fn parse(&mut self, cx: &mut Context, input: &str) -> ParserUpdate {
232 // Step 1. Let input be the string being parsed, after conversion to Unicode,
233 // and with the following transformations applied:
234 // > Replace all U+0000 NULL characters by U+FFFD REPLACEMENT CHARACTERs.
235 // > Replace each U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair
236 // > by a single U+000A LINE FEED (LF) character.
237 // > Replace all remaining U+000D CARRIAGE RETURN characters by U+000A LINE FEED (LF) characters.
238 // TODO
239 // Step 2. Let position be a pointer into input, initially pointing at the start of the string.
240 // In an incremental WebVTT parser, when this algorithm (or further algorithms that it uses)
241 // moves the position pointer, the user agent must wait until appropriate further characters
242 // from the byte stream have been added to input before moving the pointer,
243 // so that the algorithm never reads past the end of the input string.
244 // Once the byte stream has ended, and all characters have been added to input,
245 // then the position pointer may, when so instructed by the algorithms,
246 // be moved past the end of input.
247 self.buffer.push_back(StrTendril::from(input));
248 self.step(cx)
249 }
250
251 fn step(&mut self, cx: &mut Context) -> ParserUpdate {
252 loop {
253 let current_state = self.state;
254 match current_state {
255 // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
256 ParserState::FileTag => {
257 // https://w3c.github.io/webvtt/#webvtt-file-body
258 // > An optional U+FEFF BYTE ORDER MARK (BOM) character.
259 if self.buffer.peek().is_some_and(|c| c == '\u{FEFF}') {
260 let _ = self.buffer.next();
261 }
262 let Some(input) = self.buffer.eat("WEBVTT", u8::eq) else {
263 // Step 4. If input is less than six characters long, then abort these steps.
264 // The file does not start with the correct WebVTT file signature
265 // and was therefore not successfully processed.
266 if self.seen_eof {
267 return Err(WebVttParserError::InvalidHeader);
268 }
269 return Ok(());
270 };
271 // Step 5. If input is exactly six characters long but does not exactly equal "WEBVTT",
272 // then abort these steps. The file does not start with the correct WebVTT
273 // file signature and was therefore not successfully processed.
274 // Step 6. If input is more than six characters long but the first six characters
275 // do not exactly equal "WEBVTT", or the seventh character is not a U+0020 SPACE character,
276 // a U+0009 CHARACTER TABULATION (tab) character, or a U+000A LINE FEED (LF) character,
277 // then abort these steps. The file does not start with the correct WebVTT file signature
278 // and was therefore not successfully processed.
279 //
280 // We check the first part of this step here
281 if !input {
282 return Err(WebVttParserError::InvalidHeader);
283 }
284 self.state = ParserState::WhitespaceAfterFileTag;
285 continue;
286 },
287 // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
288 ParserState::WhitespaceAfterFileTag => {
289 let Some(seventh) = self.buffer.peek() else {
290 // Input is exactly six characters and is a valid header
291 if self.seen_eof {
292 self.state = ParserState::Finished;
293 continue;
294 }
295 return Ok(());
296 };
297 // Step 6. If input is more than six characters long but the first six characters
298 // do not exactly equal "WEBVTT", or the seventh character is not a U+0020 SPACE character,
299 // a U+0009 CHARACTER TABULATION (tab) character, or a U+000A LINE FEED (LF) character,
300 // then abort these steps. The file does not start with the correct WebVTT file signature
301 // and was therefore not successfully processed.
302 //
303 // We check the second part of this step here
304 if !matches!(seventh, '\u{0020}' | '\u{0009}' | '\u{000A}') {
305 return Err(WebVttParserError::InvalidHeader);
306 }
307 self.state = ParserState::BeforeNewlineAfterFileTag;
308 continue;
309 },
310 // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
311 ParserState::BeforeNewlineAfterFileTag => {
312 // Step 7. collect a sequence of code points that are not U+000A LINE FEED (LF) characters.
313 let Some(current_char) = self.buffer.next() else {
314 // Step 8. If position is past the end of input, then abort these steps.
315 // The file was successfully processed, but it contains no useful data and so
316 // no WebVTT cues were added to output.
317 if self.seen_eof {
318 self.state = ParserState::Finished;
319 continue;
320 }
321 return Ok(());
322 };
323 // Step 9. The character indicated by position is a U+000A LINE FEED (LF) character.
324 // Advance position to the next character in input.
325 if current_char == '\u{000A}' {
326 self.state = ParserState::BeforeHeader;
327 }
328 },
329 // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
330 ParserState::BeforeHeader => {
331 let Some(current_char) = self.buffer.peek() else {
332 // Step 10. If position is past the end of input, then abort these steps.
333 // The file was successfully processed, but it contains no useful data and
334 // so no WebVTT cues were added to output.
335 if self.seen_eof {
336 self.state = ParserState::Finished;
337 continue;
338 }
339 return Ok(());
340 };
341 // Step 11. Header: If the character indicated by position is not
342 // a U+000A LINE FEED (LF) character,
343 // then collect a WebVTT block with the in header flag set.
344 // Otherwise, advance position to the next character in input.
345 if current_char != '\u{000A}' {
346 self.in_header = true;
347 self.start_collecting_webvtt_block();
348 } else {
349 self.buffer.next();
350 self.state = ParserState::Region;
351 }
352 },
353 // https://w3c.github.io/webvtt/#collect-a-webvtt-block
354 ParserState::InBlockLoop => {
355 let Some(current_char) =
356 self.buffer.pop_except_from(small_char_set!('\u{000A}'))
357 else {
358 // Step 11.3. If position is past the end of input, let seen EOF be true.
359 // Otherwise, the character indicated by position is a U+000A LINE FEED (LF) character;
360 // advance position to the next character in input.
361 //
362 // We check the first part of this step here
363 // Step 11.7. If seen EOF is true, break out of loop.
364 if self.seen_eof {
365 // It depends when we see the EOF whether there is still content in the buffer or not.
366 // In the case that the EOF is at the end of a line (e.g. no `\n` in between), we
367 // should copy the current line to the buffer. The buffer is then populated with the
368 // line as usual, so that the last line of a file can still be the cue text.
369 if !self.current_line_in_block.is_empty() {
370 if !self.current_buffer_in_block.is_empty() {
371 self.current_buffer_in_block.push('\n');
372 }
373 self.current_buffer_in_block
374 .push_str(&self.current_line_in_block);
375 }
376 self.state = ParserState::AfterBlockLoop;
377 continue;
378 }
379 return Ok(());
380 };
381 match current_char {
382 // Step 11.3. If position is past the end of input, let seen EOF be true.
383 // Otherwise, the character indicated by position is a U+000A LINE FEED (LF) character;
384 // advance position to the next character in input.
385 //
386 // We check the second part of this step here
387 SetResult::FromSet('\u{000A}') => {
388 // Step 11.2. Increment line count by 1.
389 self.line_count += 1;
390 if self.in_header {
391 self.state = ParserState::AfterBlockLoop;
392 } else {
393 // Step 11.4. If line contains the three-character substring "-->"
394 // (U+002D HYPHEN-MINUS, U+002D HYPHEN-MINUS, U+003E GREATER-THAN SIGN),
395 // then run these substeps:
396 if self.current_line_in_block.contains("-->") {
397 // Step 11.4.1. If in header is not set and at least
398 // one of the following conditions are true:
399 //
400 // We already checked for the header set after step 11.2.
401 if
402 // line count is 1
403 self.line_count == 1
404 // line count is 2 and seen arrow is false
405 || (self.line_count == 2 && !self.seen_arrow)
406 {
407 // Step 11.4.1.1. Let seen arrow be true.
408 self.seen_arrow = true;
409 // Step 11.4.1.2. Let previous position be position.
410 // TODO
411 // Step 11.4.1.3. Cue creation: Let cue be a new WebVTT cue and initialize it as follows:
412 // Step 11.4.1.3.1. Let cue’s text track cue identifier be buffer.
413 let identifier = self.current_buffer_in_block.clone();
414 // Step 11.4.1.4. Collect WebVTT cue timings and settings from line using regions for cue.
415 // If that fails, let cue be null.
416 // Otherwise, let buffer be the empty string and let seen cue be true.
417 let cue = collect_webvtt_cue_timings_and_settings(
418 identifier,
419 &self.current_line_in_block,
420 );
421 let has_cue = cue.is_some();
422 self.current_cue_in_block = cue;
423 if has_cue {
424 self.current_buffer_in_block.clear();
425 self.current_line_in_block.clear();
426 self.seen_cue = true;
427 }
428 } else {
429 // Otherwise, let position be previous position and break out of loop.
430 self.state = ParserState::AfterBlockLoop;
431 }
432 continue;
433 } else if self.current_line_in_block.is_empty() {
434 // Step 11.5. Otherwise, if line is the empty string, break out of loop.
435 self.state = ParserState::AfterBlockLoop;
436 } else {
437 // Step 11.6. Otherwise, run these substeps:
438 // Step 11.6.1. If in header is not set and line count is 2, run these substeps:
439 // TODO
440 // Step 11.6.2. If buffer is not the empty string,
441 // append a U+000A LINE FEED (LF) character to buffer.
442 if !self.current_buffer_in_block.is_empty() {
443 self.current_buffer_in_block.push('\u{000A}');
444 }
445 // Step 11.6.3. Append line to buffer.
446 self.current_buffer_in_block
447 .push_str(&self.current_line_in_block);
448 // Step 11.6.4. Let previous position be position.
449 self.current_line_in_block.clear();
450 }
451 }
452 continue;
453 },
454 // Step 11.1. collect a sequence of code points that are not U+000A LINE FEED (LF) characters.
455 // Let line be those characters, if any.
456 SetResult::NotFromSet(current_tendril) => {
457 if !self.in_header {
458 self.current_line_in_block.push_tendril(¤t_tendril);
459 }
460 },
461 _ => {
462 unreachable!();
463 },
464 }
465 },
466 ParserState::AfterBlockLoop => {
467 // https://w3c.github.io/webvtt/#collect-a-webvtt-block
468 // Step 12. If cue is not null, let the cue text of cue be buffer, and return cue.
469 // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
470 // Step 14.2. If block is a WebVTT cue, add block to the text track list of cues output.
471 if let Some(mut cue) = self.current_cue_in_block.take() {
472 cue.text = self.current_buffer_in_block.clone();
473 self.sink.consume_cue(cx, cue);
474 }
475 // Step 14.3. Otherwise, if block is a CSS style sheet, add block to stylesheets.
476 // TODO
477 // Step 14.4. Otherwise, if block is a WebVTT region object, add block to regions.
478 // TODO
479 // Step 14.5. collect a sequence of code points that are U+000A LINE FEED (LF) characters.
480 let Some(current_char) = self.buffer.peek() else {
481 if self.seen_eof {
482 self.state = ParserState::Finished;
483 continue;
484 }
485 return Ok(());
486 };
487 if current_char == '\u{000A}' {
488 // Since we don't change the state here, it means that if the next character is
489 // also a newline, we re-enter this block and consume it again. Therefore, we
490 // only consume one-by-one.
491 let _ = self.buffer.next();
492 } else {
493 // If we were in the header block, then we should proceed with the next step
494 // which is collecting a region in step 12. Otherwise, we are in the general loop of
495 // step 14.
496
497 if mem::take(&mut self.in_header) {
498 self.state = ParserState::Region;
499 } else {
500 self.start_collecting_webvtt_block();
501 }
502 }
503 },
504 // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
505 ParserState::Region => {
506 // Step 12. collect a sequence of code points that are U+000A LINE FEED (LF) characters.
507 // TODO
508 self.start_collecting_webvtt_block();
509 },
510 ParserState::Finished => {
511 // Step 15. End: The file has ended. Abort these steps. The WebVTT parser has finished.
512 // The file was successfully processed.
513 return Ok(());
514 },
515 }
516 }
517 }
518
519 /// <https://w3c.github.io/webvtt/#collect-a-webvtt-block>
520 fn start_collecting_webvtt_block(&mut self) {
521 // Step 2. Let line count be zero.
522 self.line_count = 0;
523 // Step 4. Let line be the empty string.
524 self.current_line_in_block.clear();
525 // Step 5. Let buffer be the empty string.
526 self.current_buffer_in_block.clear();
527 // Step 7. Let seen arrow be false.
528 self.seen_arrow = false;
529 // Step 8. Let cue be null.
530 self.current_cue_in_block = None;
531 self.state = ParserState::InBlockLoop;
532 }
533}
534
535/// <https://w3c.github.io/webvtt/#collect-webvtt-cue-timings-and-settings>
536fn collect_webvtt_cue_timings_and_settings(identifier: String, input: &str) -> Option<WebVttCue> {
537 // Step 1. Let input be the string being parsed.
538 //
539 // Passed in as argument
540
541 // Step 2. Let position be a pointer into input,
542 // initially pointing at the start of the string.
543 let mut position = input.chars().peekable();
544 // Step 3. Skip whitespace.
545 skip_whitespace(&mut position);
546 // Step 4. Collect a WebVTT timestamp. If that algorithm fails,
547 // then abort these steps and return failure.
548 // Otherwise, let cue’s text track cue start time be the collected time.
549 let start_time = collect_webvtt_timestamp(position.by_ref())?;
550 // Step 5. Skip whitespace.
551 skip_whitespace(&mut position);
552 // Step 6. If the character at position is not a U+002D HYPHEN-MINUS character (-)
553 // then abort these steps and return failure.
554 // Otherwise, move position forwards one character.
555 let _ = position.next().filter(|c| *c == '\u{002D}')?;
556 // Step 7. If the character at position is not a U+002D HYPHEN-MINUS character (-)
557 // then abort these steps and return failure.
558 // Otherwise, move position forwards one character.
559 let _ = position.next().filter(|c| *c == '\u{002D}')?;
560 // Step 8. If the character at position is not a U+003E GREATER-THAN SIGN character (>)
561 // then abort these steps and return failure.
562 // Otherwise, move position forwards one character.
563 let _ = position.next().filter(|c| *c == '\u{003E}')?;
564 // Step 9. Skip whitespace.
565 skip_whitespace(&mut position);
566 // Step 10. Collect a WebVTT timestamp. If that algorithm fails,
567 // then abort these steps and return failure.
568 // Otherwise, let cue’s text track cue end time be the collected time.
569 let end_time = collect_webvtt_timestamp(position.by_ref())?;
570 // Step 11. Let remainder be the trailing substring of input starting at position.
571 let remainder = collect_for_closure(&mut position, |_| true);
572 // Step 12. Parse the WebVTT cue settings from remainder using regions for cue.
573 let cue = WebVttCue {
574 identifier,
575 start_time,
576 end_time,
577 ..Default::default()
578 };
579 Some(parse_the_webvtt_cue_settings(cue, remainder))
580}
581
582/// <https://w3c.github.io/webvtt/#collect-a-webvtt-timestamp>
583fn collect_webvtt_timestamp(position: &mut Peekable<Chars<'_>>) -> Option<f64> {
584 // Step 1. Let input and position be the same variables
585 // as those of the same name in the algorithm that invoked these steps.
586 //
587 // Passed in as argument
588
589 // Step 2. Let most significant units be minutes.
590 let mut most_significant_units = MostSignificantUnits::Minutes;
591 // Step 3. If position is past the end of input, return an error and abort these steps.
592 // Step 4. If the character indicated by position is not an ASCII digit,
593 // then return an error and abort these steps.
594 if !position.peek()?.is_ascii_digit() {
595 return None;
596 }
597 // Step 5. Collect a sequence of code points that are ASCII digits,
598 // and let string be the collected substring.
599 let string = collect_ascii_digits(position);
600 // Step 6. Interpret string as a base-ten integer. Let value1 be that integer.
601 let mut value_1 = string.parse::<f64>().ok()?;
602 // Step 7. If string is not exactly two characters in length,
603 // or if value1 is greater than 59, let most significant units be hours.
604 if string.len() != 2 || value_1 > 59_f64 {
605 most_significant_units = MostSignificantUnits::Hours;
606 }
607 // Step 8. If position is beyond the end of input or if the character at position is
608 // not a U+003A COLON character (:), then return an error and abort these steps.
609 // Otherwise, move position forwards one character.
610 let _ = position.next().filter(|c| *c == '\u{003A}')?;
611 // Step 9. Collect a sequence of code points that are ASCII digits,
612 // and let string be the collected substring.
613 let string = collect_ascii_digits(position);
614 // Step 10. If string is not exactly two characters in length,
615 // return an error and abort these steps.
616 if string.len() != 2 {
617 return None;
618 }
619 // Step 11. Interpret string as a base-ten integer. Let value2 be that integer.
620 let mut value_2 = string.parse::<f64>().ok()?;
621 // Step 12. If most significant units is hours,
622 // or if position is not beyond the end of input and the character
623 // at position is a U+003A COLON character (:), run these substeps:
624 let value_3: f64;
625 if most_significant_units == MostSignificantUnits::Hours ||
626 position.peek().is_some_and(|c| *c == '\u{003A}')
627 {
628 // Step 12.1. If position is beyond the end of input or if
629 // the character at position is not a U+003A COLON character (:),
630 // then return an error and abort these steps.
631 // Otherwise, move position forwards one character.
632 position.next().filter(|c| *c == '\u{003A}')?;
633 // Step 12.2. Collect a sequence of code points that are ASCII digits,
634 // and let string be the collected substring.
635 let string = collect_ascii_digits(position);
636 // Step 12.3. If string is not exactly two characters in length,
637 // return an error and abort these steps.
638 if string.len() != 2 {
639 return None;
640 }
641 // Step 12.4. Interpret string as a base-ten integer. Let value3 be that integer.
642 value_3 = string.parse::<f64>().ok()?;
643 } else {
644 // Otherwise (if most significant units is not hours,
645 // and either position is beyond the end of input,
646 // or the character at position is not a U+003A COLON character (:)),
647 // let value3 have the value of value2,
648 // then value2 have the value of value1, then let value1 equal zero.
649 value_3 = value_2;
650 value_2 = value_1;
651 value_1 = 0_f64;
652 }
653 // Step 13. If position is beyond the end of input or if the character at
654 // position is not a U+002E FULL STOP character (.),
655 // then return an error and abort these steps.
656 // Otherwise, move position forwards one character.
657 position.next().filter(|c| *c == '\u{002E}')?;
658 // Step 14. Collect a sequence of code points that are ASCII digits,
659 // and let string be the collected substring.
660 let string = collect_ascii_digits(position);
661 // Step 15. If string is not exactly three characters in length,
662 // return an error and abort these steps.
663 if string.len() != 3 {
664 return None;
665 }
666 // Step 16. Interpret string as a base-ten integer. Let value4 be that integer.
667 let value_4 = string.parse::<f64>().ok()?;
668 // Step 17. If value2 is greater than 59 or if value3 is greater than 59,
669 // return an error and abort these steps.
670 if value_2 > 59_f64 || value_3 > 59_f64 {
671 return None;
672 }
673 // Step 18. Let result be value1×60×60 + value2×60 + value3 + value4∕1000.
674 // Step 19. Return result.
675 Some(value_1 * 60_f64 * 60_f64 + value_2 * 60_f64 + value_3 + value_4 / 1000_f64)
676}
677
678/// <https://w3c.github.io/webvtt/#parse-the-webvtt-cue-settings>
679fn parse_the_webvtt_cue_settings(mut cue: WebVttCue, input: String) -> WebVttCue {
680 // Step 1. Let settings be the result of splitting input on spaces.
681 let settings = input.split_ascii_whitespace();
682 // Step 2. For each token setting in the list settings, run the following substeps:
683 'next_setting: for setting in settings {
684 // Step 2.2. Let name be the leading substring of setting up to
685 // and excluding the first U+003A COLON character (:) in that string.
686 // Step 2.3. Let value be the trailing substring of setting starting from the
687 // character immediately after the first U+003A COLON character (:) in that string.
688 let Some((name, value)) = setting.split_once('\u{003A}') else {
689 // Step 2.1. If setting does not contain a U+003A COLON character (:),
690 // or if the first U+003A COLON character (:) in setting is either
691 // the first or last character of setting,
692 // then jump to the step labeled next setting.
693 //
694 // We check the first part here
695 continue 'next_setting;
696 };
697 // Step 2.1. If setting does not contain a U+003A COLON character (:),
698 // or if the first U+003A COLON character (:) in setting is either
699 // the first or last character of setting,
700 // then jump to the step labeled next setting.
701 //
702 // We check the second part here
703 if name.is_empty() || value.is_empty() {
704 continue 'next_setting;
705 }
706 // Step 2.4. Run the appropriate substeps that apply for the value of name, as follows:
707 match name {
708 // > If name is a case-sensitive match for "vertical"
709 "vertical" => {
710 // Step 2.4."vertical".1. If value is a case-sensitive match for the string "rl",
711 // then let cue’s WebVTT cue writing direction be vertical growing left.
712 if value == "rl" {
713 cue.writing_direction = WebVttWritingDirection::VerticalGrowingLeft;
714 }
715 // Step 2.4."vertical".2. Otherwise, if value is a case-sensitive match for the string "lr",
716 // then let cue’s WebVTT cue writing direction be vertical growing right.
717 if value == "lr" {
718 cue.writing_direction = WebVttWritingDirection::VerticalGrowingRight;
719 }
720 // Step 2.4."vertical".3. If cue’s WebVTT cue writing direction is not horizontal,
721 // let cue’s WebVTT cue region be null (there are no vertical regions).
722 // TODO
723 },
724 // > If name is a case-sensitive match for "line"
725 "line" => {
726 // Step 2.4."line".1. If value contains a U+002C COMMA character (,),
727 // then let linepos be the leading substring of value up to and excluding the
728 // first U+002C COMMA character (,) in that string and let linealign be the
729 // trailing substring of value starting from the character immediately after the
730 // first U+002C COMMA character (,) in that string.
731 // Step 2.4."line".2. Otherwise let linepos be the full value string and linealign be null.
732 let (linepos, linealign) = value
733 .split_once('\u{002C}')
734 .map(|(linepos, linealign)| (linepos, Some(linealign)))
735 .unwrap_or((value, None));
736
737 // Step 2.4."line".4. If the last character in linepos is a U+0025 PERCENT SIGN character (%)
738 let last_char_is_percentage =
739 linepos.chars().last().is_some_and(|c| c == '\u{0025}');
740 let number = if last_char_is_percentage {
741 // If parse a percentage string from linepos doesn’t fail,
742 // let number be the returned percentage, otherwise jump to the step labeled next setting.
743 let Some(number) = parse_a_percentage_string(linepos) else {
744 continue 'next_setting;
745 };
746 number
747 } else {
748 let mut chars = linepos.chars().peekable();
749 let mut has_at_least_one_dot = false;
750 let mut last_char: Option<char> = None;
751 let mut at_least_one_digit = false;
752 while let Some(current_char) = chars.next() {
753 match current_char {
754 // Step 2.4."line".4.2. If any character in linepos other than the first character is
755 // a U+002D HYPHEN-MINUS character (-), then jump to the step labeled next setting.
756 '\u{002D}' => {
757 if last_char.is_some() {
758 continue 'next_setting;
759 }
760 },
761 '\u{002E}' => {
762 // Step 2.4."line".4.3. If there are more than one U+002E DOT characters (.),
763 // then jump to the step labeled next setting.
764 if has_at_least_one_dot {
765 continue 'next_setting;
766 }
767 has_at_least_one_dot = true;
768 // Step 2.4."line".4.4. If there is a U+002E DOT character (.)
769 // and the character before or the character after is not an ASCII digit,
770 // or if the U+002E DOT character (.) is the first or the last character,
771 // then jump to the step labeled next setting.
772 if last_char.is_none_or(|c| !c.is_ascii_digit()) ||
773 chars.peek().is_none_or(|c| !c.is_ascii_digit())
774 {
775 continue 'next_setting;
776 }
777 },
778 _ => {
779 // Step 2.4."line".4.1. If linepos contains any characters other than
780 // U+002D HYPHEN-MINUS characters (-), ASCII digits, and U+002E DOT character (.),
781 // then jump to the step labeled next setting.
782 if !current_char.is_ascii_digit() {
783 continue 'next_setting;
784 }
785 at_least_one_digit = true;
786 },
787 }
788 last_char = Some(current_char);
789 }
790 // Step 2.4."line".3. If linepos does not contain at least one ASCII digit,
791 // then jump to the step labeled next setting.
792 if !at_least_one_digit {
793 continue 'next_setting;
794 }
795 // Step 2.4."line".4.5. Let number be the result of parsing linepos using the
796 // rules for parsing floating-point number values. [HTML]
797 let Some(number) = linepos.parse::<f64>().ok() else {
798 // Step 2.4."line".4.6. If number is an error,
799 // then jump to the step labeled next setting.
800 continue 'next_setting;
801 };
802 number
803 };
804 match linealign {
805 // Step 2.4."line".5. If linealign is a case-sensitive match for the string "start",
806 // then let cue’s WebVTT cue line alignment be start alignment.
807 Some("start") => {
808 cue.line_alignment = WebVttLineAlignment::Start;
809 },
810 // Step 2.4."line".6. If linealign is a case-sensitive match for the string "center",
811 // then let cue’s WebVTT cue line alignment be center alignment.
812 Some("center") => {
813 cue.line_alignment = WebVttLineAlignment::Center;
814 },
815 // Step 2.4."line".7. If linealign is a case-sensitive match for the string "end",
816 // then let cue’s WebVTT cue line alignment be end alignment.
817 Some("end") => {
818 cue.line_alignment = WebVttLineAlignment::End;
819 },
820 // Step 2.4."line".8. Otherwise, if linealign is not null,
821 // then jump to the step labeled next setting.
822 Some(_) => {
823 continue 'next_setting;
824 },
825 _ => {},
826 }
827 // Step 2.4."line".9. Let cue’s WebVTT cue line be number.
828 cue.line = WebVttLineAndPositionSetting::Double(number);
829 // Step 2.4."line".10. If the last character in linepos is a U+0025 PERCENT SIGN character (%),
830 // then let cue’s WebVTT cue snap-to-lines flag be false. Otherwise, let it be true.
831 cue.snap_to_lines = (!last_char_is_percentage).into();
832 // If cue’s WebVTT cue line is not auto,
833 // let cue’s WebVTT cue region be null
834 // (the cue has been explicitly positioned with a line offset
835 // and thus drops out of the region).
836 // TODO
837 },
838 // > If name is a case-sensitive match for "position"
839 "position" => {
840 // Step 2.4."position".1. If value contains a U+002C COMMA character (,),
841 // then let colpos be the leading substring of value up to and excluding the
842 // first U+002C COMMA character (,) in that string and let colalign be the
843 // trailing substring of value starting from the character immediately after the
844 // first U+002C COMMA character (,) in that string.
845 // Step 2.4."position".2. Otherwise let colpos be the full value string and colalign be null.
846 let (colpos, colalign) = value
847 .split_once('\u{002C}')
848 .map(|(colpos, colalign)| (colpos, Some(colalign)))
849 .unwrap_or((value, None));
850 // Step 2.4."position".3. If parse a percentage string from colpos doesn’t fail,
851 // let number be the returned percentage,
852 // otherwise jump to the step labeled next setting
853 // (position’s value remains the special value auto).
854 let Some(number) = parse_a_percentage_string(colpos) else {
855 continue 'next_setting;
856 };
857 match colalign {
858 // Step 2.4."position".4. If colalign is a case-sensitive match for the string "line-left",
859 // then let cue’s WebVTT cue position alignment be line-left alignment.
860 Some("line-left") => {
861 cue.position_alignment = WebVttPositionAlignment::LineLeft;
862 },
863 // Step 2.4."position".5. Otherwise, if colalign is a case-sensitive match for the string "center",
864 // then let cue’s WebVTT cue position alignment be center alignment.
865 Some("center") => {
866 cue.position_alignment = WebVttPositionAlignment::Center;
867 },
868 // Step 2.4."position".6. Otherwise, if colalign is a case-sensitive match for the string "line-right",
869 // then let cue’s WebVTT cue position alignment be line-right alignment.
870 Some("line-right") => {
871 cue.position_alignment = WebVttPositionAlignment::LineRight;
872 },
873 // Step 2.4."position".7. Otherwise, if colalign is not null,
874 // then jump to the step labeled next setting.
875 Some(_) => {
876 continue 'next_setting;
877 },
878 _ => {},
879 }
880 // Step 2.4."position".8. Let cue’s position be number.
881 cue.position = WebVttLineAndPositionSetting::Double(number);
882 },
883 // > If name is a case-sensitive match for "size"
884 "size" => {
885 // Step 2.4."size".1. If parse a percentage string from value doesn’t fail,
886 // let number be the returned percentage,
887 // otherwise jump to the step labeled next setting.
888 let Some(number) = parse_a_percentage_string(value) else {
889 continue 'next_setting;
890 };
891 // Step 2.4."size".2. Let cue’s WebVTT cue size be number.
892 cue.size = WebVttCueSize(number);
893 // Step 2.4."size".3. If cue’s WebVTT cue size is not 100,
894 // let cue’s WebVTT cue region be null
895 // (the cue has been explicitly sized and thus drops out of the region).
896 // TODO
897 },
898 // > If name is a case-sensitive match for "align"
899 "align" => {
900 // Step 2.4."align".1. If value is a case-sensitive match for the string "start",
901 // then let cue’s WebVTT cue text alignment be start alignment.
902 if value == "start" {
903 cue.text_alignment = WebVttTextAlignment::Start;
904 }
905 // Step 2.4."align".2. If value is a case-sensitive match for the string "center",
906 // then let cue’s WebVTT cue text alignment be center alignment.
907 if value == "center" {
908 cue.text_alignment = WebVttTextAlignment::Center;
909 }
910 // Step 2.4."align".3. If value is a case-sensitive match for the string "end",
911 // then let cue’s WebVTT cue text alignment be end alignment.
912 if value == "end" {
913 cue.text_alignment = WebVttTextAlignment::End;
914 }
915 // Step 2.4."align".4. If value is a case-sensitive match for the string "left",
916 // then let cue’s WebVTT cue text alignment be left alignment.
917 if value == "left" {
918 cue.text_alignment = WebVttTextAlignment::Left;
919 }
920 // Step 2.4."align".5. If value is a case-sensitive match for the string "right",
921 // then let cue’s WebVTT cue text alignment be right alignment.
922 if value == "right" {
923 cue.text_alignment = WebVttTextAlignment::Right;
924 }
925 },
926 _ => {},
927 }
928 }
929 cue
930}
931
932fn collect_for_closure<F>(position: &mut Peekable<Chars<'_>>, f: F) -> String
933where
934 F: FnOnce(&char) -> bool + Copy,
935{
936 let mut string = String::new();
937 while let Some(next) = position.next_if(f) {
938 string.push(next);
939 }
940 string
941}
942
943fn collect_ascii_digits(position: &mut Peekable<Chars<'_>>) -> String {
944 collect_for_closure(position, char::is_ascii_digit)
945}
946
947fn skip_whitespace(position: &mut Peekable<Chars<'_>>) {
948 collect_for_closure(position, |c| matches!(c, '\r' | '\n' | '\t' | ' '));
949}
950
951/// <https://w3c.github.io/webvtt/#webvtt-percentage>
952static WEB_VTT_PERCENTAGE_GRAMMAR: LazyLock<Regex> =
953 LazyLock::new(|| Regex::new(r#"^(?P<number>[0-9]+(\.[0-9]+)?)%$"#).unwrap());
954
955/// <https://w3c.github.io/webvtt/#parse-a-percentage-string>
956fn parse_a_percentage_string(input: &str) -> Option<f64> {
957 // Step 1. Let input be the string being parsed.
958 //
959 // Passed in as argument
960
961 // Step 2. If input does not match the syntax for a WebVTT percentage, then fail.
962 let captures = WEB_VTT_PERCENTAGE_GRAMMAR.captures(input)?;
963 // Step 3. Remove the last character from input.
964 let input = captures.name("number").expect("Must always have a capture");
965 // Step 4. Let percentage be the result of parsing input using the rules for parsing floating-point number values. [HTML]
966 // Step 5. If percentage is an error, is less than 0, or is greater than 100, then fail.
967 // Step 6. Return percentage.
968 input
969 .as_str()
970 .trim()
971 .parse::<f64>()
972 .ok()
973 .filter(|percentage| *percentage >= 0. && *percentage <= 100.)
974}
975
976#[cfg(any(test, feature = "test-util"))]
977pub mod shared_test_setup;
978
979#[cfg(test)]
980mod tests {
981 use crate::WebVttParserError;
982 use crate::shared_test_setup::{compute_result_in_seconds, parser_with_dummy_sink};
983
984 #[test]
985 fn test_header_in_two_chunks() {
986 let mut parser = parser_with_dummy_sink();
987 assert_eq!(parser.parse(&mut (), "WEB"), Ok(()));
988 assert_eq!(parser.parse(&mut (), "VTT"), Ok(()));
989 assert_eq!(parser.end(&mut ()), Ok(()));
990 }
991
992 #[test]
993 fn test_invalid_header_in_two_chunks() {
994 let mut parser = parser_with_dummy_sink();
995 assert_eq!(parser.parse(&mut (), "WEB"), Ok(()));
996 assert_eq!(
997 parser.parse(&mut (), "NOT"),
998 Err(WebVttParserError::InvalidHeader)
999 );
1000 }
1001
1002 #[test]
1003 fn test_valid_space_character_after_header() {
1004 let mut parser = parser_with_dummy_sink();
1005 assert_eq!(parser.parse_sync(&mut (), "WEBVTT "), Ok(()));
1006 }
1007
1008 #[test]
1009 fn test_no_space_character_after_header_multiple_chunks() {
1010 let mut parser = parser_with_dummy_sink();
1011 assert_eq!(parser.parse(&mut (), "WEB"), Ok(()));
1012 assert_eq!(parser.parse(&mut (), "VTT"), Ok(()));
1013 assert_eq!(
1014 parser.parse(&mut (), "2"),
1015 Err(WebVttParserError::InvalidHeader)
1016 );
1017 }
1018
1019 mod cue_settings {
1020 use crate::tests::compute_result_in_seconds;
1021 use crate::{WebVttCue, collect_webvtt_cue_timings_and_settings};
1022
1023 #[test]
1024 fn test_parses_cue_correctly() {
1025 assert_eq!(
1026 collect_webvtt_cue_timings_and_settings(
1027 Default::default(),
1028 "01:10:03.000 --> 02:20:23.000"
1029 ),
1030 Some(WebVttCue {
1031 start_time: compute_result_in_seconds(1., 10., 3., 0.),
1032 end_time: compute_result_in_seconds(2., 20., 23., 0.),
1033 ..Default::default()
1034 })
1035 );
1036 }
1037
1038 #[test]
1039 fn test_does_not_require_whitespace_around_arrow() {
1040 assert_eq!(
1041 collect_webvtt_cue_timings_and_settings(
1042 Default::default(),
1043 "01:10:03.000-->02:20:23.000"
1044 ),
1045 Some(WebVttCue {
1046 start_time: compute_result_in_seconds(1., 10., 3., 0.),
1047 end_time: compute_result_in_seconds(2., 20., 23., 0.),
1048 ..Default::default()
1049 })
1050 );
1051 }
1052
1053 #[test]
1054 fn test_can_handle_tabs_around_arrow() {
1055 assert_eq!(
1056 collect_webvtt_cue_timings_and_settings(
1057 Default::default(),
1058 "01:10:03.000\t-->\t02:20:23.000"
1059 ),
1060 Some(WebVttCue {
1061 start_time: compute_result_in_seconds(1., 10., 3., 0.),
1062 end_time: compute_result_in_seconds(2., 20., 23., 0.),
1063 ..Default::default()
1064 })
1065 );
1066 }
1067
1068 #[test]
1069 fn test_arrow_too_short_is_invalid() {
1070 assert_eq!(
1071 collect_webvtt_cue_timings_and_settings(
1072 Default::default(),
1073 "01:10:03.000 -> t02:20:23.000"
1074 ),
1075 None
1076 );
1077 }
1078
1079 #[test]
1080 fn test_arrow_too_long_is_invalid() {
1081 assert_eq!(
1082 collect_webvtt_cue_timings_and_settings(
1083 Default::default(),
1084 "01:10:03.000 ---> t02:20:23.000"
1085 ),
1086 None
1087 );
1088 }
1089
1090 #[test]
1091 fn test_skips_whitespace_at_start() {
1092 assert_eq!(
1093 collect_webvtt_cue_timings_and_settings(
1094 Default::default(),
1095 " 01:10:03.000 --> 02:20:23.000"
1096 ),
1097 Some(WebVttCue {
1098 start_time: compute_result_in_seconds(1., 10., 3., 0.),
1099 end_time: compute_result_in_seconds(2., 20., 23., 0.),
1100 ..Default::default()
1101 })
1102 );
1103 }
1104 }
1105
1106 mod timestamp {
1107 use crate::collect_webvtt_timestamp;
1108 use crate::tests::compute_result_in_seconds;
1109
1110 fn parse_timestamp(input: &str) -> Option<f64> {
1111 collect_webvtt_timestamp(&mut input.chars().peekable())
1112 }
1113
1114 #[test]
1115 fn test_parses_start_timestamp_correctly() {
1116 assert_eq!(
1117 parse_timestamp("01:10:03.000"),
1118 Some(compute_result_in_seconds(1., 10., 3., 0.))
1119 );
1120 }
1121
1122 #[test]
1123 fn test_parses_maximum_minute_timestamp() {
1124 assert_eq!(
1125 parse_timestamp("10:59:03.000"),
1126 Some(compute_result_in_seconds(10., 59., 3., 0.))
1127 );
1128 }
1129
1130 #[test]
1131 fn test_parses_maximum_second_timestamp() {
1132 assert_eq!(
1133 parse_timestamp("10:04:59.000"),
1134 Some(compute_result_in_seconds(10., 4., 59., 0.))
1135 );
1136 }
1137
1138 #[test]
1139 fn test_hours_more_than_59_ensures_three_values() {
1140 assert_eq!(parse_timestamp("60:04.000"), None);
1141 }
1142
1143 #[test]
1144 fn test_first_value_below_60_implies_minutes() {
1145 assert_eq!(
1146 parse_timestamp("59:04.000"),
1147 Some(compute_result_in_seconds(0., 59., 4., 0.))
1148 );
1149 }
1150
1151 #[test]
1152 fn test_seconds_cannot_exceed_59() {
1153 assert_eq!(parse_timestamp("05:60.000"), None);
1154 }
1155
1156 #[test]
1157 fn test_minutes_cannot_exceed_59() {
1158 assert_eq!(parse_timestamp("05:60:35.000"), None);
1159 }
1160
1161 #[test]
1162 fn test_hours_can_exceed_59() {
1163 assert_eq!(
1164 parse_timestamp("60:20:35.000"),
1165 Some(compute_result_in_seconds(60., 20., 35., 0.))
1166 );
1167 }
1168 }
1169}