1use std::borrow::Cow;
7use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::document::html::{HtmlBlock, render_blocks_to_pages};
11use crate::error::{Error, Result};
12
13const MAX_SUBTITLE_BYTES: u64 = 64 * 1024 * 1024;
14const MAX_SUBTITLE_LINES: usize = 1_000_000;
15const MAX_SUBTITLE_LINE_BYTES: usize = 1024 * 1024;
16const MAX_CUES: usize = 100_000;
17const MAX_CUE_TEXT_BYTES: usize = 2 * 1024 * 1024;
18const MAX_TOTAL_TEXT_BYTES: usize = 32 * 1024 * 1024;
19const MAX_IDENTIFIER_BYTES: usize = 1024;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub(crate) enum SubtitleKind {
23 Srt,
24 Vtt,
25}
26
27impl SubtitleKind {
28 fn label(self) -> &'static str {
29 match self {
30 Self::Srt => "SubRip subtitles",
31 Self::Vtt => "WebVTT subtitles",
32 }
33 }
34
35 fn source_format(self) -> &'static str {
36 match self {
37 Self::Srt => "srt",
38 Self::Vtt => "vtt",
39 }
40 }
41}
42
43#[derive(Default)]
44struct ParseWarnings {
45 malformed_blocks: usize,
46 comments: usize,
47 metadata_blocks: usize,
48 cue_settings: usize,
49 inline_markup: usize,
50 controls: usize,
51}
52
53pub(crate) fn convert(
54 path: &Path,
55 options: &ConvertOptions,
56 sink: &mut dyn PageConsumer,
57 kind: SubtitleKind,
58) -> Result<Vec<String>> {
59 let bytes = read_limited_file(
60 path,
61 options.max_input_bytes.min(MAX_SUBTITLE_BYTES),
62 kind.label(),
63 )?;
64 let (text, encoding_note) = decode_subtitle_input(bytes, kind)?;
65 let (blocks, mut warnings) = parse_blocks(&text, kind)?;
66 if let Some(note) = encoding_note {
67 warnings.push(note.into());
68 }
69 let mut page_sink = SubtitlePageSink {
70 inner: sink,
71 warnings: &warnings,
72 kind,
73 };
74 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
75 Ok(warnings)
76}
77
78struct SubtitlePageSink<'a> {
79 inner: &'a mut dyn PageConsumer,
80 warnings: &'a [String],
81 kind: SubtitleKind,
82}
83
84impl PageConsumer for SubtitlePageSink<'_> {
85 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
86 page.source_format = self.kind.source_format().into();
87 if page.title.is_empty() {
88 page.title = self.kind.label().into();
89 }
90 for warning in self.warnings {
91 page.warn(warning.clone());
92 }
93 self.inner.consume(page)
94 }
95}
96
97pub(crate) fn looks_like_subtitle_prefix(prefix: &[u8]) -> Option<SubtitleKind> {
98 let text = String::from_utf8_lossy(prefix);
99 let normalized = text.trim_start_matches('\u{feff}');
100 if normalized.lines().next().is_some_and(|line| {
101 line == "WEBVTT" || line.starts_with("WEBVTT ") || line.starts_with("WEBVTT\t")
102 }) {
103 return Some(SubtitleKind::Vtt);
104 }
105 for line in normalized.lines().take(64) {
106 if let Some((start, end)) = line.split_once("-->") {
107 if parse_timestamp(start.trim(), SubtitleKind::Srt).is_some()
108 && end
109 .split_whitespace()
110 .next()
111 .is_some_and(|end| parse_timestamp(end, SubtitleKind::Srt).is_some())
112 {
113 return Some(SubtitleKind::Srt);
114 }
115 if parse_timestamp(start.trim(), SubtitleKind::Vtt).is_some()
116 && end
117 .split_whitespace()
118 .next()
119 .is_some_and(|end| parse_timestamp(end, SubtitleKind::Vtt).is_some())
120 {
121 return Some(SubtitleKind::Vtt);
122 }
123 }
124 }
125 None
126}
127
128fn parse_blocks(text: &str, kind: SubtitleKind) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
129 let normalized_storage = normalize_line_terminators(text);
130 let normalized = normalized_storage.trim_start_matches('\u{feff}');
131 validate(normalized, kind)?;
132 let mut lines = normalized
133 .lines()
134 .map(|line| line.trim_end_matches('\r'))
135 .peekable();
136 let mut header = None::<String>;
137 let mut warnings = ParseWarnings::default();
138 if kind == SubtitleKind::Vtt {
139 let first = lines.next().unwrap_or_default();
140 if !(first == "WEBVTT" || first.starts_with("WEBVTT ") || first.starts_with("WEBVTT\t")) {
141 return Err(Error::InvalidInput(
142 "WebVTT input must start with the WEBVTT signature".into(),
143 ));
144 }
145 let suffix = first.strip_prefix("WEBVTT").unwrap_or_default().trim();
146 if !suffix.is_empty() {
147 let (suffix, had_controls) = sanitize_text(suffix);
148 warnings.controls += usize::from(had_controls);
149 header = Some(suffix);
150 }
151 while lines.peek().is_some_and(|line| !line.trim().is_empty()) {
152 let line = lines.next().unwrap_or_default().trim();
153 if !line.is_empty() {
154 let (line, had_controls) = sanitize_text(line);
155 warnings.controls += usize::from(had_controls);
156 header = Some(match header.take() {
157 Some(existing) => format!("{existing} · {line}"),
158 None => line,
159 });
160 if header
161 .as_ref()
162 .is_some_and(|header| header.len() > MAX_TOTAL_TEXT_BYTES)
163 {
164 return Err(Error::LimitExceeded(format!(
165 "WebVTT header exceeds {MAX_TOTAL_TEXT_BYTES} bytes"
166 )));
167 }
168 }
169 }
170 }
171
172 let mut blocks = vec![HtmlBlock::Heading {
173 level: 1,
174 text: kind.label().into(),
175 }];
176 let mut rendered_text_bytes = 0usize;
177 if let Some(header) = header {
178 let (header, had_controls) = sanitize_text(&header);
179 warnings.controls += usize::from(had_controls);
180 rendered_text_bytes = "Track: ".len() + header.len();
181 if rendered_text_bytes > MAX_TOTAL_TEXT_BYTES {
182 return Err(Error::LimitExceeded(format!(
183 "subtitle text exceeds {MAX_TOTAL_TEXT_BYTES} bytes"
184 )));
185 }
186 blocks.push(HtmlBlock::Paragraph {
187 text: format!("Track: {header}"),
188 });
189 }
190
191 let mut cue_count = 0usize;
192 while let Some(line) = lines.next() {
193 if line.trim().is_empty() {
194 continue;
195 }
196 let first_line = line.trim();
197 if kind == SubtitleKind::Vtt
198 && (first_line == "NOTE"
199 || first_line.starts_with("NOTE ")
200 || first_line.starts_with("NOTE\t"))
201 {
202 warnings.comments = warnings.comments.saturating_add(1);
203 while lines.peek().is_some_and(|line| !line.trim().is_empty()) {
204 lines.next();
205 }
206 continue;
207 }
208 if kind == SubtitleKind::Vtt && (first_line == "STYLE" || first_line == "REGION") {
209 warnings.metadata_blocks = warnings.metadata_blocks.saturating_add(1);
210 while lines.peek().is_some_and(|line| !line.trim().is_empty()) {
211 lines.next();
212 }
213 continue;
214 }
215
216 let mut cue_lines = vec![line];
217 while lines.peek().is_some_and(|line| !line.trim().is_empty()) {
218 cue_lines.push(lines.next().unwrap_or_default());
219 if cue_lines.len() > MAX_SUBTITLE_LINES {
220 return Err(Error::LimitExceeded(format!(
221 "{} cue contains too many lines",
222 kind.label()
223 )));
224 }
225 }
226
227 let timing_index = cue_lines
228 .iter()
229 .take(2)
230 .position(|line| line.contains("-->"));
231 let Some(timing_index) = timing_index else {
232 warnings.malformed_blocks = warnings.malformed_blocks.saturating_add(1);
233 continue;
234 };
235 let Some((start, end, has_settings)) = parse_timing(cue_lines[timing_index], kind) else {
236 warnings.malformed_blocks = warnings.malformed_blocks.saturating_add(1);
237 continue;
238 };
239 if start > end {
240 warnings.malformed_blocks = warnings.malformed_blocks.saturating_add(1);
241 continue;
242 }
243 if has_settings {
244 warnings.cue_settings = warnings.cue_settings.saturating_add(1);
245 }
246
247 let identifier = if timing_index == 1 {
248 let id = cue_lines[0].trim();
249 if id.len() > MAX_IDENTIFIER_BYTES {
250 warnings.malformed_blocks = warnings.malformed_blocks.saturating_add(1);
251 continue;
252 }
253 let (id, had_controls) = sanitize_text(id);
254 warnings.controls += usize::from(had_controls);
255 Some(id)
256 } else {
257 None
258 };
259 let mut cue_text = String::new();
260 for text_line in cue_lines.iter().skip(timing_index + 1) {
261 if !cue_text.is_empty() {
262 cue_text.push_str(" · ");
263 }
264 let (clean, had_markup, had_controls) = flatten_markup(text_line);
265 warnings.inline_markup += usize::from(had_markup);
266 warnings.controls += usize::from(had_controls);
267 cue_text.push_str(&clean);
268 if cue_text.len() > MAX_CUE_TEXT_BYTES {
269 return Err(Error::LimitExceeded(format!(
270 "subtitle cue text exceeds {MAX_CUE_TEXT_BYTES} bytes"
271 )));
272 }
273 }
274 if cue_text.trim().is_empty() {
275 warnings.malformed_blocks = warnings.malformed_blocks.saturating_add(1);
276 continue;
277 }
278 let timing_text = format!("{}–{}", format_time(start), format_time(end));
279 cue_count += 1;
280 if cue_count > MAX_CUES {
281 return Err(Error::LimitExceeded(format!(
282 "{} exceeds {MAX_CUES} cues",
283 kind.label()
284 )));
285 }
286 let mut prefix = timing_text;
287 if let Some(identifier) = identifier.filter(|id| !id.is_empty()) {
288 prefix.push_str(" [");
289 prefix.push_str(&identifier);
290 prefix.push(']');
291 }
292 prefix.push_str(" ");
293 rendered_text_bytes = rendered_text_bytes
294 .checked_add(cue_text.len())
295 .and_then(|value| value.checked_add(prefix.len()))
296 .ok_or_else(|| Error::LimitExceeded("subtitle text size overflow".into()))?;
297 if rendered_text_bytes > MAX_TOTAL_TEXT_BYTES {
298 return Err(Error::LimitExceeded(format!(
299 "subtitle text exceeds {MAX_TOTAL_TEXT_BYTES} bytes"
300 )));
301 }
302 prefix.push_str(&cue_text);
303 blocks.push(HtmlBlock::Paragraph { text: prefix });
304 }
305
306 if cue_count == 0 {
307 return Err(Error::InvalidInput(format!(
308 "{} contains no valid subtitle cues",
309 kind.label()
310 )));
311 }
312 let mut messages = Vec::new();
313 push_summary(
314 &mut messages,
315 warnings.malformed_blocks,
316 "malformed subtitle blocks were skipped",
317 );
318 push_summary(
319 &mut messages,
320 warnings.comments,
321 "WebVTT NOTE comment blocks were omitted",
322 );
323 push_summary(
324 &mut messages,
325 warnings.metadata_blocks,
326 "WebVTT STYLE/REGION blocks were omitted",
327 );
328 push_summary(
329 &mut messages,
330 warnings.cue_settings,
331 "cue positioning/settings were not reproduced",
332 );
333 push_summary(
334 &mut messages,
335 warnings.inline_markup,
336 "subtitle inline styling and timed-text tags were flattened to readable text",
337 );
338 push_summary(
339 &mut messages,
340 warnings.controls,
341 "subtitle control characters were removed",
342 );
343 Ok((blocks, messages))
344}
345
346fn validate(text: &str, kind: SubtitleKind) -> Result<()> {
347 if text.len() as u64 > MAX_SUBTITLE_BYTES {
348 return Err(Error::LimitExceeded(format!(
349 "{} exceeds {MAX_SUBTITLE_BYTES} bytes",
350 kind.label()
351 )));
352 }
353 for (index, line) in text.lines().enumerate() {
354 if index >= MAX_SUBTITLE_LINES {
355 return Err(Error::LimitExceeded(format!(
356 "{} exceeds {MAX_SUBTITLE_LINES} lines",
357 kind.label()
358 )));
359 }
360 if line.len() > MAX_SUBTITLE_LINE_BYTES {
361 return Err(Error::LimitExceeded(format!(
362 "subtitle line exceeds {MAX_SUBTITLE_LINE_BYTES} bytes"
363 )));
364 }
365 }
366 Ok(())
367}
368
369fn decode_subtitle_input(
370 bytes: Vec<u8>,
371 kind: SubtitleKind,
372) -> Result<(String, Option<&'static str>)> {
373 let bytes = match String::from_utf8(bytes) {
374 Ok(text) => return Ok((text, None)),
375 Err(error) => error.into_bytes(),
376 };
377 if kind == SubtitleKind::Vtt {
378 return Err(Error::InvalidInput(
379 "WebVTT input must be valid UTF-8".into(),
380 ));
381 }
382 let (encoding, payload, note) = if let Some(payload) = bytes.strip_prefix(&[0xff, 0xfe]) {
383 (
384 encoding_rs::UTF_16LE,
385 payload,
386 "SubRip input was decoded from UTF-16LE" as &'static str,
387 )
388 } else if let Some(payload) = bytes.strip_prefix(&[0xfe, 0xff]) {
389 (
390 encoding_rs::UTF_16BE,
391 payload,
392 "SubRip input was decoded from UTF-16BE",
393 )
394 } else {
395 (
396 encoding_rs::WINDOWS_1252,
397 bytes.as_slice(),
398 "SubRip input was not UTF-8; Windows-1252 fallback decoding was used",
399 )
400 };
401 let (decoded, had_errors) = encoding.decode_without_bom_handling(payload);
402 if had_errors {
403 return Err(Error::InvalidInput(format!(
404 "{} contains invalid text for the detected encoding",
405 kind.label()
406 )));
407 }
408 let decoded = decoded.into_owned();
409 if decoded.len() as u64 > MAX_SUBTITLE_BYTES {
410 return Err(Error::LimitExceeded(format!(
411 "decoded {} exceeds {MAX_SUBTITLE_BYTES} bytes",
412 kind.label()
413 )));
414 }
415 Ok((decoded, Some(note)))
416}
417
418fn normalize_line_terminators(text: &str) -> Cow<'_, str> {
419 if !text.contains('\r') {
420 return Cow::Borrowed(text);
421 }
422 let mut normalized = String::with_capacity(text.len());
423 let mut characters = text.chars().peekable();
424 while let Some(character) = characters.next() {
425 if character == '\r' {
426 normalized.push('\n');
427 if characters.peek() == Some(&'\n') {
428 characters.next();
429 }
430 } else {
431 normalized.push(character);
432 }
433 }
434 Cow::Owned(normalized)
435}
436
437fn parse_timing(line: &str, kind: SubtitleKind) -> Option<(u64, u64, bool)> {
438 let (left, right) = line.split_once("-->")?;
439 let start = parse_timestamp(left.trim(), kind)?;
440 let mut right_fields = right.split_whitespace();
441 let end = parse_timestamp(right_fields.next()?, kind)?;
442 Some((start, end, right_fields.next().is_some()))
443}
444
445fn parse_timestamp(value: &str, kind: SubtitleKind) -> Option<u64> {
446 let separator = match kind {
447 SubtitleKind::Srt => ',',
448 SubtitleKind::Vtt => '.',
449 };
450 let (clock, millis) = value.split_once(separator)?;
451 if millis.len() != 3 || !millis.bytes().all(|byte| byte.is_ascii_digit()) {
452 return None;
453 }
454 let mut parts = clock.split(':');
455 let first = parts.next()?;
456 let second = parts.next()?;
457 let third = parts.next();
458 if parts.next().is_some() {
459 return None;
460 }
461 let (hours, minutes, seconds) = match (kind, third) {
462 (SubtitleKind::Srt, Some(seconds)) => {
463 let hours = first;
464 let minutes = second;
465 if hours.len() < 2 || minutes.len() != 2 || seconds.len() != 2 {
466 return None;
467 }
468 (
469 hours.parse::<u64>().ok()?,
470 minutes.parse::<u64>().ok()?,
471 seconds.parse::<u64>().ok()?,
472 )
473 }
474 (SubtitleKind::Vtt, None) => {
475 let minutes = first;
476 let seconds = second;
477 if minutes.len() != 2 || seconds.len() != 2 {
478 return None;
479 }
480 (
481 0,
482 minutes.parse::<u64>().ok()?,
483 seconds.parse::<u64>().ok()?,
484 )
485 }
486 (SubtitleKind::Vtt, Some(seconds)) => {
487 let hours = first;
488 let minutes = second;
489 if hours.is_empty() || minutes.len() != 2 || seconds.len() != 2 {
490 return None;
491 }
492 (
493 hours.parse::<u64>().ok()?,
494 minutes.parse::<u64>().ok()?,
495 seconds.parse::<u64>().ok()?,
496 )
497 }
498 _ => return None,
499 };
500 if minutes > 59 || seconds > 59 {
501 return None;
502 }
503 let millis = millis.parse::<u64>().ok()?;
504 hours
505 .checked_mul(3_600_000)?
506 .checked_add(minutes.checked_mul(60_000)?)?
507 .checked_add(seconds.checked_mul(1_000)?)?
508 .checked_add(millis)
509}
510
511fn format_time(milliseconds: u64) -> String {
512 let hours = milliseconds / 3_600_000;
513 let minutes = milliseconds / 60_000 % 60;
514 let seconds = milliseconds / 1_000 % 60;
515 let millis = milliseconds % 1_000;
516 format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
517}
518
519fn flatten_markup(input: &str) -> (String, bool, bool) {
520 let mut output = String::with_capacity(input.len());
521 let mut index = 0usize;
522 let mut had_markup = false;
523 let mut had_controls = false;
524 while index < input.len() {
525 let rest = &input[index..];
526 let Some('<') = rest.chars().next() else {
527 let character = rest.chars().next().unwrap_or_default();
528 if character.is_control() {
529 had_controls = true;
530 if character == '\t' {
531 output.push(' ');
532 }
533 } else {
534 output.push(character);
535 }
536 index += character.len_utf8();
537 continue;
538 };
539 let Some(relative_end) = rest.find('>') else {
540 output.push('<');
541 index += 1;
542 continue;
543 };
544 if relative_end > 512 {
545 output.push('<');
546 index += 1;
547 continue;
548 }
549 let body = rest[1..relative_end].trim();
550 let normalized = body.trim_start_matches('/');
551 let tag = normalized
552 .split(|character: char| character.is_whitespace() || character == '.')
553 .next()
554 .unwrap_or_default()
555 .to_ascii_lowercase();
556 let closing = body.starts_with('/');
557 let known = matches!(
558 tag.as_str(),
559 "b" | "i" | "u" | "font" | "c" | "v" | "lang" | "ruby" | "rt"
560 ) || is_vtt_timestamp(body);
561 if known {
562 had_markup = true;
563 if tag == "v" && !closing {
564 let speaker = normalized.strip_prefix('v').unwrap_or_default().trim();
565 let speaker =
566 speaker.trim_matches(|character| character == '<' || character == '>');
567 if !speaker.is_empty() {
568 let (clean_speaker, speaker_controls) = sanitize_text(speaker);
569 had_controls |= speaker_controls;
570 if !clean_speaker.is_empty() {
571 if !output.is_empty() {
572 output.push(' ');
573 }
574 output.push_str(&clean_speaker);
575 output.push_str(": ");
576 }
577 }
578 }
579 index += relative_end + 1;
580 } else {
581 output.push('<');
582 index += 1;
583 }
584 }
585 (output.trim().to_string(), had_markup, had_controls)
586}
587
588fn is_vtt_timestamp(value: &str) -> bool {
589 parse_timestamp(value, SubtitleKind::Vtt).is_some()
590}
591
592fn sanitize_text(value: &str) -> (String, bool) {
593 let mut had_controls = false;
594 let text = value
595 .chars()
596 .filter_map(|character| {
597 if character.is_control() {
598 had_controls = true;
599 (character == '\t').then_some(' ')
600 } else {
601 Some(character)
602 }
603 })
604 .collect();
605 (text, had_controls)
606}
607
608fn push_summary(messages: &mut Vec<String>, count: usize, description: &str) {
609 if count > 0 {
610 messages.push(format!("{count} {description}"));
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn parses_srt_sequences_unicode_and_multiline_cues() {
620 let input = "1\r\n00:00:01,250 --> 00:00:02,500\r\nHello <i>世界</i>\r\nSecond line\r\n\r\n2\r\n01:02:03,004 --> 01:02:04,000\r\nNext\r\n";
621 let (blocks, warnings) = parse_blocks(input, SubtitleKind::Srt).unwrap();
622 assert!(
623 warnings
624 .iter()
625 .any(|warning| warning.contains("inline styling"))
626 );
627 let text = blocks
628 .iter()
629 .filter_map(|block| match block {
630 HtmlBlock::Paragraph { text } => Some(text.as_str()),
631 _ => None,
632 })
633 .collect::<Vec<_>>();
634 assert_eq!(
635 text[0],
636 "00:00:01.250–00:00:02.500 [1] Hello 世界 · Second line"
637 );
638 assert!(text[1].starts_with("01:02:03.004–01:02:04.000"));
639 }
640
641 #[test]
642 fn parses_webvtt_header_voice_tags_and_omits_metadata_blocks() {
643 let input = "WEBVTT - English\nKind: captions\n\nNOTE generated file\ncomment\n\nSTYLE\n::cue { color: red }\n\nintro\n00:01.000 --> 00:02.000 align:start\n<v Narrator>Hello <b>there</b></v>\n";
644 let (blocks, warnings) = parse_blocks(input, SubtitleKind::Vtt).unwrap();
645 assert!(warnings.iter().any(|warning| warning.contains("NOTE")));
646 assert!(
647 warnings
648 .iter()
649 .any(|warning| warning.contains("STYLE/REGION"))
650 );
651 assert!(
652 warnings
653 .iter()
654 .any(|warning| warning.contains("positioning/settings"))
655 );
656 assert!(blocks.iter().any(|block| matches!(block, HtmlBlock::Paragraph { text } if text.contains("Narrator: Hello there"))));
657 assert!(blocks.iter().any(|block| matches!(block, HtmlBlock::Paragraph { text } if text.contains("English · Kind: captions"))));
658 }
659
660 #[test]
661 fn rejects_malformed_timestamps_and_enforces_budgets() {
662 assert!(
663 parse_blocks(
664 "1\n00:00:01.000 --> 00:00:02.000\nText\n",
665 SubtitleKind::Srt
666 )
667 .is_err()
668 );
669 assert!(
670 parse_blocks(
671 "WEBVTT\n\n00:99.000 --> 01:00.000\nText\n",
672 SubtitleKind::Vtt
673 )
674 .is_err()
675 );
676 assert!(parse_timestamp("00:00:02,000", SubtitleKind::Srt).is_some());
677 assert!(parse_timestamp("99:59.000", SubtitleKind::Vtt).is_none());
678 }
679
680 #[test]
681 fn detects_extensionless_srt_and_webvtt() {
682 assert_eq!(
683 looks_like_subtitle_prefix(b"WEBVTT\n\n"),
684 Some(SubtitleKind::Vtt)
685 );
686 assert_eq!(
687 looks_like_subtitle_prefix(b"1\n00:00:01,000 --> 00:00:02,000\nText\n"),
688 Some(SubtitleKind::Srt)
689 );
690 assert_eq!(looks_like_subtitle_prefix(b"00:00:01 --> 00:00:02\n"), None);
691 }
692
693 #[test]
694 fn decodes_common_subrip_encodings_and_keeps_webvtt_utf8_only() {
695 let cp1252 = b"1\n00:00:01,000 --> 00:00:02,000\nCaf\xe9\n".to_vec();
696 let (text, note) = decode_subtitle_input(cp1252, SubtitleKind::Srt).unwrap();
697 assert_eq!(
698 note,
699 Some("SubRip input was not UTF-8; Windows-1252 fallback decoding was used")
700 );
701 assert!(text.contains("Café"));
702
703 let utf16: Vec<u8> = "1\r00:00:01,000 --> 00:00:02,000\rCafé\r"
704 .encode_utf16()
705 .flat_map(u16::to_le_bytes)
706 .collect();
707 let mut utf16le = vec![0xff, 0xfe];
708 utf16le.extend(utf16);
709 let (text, note) = decode_subtitle_input(utf16le, SubtitleKind::Srt).unwrap();
710 assert_eq!(note, Some("SubRip input was decoded from UTF-16LE"));
711 let (blocks, _) = parse_blocks(&text, SubtitleKind::Srt).unwrap();
712 assert!(
713 blocks.iter().any(
714 |block| matches!(block, HtmlBlock::Paragraph { text } if text.contains("Café"))
715 )
716 );
717
718 assert!(decode_subtitle_input(b"WEBVTT\n\n\xFF".to_vec(), SubtitleKind::Vtt).is_err());
719 }
720}