Skip to main content

docling_core/
vtt.rs

1//! WebVTT export (#614) — a port of docling-core's `WebVTTDocSerializer`
2//! (`transforms/serializer/webvtt.py`, docling-core 2.101) and the
3//! `WebVTTFile` / `WebVTTCueBlock` formatting it hands its cues to, which is
4//! what docling's `--to vtt` writes.
5//!
6//! The walk is upstream's: the body-layer items in document order (picture
7//! children skipped, captions kept), where only a text item whose `source`
8//! is a track makes a cue — a WebVTT input's cues and an ASR segment
9//! ([`Node::Track`](crate::Node::Track)) — and a title item names the file
10//! (`WEBVTT <title>`). Everything else (tables, pictures, lists, untimed
11//! text) is not represented, so a document without timed text is the bare
12//! `WEBVTT` header, as upstream writes it. The items are read from the
13//! document's JSON export, so this sees exactly the labels, groups,
14//! formatting and tracks the JSON carries.
15//!
16//! Per item, its text gets the cue spans of its formatting — `<b>`, then
17//! `<i>`, then `<u>` around that (upstream's `post_process` order) — and a
18//! `<v voice>` span outermost. An inline group joins its items' texts.
19//! Consecutive items of one cue (same identifier and timings — a voice span
20//! broken over lines in the source) join with a line feed. Redundant tag
21//! pairs (`</i><i>`, `</v>\n<v A>`) are merged by the ports of upstream's two
22//! regular expressions below. Text is escaped as `WebVTTCueTextSpan` writes it
23//! (`&amp;`, `&lt;`); upstream re-parses the cue text instead, and fails on a
24//! raw `&` or `<`.
25
26use serde_json::Value;
27
28use crate::DoclingDocument;
29
30/// Options of the WebVTT export — docling-core's `WebVTTParams`.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct VttExportOptions {
33    /// Write `MM:SS.mmm` when a cue's hour is 0 (`omit_hours_if_zero`).
34    pub omit_hours_if_zero: bool,
35    /// Drop the `</v>` of a cue whose payload is one voice span
36    /// (`omit_voice_end`).
37    pub omit_voice_end: bool,
38}
39
40impl Default for VttExportOptions {
41    /// What docling's `--to vtt` writes (`DoclingDocument.save_as_vtt`): hours
42    /// always, voice end tags omitted.
43    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    // Tables stay typed (#875): cues never come from them.
53    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
60/// One serialized top-level part: its text and the item that times it.
61struct 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
91/// docling-core's `_iterate_items_with_stack` with `with_groups`: every
92/// body-layer node in pre-order, a picture's children skipped bar its
93/// captions; an inline group serializes its items itself (they are then
94/// `visited`).
95fn 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                // The group's first child times it.
124                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
159/// `WebVTTTextSerializer.serialize`: a title's text as is, a timed item's
160/// text with its formatting and voice spans, nothing for the rest.
161fn 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('&', "&amp;").replace('>', "&gt;")
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
197/// `WebVTTCueTextSpan.__str__`: `&` and the cue-text terminator `<` escaped.
198fn escape(text: &str) -> String {
199    text.replace('&', "&amp;").replace('<', "&lt;")
200}
201
202/// `WebVTTTimestamp.from_seconds(s)`: whole milliseconds, rounded half to
203/// even like Python's `round`.
204fn millis(seconds: f64) -> u64 {
205    (seconds * 1000.0).round_ties_even().max(0.0) as u64
206}
207
208/// `WebVTTTimestamp.seconds` of [`millis`]: what upstream compares a later
209/// item's start/end against when it merges items into one cue.
210fn 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
235/// `WebVTTDocSerializer.serialize_doc` with `WebVTTFile.format`.
236fn 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
294/// Whether the payload is one `<v …>…</v>` span — `WebVTTCueBlock.format`'s
295/// condition for `omit_voice_end` (a single component of kind `v`).
296fn is_single_voice_span(payload: &str) -> bool {
297    if !payload.starts_with("<v") || !payload.ends_with("</v>") {
298        return false;
299    }
300    // The opening `<v …>` must be closed by the final `</v>` and nothing else
301    // at depth 0.
302    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                // `<00:00:01.000>` timestamps are not spans.
317                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
330/// The span names upstream's merge patterns match (`[bciuv]|lang`).
331fn 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
342/// A start tag `<name(.class)*( annotation)?>` at the start of `s` with the
343/// given name (any name when `None`): (name, classes, annotation, length).
344fn 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    // (?:\.\w+)*
352    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    // (?:\s+([^>]+))? then `>`
369    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
394/// Upstream's first pattern, `re.sub` left to right: `<tag…>content</tag>
395/// ws <tag…>` with matching classes and annotation becomes `<tag…>content
396/// ws` — the second start tag and the first end tag dropped. `content` is the
397/// text up to the first `</tag>` and may not hold a line break (`.` stops at
398/// one); a non-matching pair is consumed unchanged.
399fn 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
444/// Upstream's second pattern: `</tag><other…><tag…>` becomes `<other…>`.
445fn 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
482/// `_remove_consecutive_pairs` until nothing changes.
483fn 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        // Each expected value is docling-core 2.101's `_remove_consecutive_pairs`
500        // run to a fixed point on the same input.
501        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            // `.` stops at a line break: no merge across one inside a span.
515            ("<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            // Two tags in between: neither pattern applies.
518            ("<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    /// An ASR segment as `docling-asr` builds it: the `[time: …]`
531    /// paragraph wrapped in its track.
532    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    /// The flat (ASR) path against docling-core 2.101: the same segments as
548    /// `add_text(..., source=TrackSource(...))` items give exactly these
549    /// files — cues of the words alone, identical timings merged into one
550    /// cue, the zero-duration bump visible, hours written.
551    #[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        // The JSON item is docling 2.135's ASR item: the words as text, the
570        // timing as its track source.
571        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        // Markdown is untouched by the wrapper.
579        assert!(doc
580            .export_to_markdown()
581            .starts_with("[time: 0-2.345678] And so"));
582    }
583
584    /// Without timed text the file is the header — with the title, if any
585    /// (docling-core writes `WEBVTT My Title` / `WEBVTT`).
586    #[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    /// Cue text is escaped as `WebVTTCueTextSpan` writes it (upstream fails
605    /// on a raw `&` or `<` instead).
606    #[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&amp;A: a &lt; 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); // 0.5 ms → even
622        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}