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(¤t_tendril);
338 self.current_line_in_block.push_tendril(¤t_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}