Skip to main content

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(&current_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}