1use serde_json::Value;
27
28use crate::DoclingDocument;
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct VttExportOptions {
33 pub omit_hours_if_zero: bool,
35 pub omit_voice_end: bool,
38}
39
40impl Default for VttExportOptions {
41 fn default() -> Self {
44 Self {
45 omit_hours_if_zero: false,
46 omit_voice_end: true,
47 }
48 }
49}
50
51pub(crate) fn to_vtt(doc: &DoclingDocument, options: &VttExportOptions) -> String {
52 let (json, _tables) = crate::json::to_json_typed_tables(doc, false);
54 let mut parts = Vec::new();
55 let mut inline_done = std::collections::HashSet::new();
56 walk(&json, &json["body"], &mut parts, &mut inline_done);
57 serialize_doc(&parts, options)
58}
59
60struct Part<'a> {
62 text: String,
63 title: bool,
64 track: Option<&'a Value>,
65}
66
67fn resolve<'a>(json: &'a Value, r: &str) -> Option<&'a Value> {
68 let path = r.strip_prefix("#/")?;
69 if path == "body" {
70 return Some(&json["body"]);
71 }
72 let (bucket, idx) = path.split_once('/')?;
73 json.get(bucket)?.get(idx.parse::<usize>().ok()?)
74}
75
76fn children<'a>(json: &'a Value, item: &'a Value) -> Vec<&'a Value> {
77 item["children"]
78 .as_array()
79 .into_iter()
80 .flatten()
81 .filter_map(|c| c["$ref"].as_str().and_then(|r| resolve(json, r)))
82 .collect()
83}
84
85fn is_text(item: &Value) -> bool {
86 item["self_ref"]
87 .as_str()
88 .is_some_and(|r| r.starts_with("#/texts/"))
89}
90
91fn walk<'a>(
96 json: &'a Value,
97 item: &'a Value,
98 parts: &mut Vec<Part<'a>>,
99 done: &mut std::collections::HashSet<&'a str>,
100) {
101 let self_ref = item["self_ref"].as_str().unwrap_or("");
102 let body_layer = item["content_layer"].as_str().is_none_or(|l| l == "body");
103 if body_layer && !done.contains(self_ref) {
104 if is_text(item) {
105 if let Some(part) = text_part(item, false) {
106 parts.push(part);
107 }
108 } else if item["label"] == "inline" && self_ref.starts_with("#/groups/") {
109 let kids = children(json, item);
110 let mut text = String::new();
111 for kid in &kids {
112 if let Some(r) = kid["self_ref"].as_str() {
113 done.insert(r);
114 }
115 if is_text(kid) && kid["content_layer"].as_str().is_none_or(|l| l == "body") {
116 if let Some(part) = text_part(kid, true) {
117 text.push_str(&part.text);
118 }
119 }
120 }
121 let text = remove_pairs_until_stable(text);
122 if !text.is_empty() {
123 let first = kids.first().copied();
125 parts.push(Part {
126 text,
127 title: false,
128 track: first.and_then(track_of),
129 });
130 }
131 }
132 }
133 let picture = self_ref.starts_with("#/pictures/");
134 let captions: Vec<&str> = item["captions"]
135 .as_array()
136 .into_iter()
137 .flatten()
138 .filter_map(|c| c["$ref"].as_str())
139 .collect();
140 for (child_ref, child) in item["children"]
141 .as_array()
142 .into_iter()
143 .flatten()
144 .filter_map(|c| c["$ref"].as_str())
145 .filter_map(|r| Some((r, resolve(json, r)?)))
146 {
147 if picture && !captions.contains(&child_ref) {
148 continue;
149 }
150 walk(json, child, parts, done);
151 }
152}
153
154fn track_of(item: &Value) -> Option<&Value> {
155 let source = item["source"].as_array()?.first()?;
156 (source["kind"] == "track").then_some(source)
157}
158
159fn text_part(item: &Value, inline: bool) -> Option<Part<'_>> {
162 let text = item["text"].as_str().unwrap_or("");
163 if item["label"] == "title" {
164 return Some(Part {
165 text: text.to_string(),
166 title: true,
167 track: None,
168 });
169 }
170 let track = track_of(item)?;
171 if text.is_empty() {
172 return None;
173 }
174 let mut out = escape(text);
175 let f = &item["formatting"];
176 for (flag, tag) in [("bold", "b"), ("italic", "i"), ("underline", "u")] {
177 if f[flag].as_bool() == Some(true) {
178 out = format!("<{tag}>{out}</{tag}>");
179 }
180 }
181 if let Some(voice) = track["voice"].as_str().filter(|v| !v.is_empty()) {
182 out = format!(
183 "<v {}>{out}</v>",
184 voice.replace('&', "&").replace('>', ">")
185 );
186 }
187 if inline {
188 out = remove_pairs_until_stable(out);
189 }
190 Some(Part {
191 text: out,
192 title: false,
193 track: Some(track),
194 })
195}
196
197fn escape(text: &str) -> String {
199 text.replace('&', "&").replace('<', "<")
200}
201
202fn millis(seconds: f64) -> u64 {
205 (seconds * 1000.0).round_ties_even().max(0.0) as u64
206}
207
208fn timestamp_seconds(ms: u64) -> f64 {
211 let (h, rest) = (ms / 3_600_000, ms % 3_600_000);
212 let (m, rest) = (rest / 60_000, rest % 60_000);
213 let (s, milli) = (rest / 1000, rest % 1000);
214 (h * 3600 + m * 60 + s) as f64 + milli as f64 / 1000.0
215}
216
217fn format_timestamp(ms: u64, omit_hours_if_zero: bool) -> String {
218 let (h, rest) = (ms / 3_600_000, ms % 3_600_000);
219 let (m, rest) = (rest / 60_000, rest % 60_000);
220 let (s, milli) = (rest / 1000, rest % 1000);
221 if omit_hours_if_zero && h == 0 {
222 format!("{m:02}:{s:02}.{milli:03}")
223 } else {
224 format!("{h:02}:{m:02}:{s:02}.{milli:03}")
225 }
226}
227
228struct Cue {
229 identifier: Option<String>,
230 start: u64,
231 end: u64,
232 text: String,
233}
234
235fn serialize_doc(parts: &[Part], options: &VttExportOptions) -> String {
237 let mut title: Option<&str> = None;
238 let mut cues: Vec<Cue> = Vec::new();
239 for part in parts {
240 if part.text.is_empty() {
241 continue;
242 }
243 if part.title {
244 title = Some(&part.text);
245 continue;
246 }
247 let Some(track) = part.track else {
248 continue;
249 };
250 let start_s = track["start_time"].as_f64().unwrap_or(0.0);
251 let end_s = track["end_time"].as_f64().unwrap_or(0.0);
252 let identifier = track["identifier"].as_str().map(str::to_string);
253 if let Some(cue) = cues.last_mut().filter(|c| {
254 c.identifier == identifier
255 && timestamp_seconds(c.start) == start_s
256 && timestamp_seconds(c.end) == end_s
257 }) {
258 let joined = format!("{}\n{}", cue.text.trim_end(), part.text);
259 cue.text = remove_pairs_until_stable(joined);
260 } else {
261 cues.push(Cue {
262 identifier,
263 start: millis(start_s),
264 end: millis(end_s),
265 text: part.text.clone(),
266 });
267 }
268 }
269 let mut out = match title {
270 Some(t) => format!("WEBVTT {t}\n"),
271 None => "WEBVTT\n".to_string(),
272 };
273 for cue in &cues {
274 out.push('\n');
275 if let Some(id) = &cue.identifier {
276 out.push_str(id);
277 out.push('\n');
278 }
279 out.push_str(&format_timestamp(cue.start, options.omit_hours_if_zero));
280 out.push_str(" --> ");
281 out.push_str(&format_timestamp(cue.end, options.omit_hours_if_zero));
282 out.push('\n');
283 let payload = cue.text.trim_end_matches('\n');
284 if options.omit_voice_end && is_single_voice_span(payload) {
285 out.push_str(payload.strip_suffix("</v>").unwrap_or(payload));
286 } else {
287 out.push_str(payload);
288 }
289 out.push('\n');
290 }
291 out.trim_end_matches('\n').to_string()
292}
293
294fn is_single_voice_span(payload: &str) -> bool {
297 if !payload.starts_with("<v") || !payload.ends_with("</v>") {
298 return false;
299 }
300 let mut depth = 0i32;
303 let mut i = 0;
304 let b = payload.as_bytes();
305 while i < b.len() {
306 if b[i] == b'<' {
307 let close = payload[i..].find('>').map(|j| i + j);
308 let Some(end) = close else { return false };
309 let tag = &payload[i + 1..end];
310 if tag.starts_with('/') {
311 depth -= 1;
312 if depth == 0 && end + 1 != b.len() {
313 return false;
314 }
315 } else if !tag.chars().next().is_some_and(|c| c.is_ascii_digit()) {
316 depth += 1;
318 }
319 i = end + 1;
320 } else {
321 if depth == 0 {
322 return false;
323 }
324 i += 1;
325 }
326 }
327 depth == 0
328}
329
330fn span_name(s: &str) -> Option<&str> {
332 if s.starts_with("lang") {
333 Some("lang")
334 } else {
335 s.chars()
336 .next()
337 .filter(|c| "bciuv".contains(*c))
338 .map(|_| &s[..1])
339 }
340}
341
342fn start_tag<'a>(s: &'a str, want: Option<&str>) -> Option<(&'a str, &'a str, &'a str, usize)> {
345 let rest = s.strip_prefix('<')?;
346 let name = match want {
347 Some(w) => rest.starts_with(w).then_some(&rest[..w.len()])?,
348 None => span_name(rest)?,
349 };
350 let mut pos = 1 + name.len();
351 let classes_start = pos;
353 loop {
354 let tail = &s[pos..];
355 let Some(after_dot) = tail.strip_prefix('.') else {
356 break;
357 };
358 let word = after_dot
359 .char_indices()
360 .find(|(_, c)| !(c.is_alphanumeric() || *c == '_'))
361 .map_or(after_dot.len(), |(i, _)| i);
362 if word == 0 {
363 break;
364 }
365 pos += 1 + word;
366 }
367 let classes = &s[classes_start..pos];
368 let tail = &s[pos..];
370 if let Some(stripped) = tail.strip_prefix('>') {
371 let _ = stripped;
372 return Some((name, classes, "", pos + 1));
373 }
374 let ws = tail
375 .char_indices()
376 .find(|(_, c)| !c.is_whitespace())
377 .map_or(tail.len(), |(i, _)| i);
378 if ws == 0 {
379 return None;
380 }
381 let anno_start = pos + ws;
382 let close = s[anno_start..].find('>')?;
383 if close == 0 {
384 return None;
385 }
386 Some((
387 name,
388 classes,
389 &s[anno_start..anno_start + close],
390 anno_start + close + 1,
391 ))
392}
393
394fn merge_adjacent(text: &str) -> String {
400 let mut out = String::with_capacity(text.len());
401 let mut i = 0;
402 while i < text.len() {
403 if let Some(m) = match_adjacent(&text[i..]) {
404 out.push_str(&m.0);
405 i += m.1;
406 } else {
407 let c = text[i..].chars().next().expect("in bounds");
408 out.push(c);
409 i += c.len_utf8();
410 }
411 }
412 out
413}
414
415fn match_adjacent(s: &str) -> Option<(String, usize)> {
416 let (name, classes1, anno1, open_len) = start_tag(s, None)?;
417 let end_tag = format!("</{name}>");
418 let close = s[open_len..].find(&end_tag)?;
419 let content = &s[open_len..open_len + close];
420 if content.contains('\n') {
421 return None;
422 }
423 let after_close = open_len + close + end_tag.len();
424 let tail = &s[after_close..];
425 let ws_len = tail
426 .char_indices()
427 .find(|(_, c)| !c.is_whitespace())
428 .map_or(tail.len(), |(i, _)| i);
429 let ws = &tail[..ws_len];
430 let (_, classes2, anno2, open2_len) = start_tag(&tail[ws_len..], Some(name))?;
431 let total = after_close + ws_len + open2_len;
432 if classes1 == classes2 && anno1 == anno2 {
433 let anno = if anno1.is_empty() {
434 String::new()
435 } else {
436 format!(" {anno1}")
437 };
438 Some((format!("<{name}{classes1}{anno}>{content}{ws}"), total))
439 } else {
440 Some((s[..total].to_string(), total))
441 }
442}
443
444fn merge_around(text: &str) -> String {
446 let mut out = String::with_capacity(text.len());
447 let mut i = 0;
448 while i < text.len() {
449 if let Some((keep, len)) = match_around(&text[i..]) {
450 out.push_str(keep);
451 i += len;
452 } else {
453 let c = text[i..].chars().next().expect("in bounds");
454 out.push(c);
455 i += c.len_utf8();
456 }
457 }
458 out
459}
460
461fn match_around(s: &str) -> Option<(&str, usize)> {
462 let rest = s.strip_prefix("</")?;
463 let name = span_name(rest)?;
464 let after_name = 2 + name.len();
465 if !s[after_name..].starts_with('>') {
466 return None;
467 }
468 let mid_start = after_name + 1;
469 let tail = &s[mid_start..];
470 if !tail.starts_with('<') {
471 return None;
472 }
473 let mid_close = tail[1..].find('>')? + 1;
474 if mid_close < 2 {
475 return None;
476 }
477 let mid = &tail[..mid_close + 1];
478 let (_, _, _, open_len) = start_tag(&tail[mid.len()..], Some(name))?;
479 Some((mid, mid_start + mid.len() + open_len))
480}
481
482fn remove_pairs_until_stable(mut text: String) -> String {
484 loop {
485 let next = merge_around(&merge_adjacent(&text));
486 if next == text {
487 return text;
488 }
489 text = next;
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn merges_like_upstreams_patterns() {
499 let cases = [
502 ("<i>a</i> <i>b</i>", "<i>a b</i>"),
503 (
504 "<v Speaker A>OK,</v>\n<v Speaker A>I think</v>",
505 "<v Speaker A>OK,\nI think</v>",
506 ),
507 ("<v A>x</v> <v B>y</v>", "<v A>x</v> <v B>y</v>"),
508 (
509 "<i><b>unexpected</b></i><i> </i><u><i>arcobaleno</i></u><i> of flavors</i>",
510 "<i><b>unexpected</b> <u>arcobaleno</u> of flavors</i>",
511 ),
512 ("<b.loud>a</b><b.loud>b</b>", "<b.loud>ab</b>"),
513 ("<b.loud>a</b><b>b</b>", "<b.loud>a</b><b>b</b>"),
514 ("<i>a\nb</i><i>c</i>", "<i>a\nb</i><i>c</i>"),
516 ("<lang en>x</lang><lang en>y</lang>", "<lang en>xy</lang>"),
517 ("<u>a</u><i></i><u>b</u>", "<u>a</u><i></i><u>b</u>"),
519 ("<b>a</b> \n <b>c</b>", "<b>a \n c</b>"),
520 ];
521 for (input, want) in cases {
522 assert_eq!(
523 remove_pairs_until_stable(input.to_string()),
524 want,
525 "{input}"
526 );
527 }
528 }
529
530 fn segment(start: f64, end: f64, words: &str) -> crate::Node {
533 crate::Node::Track {
534 track: crate::tree::TreeTrack {
535 start_time: start,
536 end_time: end,
537 identifier: None,
538 voice: None,
539 },
540 cue: words.into(),
541 inner: Box::new(crate::Node::Paragraph {
542 text: format!("[time: {start}-{end}] {words}"),
543 }),
544 }
545 }
546
547 #[test]
552 fn asr_segments_become_cues_like_upstream() {
553 let mut doc = DoclingDocument::new("talk");
554 for (s, e, t) in [
555 (0.0, 2.345678, "And so my fellow Americans"),
556 (2.5, 2.501, "ask not"),
557 (3.0, 4.25, "what your country"),
558 (3.0, 4.25, "can do for you"),
559 (3661.2, 3662.0, "late remark"),
560 ] {
561 doc.push(segment(s, e, t));
562 }
563 assert_eq!(
564 doc.export_to_vtt(),
565 "WEBVTT\n\n00:00:00.000 --> 00:00:02.346\nAnd so my fellow Americans\n\n\
566 00:00:02.500 --> 00:00:02.501\nask not\n\n00:00:03.000 --> 00:00:04.250\n\
567 what your country\ncan do for you\n\n01:01:01.200 --> 01:01:02.000\nlate remark"
568 );
569 let json = doc.export_to_json_value();
572 assert_eq!(json["texts"][0]["text"], "And so my fellow Americans");
573 assert_eq!(json["texts"][0]["orig"], "And so my fellow Americans");
574 assert_eq!(
575 json["texts"][0]["source"],
576 serde_json::json!([{"kind": "track", "start_time": 0.0, "end_time": 2.345678}])
577 );
578 assert!(doc
580 .export_to_markdown()
581 .starts_with("[time: 0-2.345678] And so"));
582 }
583
584 #[test]
587 fn untimed_documents_are_the_bare_header() {
588 let mut doc = DoclingDocument::new("x");
589 doc.push(crate::Node::Heading {
590 level: 1,
591 text: "My Title".into(),
592 });
593 doc.push(crate::Node::Paragraph {
594 text: "no timing".into(),
595 });
596 assert_eq!(doc.export_to_vtt(), "WEBVTT My Title");
597 let mut doc = DoclingDocument::new("y");
598 doc.push(crate::Node::Paragraph {
599 text: "no timing".into(),
600 });
601 assert_eq!(doc.export_to_vtt(), "WEBVTT");
602 }
603
604 #[test]
607 fn cue_text_is_escaped() {
608 let mut doc = DoclingDocument::new("e");
609 doc.push(segment(1.0, 2.0, "Q&A: a < b > c"));
610 assert_eq!(
611 doc.export_to_vtt(),
612 "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nQ&A: a < b > c"
613 );
614 }
615
616 #[test]
617 fn timestamps_round_half_even_and_format() {
618 assert_eq!(format_timestamp(millis(4.963), false), "00:00:04.963");
619 assert_eq!(format_timestamp(millis(14_586.5), false), "04:03:06.500");
620 assert_eq!(format_timestamp(millis(62.0), true), "01:02.000");
621 assert_eq!(millis(0.0005), 0); assert_eq!(millis(0.0015), 2);
623 assert_eq!(timestamp_seconds(millis(4.963)), 4.963);
624 }
625
626 #[test]
627 fn voice_end_is_omitted_only_for_a_lone_voice_span() {
628 assert!(is_single_voice_span("<v A>OK,\nI think</v>"));
629 assert!(is_single_voice_span("<v A><i>x</i> y</v>"));
630 assert!(!is_single_voice_span("<v Esme>Hee!</v> <i>laughter</i>"));
631 assert!(!is_single_voice_span("<v A>x</v><v B>y</v>"));
632 assert!(!is_single_voice_span("plain"));
633 }
634}