use crate::model::{Subtitle, TextPart};
use crate::types::AnyResult;
use crate::utils::{format_timestamp, parse_timestamps};
use anyhow::anyhow;
use regex::Regex;
#[cfg(feature = "http")]
use reqwest;
use smallvec::SmallVec;
use std::sync::LazyLock;
use tokio::fs;
use tokio::io::AsyncWriteExt;
static RE_VTT_TAG: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"<v(?:\s+\w+)?>|</v>|",
r"</?(?:b|i|u|c)(?:\.[^>]*)?>"
))
.unwrap()
});
#[derive(PartialEq)]
enum Phase {
Header,
Cue,
Timestamp,
Text,
VttComment,
}
fn extract_text_parts(text: &str) -> (String, SmallVec<[TextPart; 4]>) {
let mut parts = SmallVec::with_capacity(4); let mut plain = String::with_capacity(text.len());
let mut bold = false;
let mut italic = false;
let mut underline = false;
let mut voice: Option<String> = None;
let mut last_end = 0;
let re = &RE_VTT_TAG;
for caps in re.find_iter(text) {
let tag = caps.as_str();
let start = caps.start();
let end = caps.end();
if start > last_end {
let segment = &text[last_end..start];
if !segment.is_empty() {
plain.push_str(segment);
if bold || italic || underline || voice.is_some() {
parts.push(TextPart {
text: segment.to_string(),
bold,
italic,
underline,
color: None,
voice: voice.clone(),
});
}
}
}
match tag {
"</v>" => voice = None,
"</b>" | "</b.c1>" | "</b.c2>" => bold = false,
"</i>" | "</i.c1>" | "</i.c2>" => italic = false,
"</u>" | "</u.c1>" | "</u.c2>" => underline = false,
"</c>" | "</c.c>" | "</c.c1>" | "</c.c2>" => {}
_ if tag.starts_with("<v") => {
let inner = &tag[2..tag.len().saturating_sub(1)];
let name = inner.split_whitespace().next().unwrap_or("unknown");
voice = Some(name.to_string());
}
_ if tag.starts_with("<b") => bold = true,
_ if tag.starts_with("<i") => italic = true,
_ if tag.starts_with("<u") => underline = true,
_ => {}
}
last_end = end;
}
if last_end < text.len() {
let segment = &text[last_end..];
plain.push_str(segment);
if bold || italic || underline || voice.is_some() {
parts.push(TextPart {
text: segment.to_string(),
bold,
italic,
underline,
color: None,
voice: voice.clone(),
});
}
}
if parts.is_empty() {
plain = text.to_string();
}
(plain, parts)
}
fn parse(content: &str) -> AnyResult<(Option<String>, Vec<Subtitle>)> {
let mut subtitles = Vec::new();
let mut current_subtitle: Option<Subtitle> = None;
let mut phase = Phase::Header;
let mut row: usize = 0;
let mut is_first_content_line = true;
let mut header_lines: Vec<String> = Vec::new();
let mut header: Option<String> = None;
for line in content.lines() {
row += 1;
let mut trimmed = line.trim().to_string();
if is_first_content_line && !trimmed.is_empty() {
is_first_content_line = false;
if trimmed.starts_with('\u{FEFF}') {
trimmed = trimmed.trim_start_matches('\u{FEFF}').to_string();
}
}
if trimmed.is_empty() {
if let Some(mut sub) = current_subtitle.take() {
let (plain, parts) = extract_text_parts(&sub.text);
sub.text = plain;
sub.text_parts = parts;
subtitles.push(sub);
}
if phase == Phase::Header && !header_lines.is_empty() {
header = Some(header_lines.join("\n"));
header_lines.clear();
}
phase = Phase::Cue;
continue;
}
match phase {
Phase::Header => {
header_lines.push(trimmed);
}
Phase::VttComment => {
}
Phase::Cue => {
if trimmed.starts_with("WEBVTT") {
} else if trimmed.starts_with("NOTE") {
phase = Phase::VttComment;
} else if trimmed.contains("-->") {
let timestamp = parse_timestamps(&trimmed)?;
let mut subtitle = Subtitle::new(timestamp.start, timestamp.end, "");
subtitle.settings = timestamp.settings;
current_subtitle = Some(subtitle);
phase = Phase::Text;
} else {
let index = trimmed.parse::<usize>().ok();
let mut subtitle = Subtitle::new(0, 0, "");
subtitle.index = index;
current_subtitle = Some(subtitle);
phase = Phase::Timestamp;
}
}
Phase::Timestamp => {
if let Some(sub) = &mut current_subtitle {
if trimmed.contains("-->") {
let timestamp = parse_timestamps(&trimmed)?;
sub.start = timestamp.start;
sub.end = timestamp.end;
sub.settings = timestamp.settings;
phase = Phase::Text;
} else {
return Err(anyhow!(
"expected timestamp at row {row}, but received: \"{}\"",
trimmed
));
}
}
}
Phase::Text => {
if let Some(sub) = &mut current_subtitle {
if !sub.text.is_empty() {
sub.text.push('\n');
}
sub.text.push_str(&trimmed);
}
}
}
}
if let Some(mut sub) = current_subtitle {
let (plain, parts) = extract_text_parts(&sub.text);
sub.text = plain;
sub.text_parts = parts;
subtitles.push(sub);
}
if header.is_none() && !header_lines.is_empty() {
header = Some(header_lines.join("\n"));
}
Ok((header, subtitles))
}
pub async fn parse_file(path: impl AsRef<std::path::Path>) -> AnyResult<Vec<Subtitle>> {
let text = tokio::fs::read_to_string(path).await?;
let (_, subtitles) = parse(&text)?;
Ok(subtitles)
}
pub fn parse_bytes(data: &[u8]) -> AnyResult<Vec<Subtitle>> {
let text = crate::encoding::decode_to_string(data)?;
let (_, subtitles) = parse(&text)?;
Ok(subtitles)
}
pub fn parse_bytes_full(data: &[u8]) -> AnyResult<(Option<String>, Vec<Subtitle>)> {
let text = crate::encoding::decode_to_string(data)?;
parse(&text)
}
#[cfg(feature = "http")]
pub async fn parse_url(url: &str) -> AnyResult<Vec<Subtitle>> {
let response = reqwest::get(url).await?;
let content = response.text().await?;
let (_, subtitles) = parse(&content)?;
Ok(subtitles)
}
pub fn parse_content(content: &str) -> AnyResult<Vec<Subtitle>> {
let (_, subtitles) = parse(content)?;
Ok(subtitles)
}
pub fn parse_content_full(content: &str) -> AnyResult<(Option<String>, Vec<Subtitle>)> {
parse(content)
}
pub fn detect_format(data: &[u8]) -> Option<crate::model::Format> {
if let Some(text) = crate::encoding::try_decode_for_detection(data)
&& text.trim().starts_with("WEBVTT")
{
return Some(crate::model::Format::Vtt);
}
None
}
pub fn to_string(subtitles: &[Subtitle], header: Option<&str>) -> String {
let mut content = if let Some(h) = header {
format!("{}\n\n", h)
} else {
String::from("WEBVTT\n\n")
};
for (i, subtitle) in subtitles.iter().enumerate() {
let position = i + 1;
content.push_str(&position.to_string());
content.push('\n');
let mut timestamp = format!(
"{} --> {}",
format_timestamp(subtitle.start, "WebVTT"),
format_timestamp(subtitle.end, "WebVTT")
);
if let Some(ref settings) = subtitle.settings {
timestamp = format!("{} {}", timestamp, settings);
}
content.push_str(×tamp);
content.push('\n');
content.push_str(&subtitle.text);
if i != subtitles.len() - 1 {
content.push('\n');
content.push('\n');
}
}
if !subtitles.is_empty() {
content.push('\n');
}
content
}
pub async fn generate(
subtitles: &[Subtitle],
file_path: impl AsRef<std::path::Path>,
policy: Option<crate::model::WritePolicy>,
) -> AnyResult<String> {
let path = file_path.as_ref();
let policy = policy.unwrap_or_default();
if policy == crate::model::WritePolicy::RefuseIfExists && path.exists() {
anyhow::bail!("Refusing to overwrite existing file: {}", path.display());
}
let mut open_opts = fs::OpenOptions::new();
let mut dest = match policy {
crate::model::WritePolicy::Append => open_opts.create(true).append(true).open(path).await,
_ => {
open_opts
.create(true)
.write(true)
.truncate(true)
.open(path)
.await
}
}?;
let content = to_string(subtitles, None);
dest.write_all(content.as_bytes()).await?;
dest.flush().await?;
Ok(path.to_string_lossy().into_owned())
}
pub struct VttStream<'a> {
lines: std::str::Lines<'a>,
phase: u8,
current_subtitle: Option<Subtitle>,
in_note: bool,
}
impl<'a> VttStream<'a> {
pub fn new(content: &'a str) -> Self {
VttStream {
lines: content.lines(),
phase: 0,
current_subtitle: None,
in_note: false,
}
}
}
impl<'a> Iterator for VttStream<'a> {
type Item = AnyResult<Subtitle>;
fn next(&mut self) -> Option<Self::Item> {
for line in self.lines.by_ref() {
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
if let Some(mut sub) = self.current_subtitle.take() {
let (plain, _) = extract_text_parts(&sub.text);
sub.text = plain;
self.phase = 0;
return Some(Ok(sub));
}
self.phase = 0;
self.in_note = false;
continue;
}
if self.phase == 0 && trimmed.starts_with("WEBVTT") {
self.phase = 1;
continue;
}
if self.phase == 0 {
continue;
}
if trimmed.starts_with("NOTE") {
self.in_note = true;
continue;
}
if self.in_note {
continue;
}
if trimmed.contains("-->") {
if let Some(mut sub) = self.current_subtitle.take() {
let (plain, _) = extract_text_parts(&sub.text);
sub.text = plain;
self.phase = 1;
if let Ok(ts) = parse_timestamps(&trimmed) {
self.current_subtitle = Some(Subtitle::new(ts.start, ts.end, ""));
}
return Some(Ok(sub));
}
if let Ok(ts) = parse_timestamps(&trimmed) {
self.current_subtitle = Some(Subtitle::new(ts.start, ts.end, ""));
}
self.phase = 1;
} else if let Some(sub) = &mut self.current_subtitle {
if !sub.text.is_empty() {
sub.text.push('\n');
}
sub.text.push_str(&trimmed);
}
}
if let Some(mut sub) = self.current_subtitle.take() {
let (plain, _) = extract_text_parts(&sub.text);
sub.text = plain;
return Some(Ok(sub));
}
None
}
}
impl<'a> crate::model::StreamingParser for VttStream<'a> {}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Subtitle;
fn make_subtitle(index: Option<usize>, start: u64, end: u64, text: &str) -> Subtitle {
Subtitle {
index,
start,
end,
text: text.to_string(),
settings: None,
text_parts: SmallVec::new(),
style: None,
actor: None,
is_comment: false,
}
}
#[test]
fn test_parse_basic_vtt() {
let content = "WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.500\nHello!\n\n2\n00:00:04.000 --> 00:00:06.500\nWorld!\n\n";
let result = parse_content(content).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0], make_subtitle(Some(1), 1000, 3500, "Hello!"));
assert_eq!(result[1], make_subtitle(Some(2), 4000, 6500, "World!"));
}
#[test]
fn test_parse_multiline_text() {
let content = "WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.500\nLine one\nLine two\n\n";
let result = parse_content(content).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].text, "Line one\nLine two");
}
#[test]
fn test_parse_with_settings() {
let content = "WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.500 align:start\nHello!\n\n";
let result = parse_content(content).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].settings, Some("align:start".to_string()));
}
#[test]
fn test_parse_no_cue_id() {
let content = "WEBVTT\n\n00:00:01.000 --> 00:00:03.500\nNo cue id\n\n";
let result = parse_content(content).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].text, "No cue id");
assert_eq!(result[0].index, None);
}
#[test]
fn test_parse_start_at_zero() {
let content = "WEBVTT\n\n1\n00:00:00.000 --> 00:00:03.500\nFrom zero\n\n";
let result = parse_content(content).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].start, 0);
}
#[tokio::test]
async fn test_round_trip() {
let original = "WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.500\nHello\n\n2\n00:00:04.000 --> 00:00:06.500\nWorld\n\n";
let subtitles = parse_content(original).unwrap();
let path = "test_round_trip_vtt.vtt";
generate(&subtitles, path, None).await.unwrap();
let parsed_back = parse_file(path).await.unwrap();
let _ = std::fs::remove_file(path);
assert_eq!(subtitles, parsed_back);
}
#[test]
fn test_parse_with_metadata_header() {
let content =
"WEBVTT\nKind: captions\nLanguage: en\n\n1\n00:00:01.000 --> 00:00:03.500\nHello\n\n";
let result = parse_content(content).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].text, "Hello");
}
#[test]
fn test_parse_missing_timestamp_error() {
let content = "WEBVTT\n\n1\nnot a timestamp\n\n";
let result = parse_content(content);
assert!(result.is_err());
}
#[test]
fn test_parse_bold_tag() {
let content = "WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.500\n<b>bold</b>\n\n";
let result = parse_content(content).unwrap();
assert!(result[0].text_parts[0].bold);
}
#[test]
fn test_parse_voice_tag() {
let content = "WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.500\n<v Alice>Hello</v>\n\n";
let result = parse_content(content).unwrap();
assert_eq!(result[0].text, "Hello");
assert_eq!(result[0].text_parts.len(), 1);
assert!(result[0].text_parts[0].voice.is_some());
}
#[test]
fn test_parse_bytes() {
let data = b"WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.500\nHello\n\n";
let result = parse_bytes(data.as_ref()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].text, "Hello");
}
#[test]
fn test_detect_format() {
let data = b"WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.500\nHello\n\n";
assert_eq!(detect_format(data), Some(crate::model::Format::Vtt));
}
#[test]
fn test_parse_note_block() {
let content = "WEBVTT\n\nNOTE\nThis is a comment\nspanning multiple lines\n\n1\n00:00:01.000 --> 00:00:03.500\nAfter note\n\n";
let result = parse_content(content).unwrap();
assert_eq!(result.len(), 1, "subtitle after NOTE block was lost");
assert_eq!(result[0].text, "After note");
}
#[test]
fn test_parse_voice_speaker_name() {
let content = "WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.500\n<v Alice>Hello</v>\n\n";
let result = parse_content(content).unwrap();
assert_eq!(result[0].text_parts[0].voice, Some("Alice".to_string()));
}
#[test]
fn test_parse_bytes_full_preserves_header() {
let data =
b"WEBVTT\nKind: captions\nLanguage: en\n\n1\n00:00:01.000 --> 00:00:03.500\nHello\n\n";
let (header, subs) = parse_bytes_full(data.as_ref()).unwrap();
assert!(header.as_deref().unwrap().contains("Kind: captions"));
assert_eq!(subs.len(), 1);
}
}