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    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
59/// One serialized top-level part: its text and the item that times it.
60struct 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
90/// docling-core's `_iterate_items_with_stack` with `with_groups`: every
91/// body-layer node in pre-order, a picture's children skipped bar its
92/// captions; an inline group serializes its items itself (they are then
93/// `visited`).
94fn 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                // The group's first child times it.
123                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
158/// `WebVTTTextSerializer.serialize`: a title's text as is, a timed item's
159/// text with its formatting and voice spans, nothing for the rest.
160fn 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('&', "&amp;").replace('>', "&gt;")
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
196/// `WebVTTCueTextSpan.__str__`: `&` and the cue-text terminator `<` escaped.
197fn escape(text: &str) -> String {
198    text.replace('&', "&amp;").replace('<', "&lt;")
199}
200
201/// `WebVTTTimestamp.from_seconds(s)`: whole milliseconds, rounded half to
202/// even like Python's `round`.
203fn millis(seconds: f64) -> u64 {
204    (seconds * 1000.0).round_ties_even().max(0.0) as u64
205}
206
207/// `WebVTTTimestamp.seconds` of [`millis`]: what upstream compares a later
208/// item's start/end against when it merges items into one cue.
209fn 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
234/// `WebVTTDocSerializer.serialize_doc` with `WebVTTFile.format`.
235fn 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
293/// Whether the payload is one `<v …>…</v>` span — `WebVTTCueBlock.format`'s
294/// condition for `omit_voice_end` (a single component of kind `v`).
295fn is_single_voice_span(payload: &str) -> bool {
296    if !payload.starts_with("<v") || !payload.ends_with("</v>") {
297        return false;
298    }
299    // The opening `<v …>` must be closed by the final `</v>` and nothing else
300    // at depth 0.
301    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                // `<00:00:01.000>` timestamps are not spans.
316                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
329/// The span names upstream's merge patterns match (`[bciuv]|lang`).
330fn 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
341/// A start tag `<name(.class)*( annotation)?>` at the start of `s` with the
342/// given name (any name when `None`): (name, classes, annotation, length).
343fn 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    // (?:\.\w+)*
351    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    // (?:\s+([^>]+))? then `>`
368    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
393/// Upstream's first pattern, `re.sub` left to right: `<tag…>content</tag>
394/// ws <tag…>` with matching classes and annotation becomes `<tag…>content
395/// ws` — the second start tag and the first end tag dropped. `content` is the
396/// text up to the first `</tag>` and may not hold a line break (`.` stops at
397/// one); a non-matching pair is consumed unchanged.
398fn 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
443/// Upstream's second pattern: `</tag><other…><tag…>` becomes `<other…>`.
444fn 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
481/// `_remove_consecutive_pairs` until nothing changes.
482fn 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        // Each expected value is docling-core 2.101's `_remove_consecutive_pairs`
499        // run to a fixed point on the same input.
500        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            // `.` stops at a line break: no merge across one inside a span.
514            ("<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            // Two tags in between: neither pattern applies.
517            ("<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    /// An ASR segment as `docling-asr` builds it: the `[time: …]`
530    /// paragraph wrapped in its track.
531    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    /// The flat (ASR) path against docling-core 2.101: the same segments as
547    /// `add_text(..., source=TrackSource(...))` items give exactly these
548    /// files — cues of the words alone, identical timings merged into one
549    /// cue, the zero-duration bump visible, hours written.
550    #[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        // The JSON item is docling 2.135's ASR item: the words as text, the
569        // timing as its track source.
570        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        // Markdown is untouched by the wrapper.
578        assert!(doc
579            .export_to_markdown()
580            .starts_with("[time: 0-2.345678] And so"));
581    }
582
583    /// Without timed text the file is the header — with the title, if any
584    /// (docling-core writes `WEBVTT My Title` / `WEBVTT`).
585    #[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    /// Cue text is escaped as `WebVTTCueTextSpan` writes it (upstream fails
604    /// on a raw `&` or `<` instead).
605    #[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&amp;A: a &lt; 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); // 0.5 ms → even
621        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}