use docling_core::{DoclingDocument, Node};
use crate::backend::markdown::escape_text;
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
pub struct WebVttBackend;
#[derive(Default, Clone, Copy)]
struct Fmt {
bold: bool,
italic: bool,
}
struct Comp {
text: String,
fmt: Fmt,
}
impl DeclarativeBackend for WebVttBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let content = source.text()?.replace("\r\n", "\n").replace('\r', "\n");
let mut doc = DoclingDocument::new(&source.name);
let mut blocks: Vec<String> = Vec::new();
let mut cur = String::new();
for line in content.lines() {
if line.trim().is_empty() {
if !cur.is_empty() {
blocks.push(std::mem::take(&mut cur));
}
} else {
if !cur.is_empty() {
cur.push('\n');
}
cur.push_str(line);
}
}
if !cur.is_empty() {
blocks.push(cur);
}
for block in &blocks {
if let Some(rest) = block.lines().next().and_then(|l| l.strip_prefix("WEBVTT")) {
let title = rest.trim();
if !title.is_empty() {
doc.push(Node::Heading {
level: 1,
text: escape_text(title),
});
}
continue;
}
if block.starts_with("NOTE")
|| block.starts_with("STYLE")
|| block.starts_with("REGION")
{
continue;
}
let lines: Vec<&str> = block.lines().collect();
let Some(ti) = lines.iter().position(|l| l.contains("-->")) else {
continue;
};
let payload = lines[ti + 1..].join("\n");
for para in parse_cue(&payload) {
let text = para
.iter()
.map(serialize_comp)
.collect::<Vec<_>>()
.join(" ");
if !text.is_empty() {
doc.push(Node::Paragraph { text });
}
}
}
Ok(doc)
}
}
fn serialize_comp(c: &Comp) -> String {
let mut s = escape_text(&c.text);
if c.fmt.bold {
s = format!("**{s}**");
}
if c.fmt.italic {
s = format!("*{s}*");
}
s
}
fn parse_cue(payload: &str) -> Vec<Vec<Comp>> {
let chars: Vec<char> = payload.chars().collect();
let mut paras: Vec<Vec<Comp>> = vec![Vec::new()];
let (mut bold, mut italic) = (0i32, 0i32);
let mut buf = String::new();
let mut k = 0;
let flush = |buf: &mut String, paras: &mut Vec<Vec<Comp>>, bold: i32, italic: i32| {
if !buf.is_empty() {
paras.last_mut().unwrap().push(Comp {
text: std::mem::take(buf),
fmt: Fmt {
bold: bold > 0,
italic: italic > 0,
},
});
}
};
while k < chars.len() {
match chars[k] {
'<' => {
flush(&mut buf, &mut paras, bold, italic);
let mut j = k + 1;
while j < chars.len() && chars[j] != '>' {
j += 1;
}
let tag: String = chars[k + 1..j.min(chars.len())].iter().collect();
k = j + 1;
let closing = tag.starts_with('/');
let base: String = tag
.trim_start_matches('/')
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect();
let delta = if closing { -1 } else { 1 };
match base.as_str() {
"b" => bold += delta,
"i" => italic += delta,
_ => {} }
}
'\n' => {
flush(&mut buf, &mut paras, bold, italic);
paras.push(Vec::new());
k += 1;
}
ch => {
buf.push(ch);
k += 1;
}
}
}
flush(&mut buf, &mut paras, bold, italic);
paras
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::InputFormat;
fn md(vtt: &str) -> String {
let src = SourceDocument::from_bytes("t", InputFormat::Vtt, vtt.as_bytes().to_vec());
WebVttBackend.convert(&src).unwrap().export_to_markdown()
}
#[test]
fn strips_voice_and_skips_notes() {
let out = md("WEBVTT\n\nNOTE hi\n\n00:01.000 --> 00:02.000\n<v Roger>Hello world\n");
assert_eq!(out.trim(), "Hello world");
}
#[test]
fn nested_spans_serialize_with_inline_join() {
let out = md("WEBVTT\n\n00:01.000 --> 00:02.000\n\
a <i>b <lang es>c</lang></i> d\n");
assert_eq!(out.trim(), "a *b * *c* d");
}
}