Skip to main content

document_svg/document/
subtitle.rs

1//! Bounded SubRip (SRT) and WebVTT subtitle previews.
2//!
3//! Cue text and timing are rendered as inert text. No HTML, script, style,
4//! media, or external reference from a track is executed or fetched.
5
6use 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}