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::marker::PhantomData;
6use std::mem;
7
8use html5ever::buffer_queue::{BufferQueue, SetResult};
9use html5ever::tendril::StrTendril;
10use markup5ever::small_char_set;
11
12mod collectors;
13pub mod cue;
14
15use collectors::collect_webvtt_cue_timings_and_settings;
16
17use crate::cue::settings::WebVttCue;
18
19#[derive(Debug, PartialEq)]
20pub enum WebVttParserError {
21    InvalidHeader,
22}
23
24impl std::fmt::Display for WebVttParserError {
25    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            WebVttParserError::InvalidHeader => write!(formatter, "Invalid WebVTT header in file"),
28        }
29    }
30}
31
32#[derive(Clone, Copy, Default, PartialEq)]
33enum ParserState {
34    #[default]
35    FileTag,
36    WhitespaceAfterFileTag,
37    BeforeNewlineAfterFileTag,
38    BeforeHeader,
39    InBlockLoop,
40    AfterBlockLoop,
41    Region,
42    Finished,
43}
44
45pub trait WebVttParserSink<Context> {
46    fn consume_cue(&self, cx: &mut Context, cue: WebVttCue);
47}
48
49#[derive(Default)]
50pub struct IncrementalWebVTTParser<Context, Sink: WebVttParserSink<Context>> {
51    phantom: PhantomData<Context>,
52    pub sink: Sink,
53    buffer: BufferQueue,
54
55    // Checkpoint values
56    seen_cue: bool,
57    seen_eof: bool,
58    seen_arrow: bool,
59
60    // State values
61    in_header: bool,
62    line_count: u32,
63    state: ParserState,
64
65    // Storage values
66    current_line_in_block: StrTendril,
67    current_buffer_in_block: String,
68    lines_from_previous_position: StrTendril,
69
70    current_cue_in_block: Option<WebVttCue>,
71}
72
73pub type ParserUpdate = Result<(), WebVttParserError>;
74
75impl<Context, Sink> IncrementalWebVTTParser<Context, Sink>
76where
77    Sink: WebVttParserSink<Context>,
78{
79    /// <https://w3c.github.io/webvtt/#webvtt-parser-algorithm>
80    pub fn new(sink: Sink) -> Self {
81        Self {
82            sink,
83            phantom: Default::default(),
84            buffer: Default::default(),
85            seen_cue: Default::default(),
86            seen_eof: Default::default(),
87            seen_arrow: Default::default(),
88            in_header: Default::default(),
89            line_count: Default::default(),
90            state: Default::default(),
91            current_line_in_block: Default::default(),
92            current_buffer_in_block: Default::default(),
93            current_cue_in_block: Default::default(),
94            lines_from_previous_position: Default::default(),
95        }
96    }
97
98    pub fn end(&mut self, cx: &mut Context) -> ParserUpdate {
99        self.seen_eof = true;
100        self.step(cx)
101    }
102
103    pub fn parse_sync(&mut self, cx: &mut Context, input: &str) -> ParserUpdate {
104        self.seen_eof = true;
105        self.parse(cx, input)
106    }
107
108    /// <https://w3c.github.io/webvtt/#webvtt-parser-algorithm>
109    pub fn parse(&mut self, cx: &mut Context, input: &str) -> ParserUpdate {
110        // Step 1. Let input be the string being parsed, after conversion to Unicode,
111        // and with the following transformations applied:
112        // > Replace all U+0000 NULL characters by U+FFFD REPLACEMENT CHARACTERs.
113        // > Replace each U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair
114        // > by a single U+000A LINE FEED (LF) character.
115        // > Replace all remaining U+000D CARRIAGE RETURN characters by U+000A LINE FEED (LF) characters.
116        // TODO
117        // Step 2. Let position be a pointer into input, initially pointing at the start of the string.
118        // In an incremental WebVTT parser, when this algorithm (or further algorithms that it uses)
119        // moves the position pointer, the user agent must wait until appropriate further characters
120        // from the byte stream have been added to input before moving the pointer,
121        // so that the algorithm never reads past the end of the input string.
122        // Once the byte stream has ended, and all characters have been added to input,
123        // then the position pointer may, when so instructed by the algorithms,
124        // be moved past the end of input.
125        self.buffer.push_back(StrTendril::from(input));
126        self.step(cx)
127    }
128
129    fn step(&mut self, cx: &mut Context) -> ParserUpdate {
130        loop {
131            let current_state = self.state;
132            match current_state {
133                // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
134                ParserState::FileTag => {
135                    // https://w3c.github.io/webvtt/#webvtt-file-body
136                    // > An optional U+FEFF BYTE ORDER MARK (BOM) character.
137                    if self.buffer.peek().is_some_and(|c| c == '\u{FEFF}') {
138                        let _ = self.buffer.next();
139                    }
140                    let Some(input) = self.buffer.eat("WEBVTT", u8::eq) else {
141                        // Step 4. If input is less than six characters long, then abort these steps.
142                        // The file does not start with the correct WebVTT file signature
143                        // and was therefore not successfully processed.
144                        if self.seen_eof {
145                            return Err(WebVttParserError::InvalidHeader);
146                        }
147                        return Ok(());
148                    };
149                    // Step 5. If input is exactly six characters long but does not exactly equal "WEBVTT",
150                    // then abort these steps. The file does not start with the correct WebVTT
151                    // file signature and was therefore not successfully processed.
152                    // Step 6. If input is more than six characters long but the first six characters
153                    // do not exactly equal "WEBVTT", or the seventh character is not a U+0020 SPACE character,
154                    // a U+0009 CHARACTER TABULATION (tab) character, or a U+000A LINE FEED (LF) character,
155                    // then abort these steps. The file does not start with the correct WebVTT file signature
156                    // and was therefore not successfully processed.
157                    //
158                    // We check the first part of this step here
159                    if !input {
160                        return Err(WebVttParserError::InvalidHeader);
161                    }
162                    self.state = ParserState::WhitespaceAfterFileTag;
163                    continue;
164                },
165                // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
166                ParserState::WhitespaceAfterFileTag => {
167                    let Some(seventh) = self.buffer.peek() else {
168                        // Input is exactly six characters and is a valid header
169                        if self.seen_eof {
170                            self.state = ParserState::Finished;
171                            continue;
172                        }
173                        return Ok(());
174                    };
175                    // Step 6. If input is more than six characters long but the first six characters
176                    // do not exactly equal "WEBVTT", or the seventh character is not a U+0020 SPACE character,
177                    // a U+0009 CHARACTER TABULATION (tab) character, or a U+000A LINE FEED (LF) character,
178                    // then abort these steps. The file does not start with the correct WebVTT file signature
179                    // and was therefore not successfully processed.
180                    //
181                    // We check the second part of this step here
182                    if !matches!(seventh, '\u{0020}' | '\u{0009}' | '\u{000A}') {
183                        return Err(WebVttParserError::InvalidHeader);
184                    }
185                    self.state = ParserState::BeforeNewlineAfterFileTag;
186                    continue;
187                },
188                // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
189                ParserState::BeforeNewlineAfterFileTag => {
190                    // Step 7. collect a sequence of code points that are not U+000A LINE FEED (LF) characters.
191                    let Some(current_char) = self.buffer.next() else {
192                        // Step 8. If position is past the end of input, then abort these steps.
193                        // The file was successfully processed, but it contains no useful data and so
194                        // no WebVTT cues were added to output.
195                        if self.seen_eof {
196                            self.state = ParserState::Finished;
197                            continue;
198                        }
199                        return Ok(());
200                    };
201                    // Step 9. The character indicated by position is a U+000A LINE FEED (LF) character.
202                    // Advance position to the next character in input.
203                    if current_char == '\u{000A}' {
204                        self.state = ParserState::BeforeHeader;
205                    }
206                },
207                // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
208                ParserState::BeforeHeader => {
209                    let Some(current_char) = self.buffer.peek() else {
210                        // Step 10. If position is past the end of input, then abort these steps.
211                        // The file was successfully processed, but it contains no useful data and
212                        // so no WebVTT cues were added to output.
213                        if self.seen_eof {
214                            self.state = ParserState::Finished;
215                            continue;
216                        }
217                        return Ok(());
218                    };
219                    // Step 11. Header: If the character indicated by position is not
220                    // a U+000A LINE FEED (LF) character,
221                    // then collect a WebVTT block with the in header flag set.
222                    // Otherwise, advance position to the next character in input.
223                    if current_char != '\u{000A}' {
224                        self.in_header = true;
225                        self.start_collecting_webvtt_block();
226                    } else {
227                        self.buffer.next();
228                        self.state = ParserState::Region;
229                    }
230                },
231                // https://w3c.github.io/webvtt/#collect-a-webvtt-block
232                ParserState::InBlockLoop => {
233                    let Some(current_char) =
234                        self.buffer.pop_except_from(small_char_set!('\u{000A}'))
235                    else {
236                        // Step 11.3. If position is past the end of input, let seen EOF be true.
237                        // Otherwise, the character indicated by position is a U+000A LINE FEED (LF) character;
238                        // advance position to the next character in input.
239                        //
240                        // We check the first part of this step here
241                        // Step 11.7. If seen EOF is true, break out of loop.
242                        if self.seen_eof {
243                            // It depends when we see the EOF whether there is still content in the buffer or not.
244                            // In the case that the EOF is at the end of a line (e.g. no `\n` in between), we
245                            // should copy the current line to the buffer. The buffer is then populated with the
246                            // line as usual, so that the last line of a file can still be the cue text.
247                            if !self.current_line_in_block.is_empty() {
248                                if !self.current_buffer_in_block.is_empty() {
249                                    self.current_buffer_in_block.push('\n');
250                                }
251                                self.current_buffer_in_block
252                                    .push_str(&self.current_line_in_block);
253                            }
254                            self.state = ParserState::AfterBlockLoop;
255                            continue;
256                        }
257                        return Ok(());
258                    };
259                    match current_char {
260                        // Step 11.3. If position is past the end of input, let seen EOF be true.
261                        // Otherwise, the character indicated by position is a U+000A LINE FEED (LF) character;
262                        // advance position to the next character in input.
263                        //
264                        // We check the second part of this step here
265                        SetResult::FromSet('\u{000A}') => {
266                            self.lines_from_previous_position.push_char('\u{000A}');
267                            // Step 11.2. Increment line count by 1.
268                            self.line_count += 1;
269                            // Step 11.4. If line contains the three-character substring "-->"
270                            // (U+002D HYPHEN-MINUS, U+002D HYPHEN-MINUS, U+003E GREATER-THAN SIGN),
271                            // then run these substeps:
272                            if self.current_line_in_block.contains("-->") {
273                                // Step 11.4.1. If in header is not set and at least
274                                // one of the following conditions are true:
275                                if !self.in_header &&
276                                    (
277                                        // line count is 1
278                                        self.line_count == 1
279                                    // line count is 2 and seen arrow is false
280                                    || (self.line_count == 2 && !self.seen_arrow)
281                                    )
282                                {
283                                    // Step 11.4.1.1. Let seen arrow be true.
284                                    self.seen_arrow = true;
285                                    // Step 11.4.1.2. Let previous position be position.
286                                    self.lines_from_previous_position.clear();
287                                    // Step 11.4.1.3. Cue creation: Let cue be a new WebVTT cue and initialize it as follows:
288                                    // Step 11.4.1.3.1. Let cue’s text track cue identifier be buffer.
289                                    let identifier = self.current_buffer_in_block.clone();
290                                    // Step 11.4.1.4. Collect WebVTT cue timings and settings from line using regions for cue.
291                                    // If that fails, let cue be null.
292                                    // Otherwise, let buffer be the empty string and let seen cue be true.
293                                    let cue = collect_webvtt_cue_timings_and_settings(
294                                        identifier,
295                                        &self.current_line_in_block,
296                                    );
297                                    let has_cue = cue.is_some();
298                                    self.current_cue_in_block = cue;
299                                    if has_cue {
300                                        self.current_buffer_in_block.clear();
301                                        self.current_line_in_block.clear();
302                                        self.seen_cue = true;
303                                    }
304                                } else {
305                                    // Otherwise, let position be previous position and break out of loop.
306                                    self.state = ParserState::AfterBlockLoop;
307                                    self.buffer.push_front(std::mem::take(
308                                        &mut self.lines_from_previous_position,
309                                    ));
310                                }
311                                continue;
312                            } else if self.current_line_in_block.is_empty() {
313                                // Step 11.5. Otherwise, if line is the empty string, break out of loop.
314                                self.state = ParserState::AfterBlockLoop;
315                            } else {
316                                // Step 11.6. Otherwise, run these substeps:
317                                // Step 11.6.1. If in header is not set and line count is 2, run these substeps:
318                                // TODO
319                                // Step 11.6.2. If buffer is not the empty string,
320                                // append a U+000A LINE FEED (LF) character to buffer.
321                                if !self.current_buffer_in_block.is_empty() {
322                                    self.current_buffer_in_block.push('\u{000A}');
323                                }
324                                // Step 11.6.3. Append line to buffer.
325                                self.current_buffer_in_block
326                                    .push_str(&self.current_line_in_block);
327                                // Step 11.6.4. Let previous position be position.
328                                self.current_line_in_block.clear();
329                                self.lines_from_previous_position.clear();
330                            }
331                            continue;
332                        },
333                        // Step 11.1. collect a sequence of code points that are not U+000A LINE FEED (LF) characters.
334                        // Let line be those characters, if any.
335                        SetResult::NotFromSet(current_tendril) => {
336                            self.lines_from_previous_position
337                                .push_tendril(&current_tendril);
338                            self.current_line_in_block.push_tendril(&current_tendril);
339                        },
340                        _ => {
341                            unreachable!();
342                        },
343                    }
344                },
345                ParserState::AfterBlockLoop => {
346                    // https://w3c.github.io/webvtt/#collect-a-webvtt-block
347                    // Step 12. If cue is not null, let the cue text of cue be buffer, and return cue.
348                    // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
349                    // Step 14.2. If block is a WebVTT cue, add block to the text track list of cues output.
350                    if let Some(mut cue) = self.current_cue_in_block.take() {
351                        cue.text = self.current_buffer_in_block.clone();
352                        self.sink.consume_cue(cx, cue);
353                    }
354                    // Step 14.3. Otherwise, if block is a CSS style sheet, add block to stylesheets.
355                    // TODO
356                    // Step 14.4. Otherwise, if block is a WebVTT region object, add block to regions.
357                    // TODO
358                    // Step 14.5. collect a sequence of code points that are U+000A LINE FEED (LF) characters.
359                    let Some(current_char) = self.buffer.peek() else {
360                        if self.seen_eof {
361                            self.state = ParserState::Finished;
362                            continue;
363                        }
364                        return Ok(());
365                    };
366                    if current_char == '\u{000A}' {
367                        // Since we don't change the state here, it means that if the next character is
368                        // also a newline, we re-enter this block and consume it again. Therefore, we
369                        // only consume one-by-one.
370                        let _ = self.buffer.next();
371                    } else {
372                        // If we were in the header block, then we should proceed with the next step
373                        // which is collecting a region in step 12. Otherwise, we are in the general loop of
374                        // step 14.
375
376                        if mem::take(&mut self.in_header) {
377                            self.state = ParserState::Region;
378                        } else {
379                            self.start_collecting_webvtt_block();
380                        }
381                    }
382                },
383                // https://w3c.github.io/webvtt/#webvtt-parser-algorithm
384                ParserState::Region => {
385                    // Step 12. collect a sequence of code points that are U+000A LINE FEED (LF) characters.
386                    // TODO
387                    self.start_collecting_webvtt_block();
388                },
389                ParserState::Finished => {
390                    // Step 15. End: The file has ended. Abort these steps. The WebVTT parser has finished.
391                    // The file was successfully processed.
392                    return Ok(());
393                },
394            }
395        }
396    }
397
398    /// <https://w3c.github.io/webvtt/#collect-a-webvtt-block>
399    fn start_collecting_webvtt_block(&mut self) {
400        // Step 2. Let line count be zero.
401        self.line_count = 0;
402        // Step 3. Let previous position be position.
403        self.lines_from_previous_position.clear();
404        // Step 4. Let line be the empty string.
405        self.current_line_in_block.clear();
406        // Step 5. Let buffer be the empty string.
407        self.current_buffer_in_block.clear();
408        // Step 7. Let seen arrow be false.
409        self.seen_arrow = false;
410        // Step 8. Let cue be null.
411        self.current_cue_in_block = None;
412        self.state = ParserState::InBlockLoop;
413    }
414}
415
416#[cfg(any(test, feature = "test-util"))]
417pub mod shared_test_setup;
418
419#[cfg(test)]
420mod tests {
421    use crate::WebVttParserError;
422    use crate::shared_test_setup::{compute_result_in_seconds, parser_with_dummy_sink};
423
424    #[test]
425    fn test_header_in_two_chunks() {
426        let mut parser = parser_with_dummy_sink();
427        assert_eq!(parser.parse(&mut (), "WEB"), Ok(()));
428        assert_eq!(parser.parse(&mut (), "VTT"), Ok(()));
429        assert_eq!(parser.end(&mut ()), Ok(()));
430    }
431
432    #[test]
433    fn test_invalid_header_in_two_chunks() {
434        let mut parser = parser_with_dummy_sink();
435        assert_eq!(parser.parse(&mut (), "WEB"), Ok(()));
436        assert_eq!(
437            parser.parse(&mut (), "NOT"),
438            Err(WebVttParserError::InvalidHeader)
439        );
440    }
441
442    #[test]
443    fn test_valid_space_character_after_header() {
444        let mut parser = parser_with_dummy_sink();
445        assert_eq!(parser.parse_sync(&mut (), "WEBVTT "), Ok(()));
446    }
447
448    #[test]
449    fn test_no_space_character_after_header_multiple_chunks() {
450        let mut parser = parser_with_dummy_sink();
451        assert_eq!(parser.parse(&mut (), "WEB"), Ok(()));
452        assert_eq!(parser.parse(&mut (), "VTT"), Ok(()));
453        assert_eq!(
454            parser.parse(&mut (), "2"),
455            Err(WebVttParserError::InvalidHeader)
456        );
457    }
458
459    mod cue_settings {
460        use crate::tests::compute_result_in_seconds;
461        use crate::{WebVttCue, collect_webvtt_cue_timings_and_settings};
462
463        #[test]
464        fn test_parses_cue_correctly() {
465            assert_eq!(
466                collect_webvtt_cue_timings_and_settings(
467                    Default::default(),
468                    "01:10:03.000 --> 02:20:23.000"
469                ),
470                Some(WebVttCue {
471                    start_time: compute_result_in_seconds(1., 10., 3., 0.),
472                    end_time: compute_result_in_seconds(2., 20., 23., 0.),
473                    ..Default::default()
474                })
475            );
476        }
477
478        #[test]
479        fn test_does_not_require_whitespace_around_arrow() {
480            assert_eq!(
481                collect_webvtt_cue_timings_and_settings(
482                    Default::default(),
483                    "01:10:03.000-->02:20:23.000"
484                ),
485                Some(WebVttCue {
486                    start_time: compute_result_in_seconds(1., 10., 3., 0.),
487                    end_time: compute_result_in_seconds(2., 20., 23., 0.),
488                    ..Default::default()
489                })
490            );
491        }
492
493        #[test]
494        fn test_can_handle_tabs_around_arrow() {
495            assert_eq!(
496                collect_webvtt_cue_timings_and_settings(
497                    Default::default(),
498                    "01:10:03.000\t-->\t02:20:23.000"
499                ),
500                Some(WebVttCue {
501                    start_time: compute_result_in_seconds(1., 10., 3., 0.),
502                    end_time: compute_result_in_seconds(2., 20., 23., 0.),
503                    ..Default::default()
504                })
505            );
506        }
507
508        #[test]
509        fn test_arrow_too_short_is_invalid() {
510            assert_eq!(
511                collect_webvtt_cue_timings_and_settings(
512                    Default::default(),
513                    "01:10:03.000 -> t02:20:23.000"
514                ),
515                None
516            );
517        }
518
519        #[test]
520        fn test_arrow_too_long_is_invalid() {
521            assert_eq!(
522                collect_webvtt_cue_timings_and_settings(
523                    Default::default(),
524                    "01:10:03.000 ---> t02:20:23.000"
525                ),
526                None
527            );
528        }
529
530        #[test]
531        fn test_skips_whitespace_at_start() {
532            assert_eq!(
533                collect_webvtt_cue_timings_and_settings(
534                    Default::default(),
535                    "  01:10:03.000 --> 02:20:23.000"
536                ),
537                Some(WebVttCue {
538                    start_time: compute_result_in_seconds(1., 10., 3., 0.),
539                    end_time: compute_result_in_seconds(2., 20., 23., 0.),
540                    ..Default::default()
541                })
542            );
543        }
544    }
545}