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