Skip to main content

document_svg/document/
ttml.rs

1//! Bounded text-only TTML/DFXP subtitle preview.
2//!
3//! This is a caption-text importer, not a TTML presentation engine. It reads
4//! `<p>` cue text and a bounded subset of media-clock/offset timing; it never
5//! evaluates XML entities, styles, animations, scripts, or external resources.
6
7use std::path::Path;
8
9use quick_xml::Reader;
10use quick_xml::events::{BytesStart, Event};
11
12use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
13use crate::document::html::{HtmlBlock, render_blocks_to_pages};
14use crate::error::{Error, Result};
15
16const MAX_TTML_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_TTML_DEPTH: usize = 256;
18const MAX_TTML_TAG_BYTES: usize = 1024 * 1024;
19const MAX_TTML_CUES: usize = 100_000;
20const MAX_TTML_CUE_TEXT_BYTES: usize = 2 * 1024 * 1024;
21const MAX_TTML_RENDERED_TEXT_BYTES: usize = 32 * 1024 * 1024;
22const TTML_NAMESPACE: &str = "http://www.w3.org/ns/ttml";
23const LEGACY_TTML_NAMESPACES: &[&str] = &[
24    "http://www.w3.org/2006/10/ttaf1",
25    "http://www.w3.org/2006/04/ttaf1",
26];
27
28#[derive(Clone, Debug)]
29struct ElementFrame {
30    name: String,
31    begin_ms: u64,
32    in_body: bool,
33    valid_timing: bool,
34}
35
36#[derive(Default)]
37struct CueBuilder {
38    start_ms: Option<u64>,
39    end_ms: Option<u64>,
40    identifier: Option<String>,
41    text: String,
42    valid: bool,
43}
44
45#[derive(Default)]
46struct ParseWarnings {
47    malformed_cues: usize,
48    unsupported_times: usize,
49    flattened_nested_timing: usize,
50    ignored_container_intervals: usize,
51    styling: bool,
52    image_content: usize,
53    controls: usize,
54}
55
56pub(crate) fn looks_like_ttml_prefix(prefix: &[u8]) -> bool {
57    let mut reader = Reader::from_reader(prefix);
58    reader.config_mut().trim_text(true);
59    let mut buffer = Vec::new();
60    for _ in 0..128 {
61        match reader.read_event_into(&mut buffer) {
62            Ok(Event::Start(start)) | Ok(Event::Empty(start)) => {
63                return is_ttml_root(&start, reader.decoder()).unwrap_or(false);
64            }
65            Ok(Event::DocType(_)) | Ok(Event::Eof) | Err(_) => return false,
66            Ok(_) => {}
67        }
68        buffer.clear();
69    }
70    false
71}
72
73pub(crate) fn convert(
74    path: &Path,
75    options: &ConvertOptions,
76    sink: &mut dyn PageConsumer,
77) -> Result<Vec<String>> {
78    let bytes = read_limited_file(
79        path,
80        options.max_input_bytes.min(MAX_TTML_BYTES),
81        "TTML input",
82    )?;
83    let xml = String::from_utf8(bytes)
84        .map_err(|error| Error::InvalidInput(format!("TTML input must be UTF-8: {error}")))?;
85    let (blocks, warnings) = parse_ttml_blocks(&xml, options.max_xml_events)?;
86    let mut page_sink = TtmlPageSink {
87        inner: sink,
88        warnings: &warnings,
89    };
90    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
91    Ok(warnings)
92}
93
94struct TtmlPageSink<'a> {
95    inner: &'a mut dyn PageConsumer,
96    warnings: &'a [String],
97}
98
99impl PageConsumer for TtmlPageSink<'_> {
100    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
101        page.source_format = "ttml".into();
102        if page.title.is_empty() {
103            page.title = "TTML subtitles".into();
104        }
105        for warning in self.warnings {
106            page.warn(warning.clone());
107        }
108        self.inner.consume(page)
109    }
110}
111
112pub(crate) fn parse_ttml_blocks(
113    xml: &str,
114    max_events: usize,
115) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
116    if xml.len() as u64 > MAX_TTML_BYTES {
117        return Err(Error::LimitExceeded(format!(
118            "TTML input exceeds {MAX_TTML_BYTES} bytes"
119        )));
120    }
121    let mut reader = Reader::from_str(xml);
122    reader.config_mut().trim_text(false);
123    let mut buffer = Vec::new();
124    let mut stack = Vec::<ElementFrame>::new();
125    let mut cue = None::<CueBuilder>;
126    let mut blocks = vec![HtmlBlock::Heading {
127        level: 1,
128        text: "TTML subtitles".into(),
129    }];
130    let mut warnings = ParseWarnings::default();
131    let mut event_count = 0usize;
132    let mut rendered_text_bytes = 0usize;
133    let mut cue_count = 0usize;
134    let mut root_seen = false;
135    let mut root_is_empty = false;
136    let mut root_closed = false;
137
138    loop {
139        event_count = event_count.saturating_add(1);
140        if event_count > max_events {
141            return Err(Error::LimitExceeded(format!(
142                "TTML input exceeds {max_events} XML events"
143            )));
144        }
145        let event = reader.read_event_into(&mut buffer)?;
146        match event {
147            Event::Start(start) => {
148                if root_closed {
149                    return Err(Error::InvalidInput(
150                        "TTML XML contains content after its root element".into(),
151                    ));
152                }
153                let raw_tag: &[u8] = start.as_ref();
154                if raw_tag.len() > MAX_TTML_TAG_BYTES {
155                    return Err(Error::LimitExceeded(format!(
156                        "TTML XML tag exceeds {MAX_TTML_TAG_BYTES} bytes"
157                    )));
158                }
159                let name = local_element_name(start.name().as_ref())?;
160                if !root_seen {
161                    validate_root(&start, reader.decoder())?;
162                    validate_time_base(&start, reader.decoder())?;
163                    root_seen = true;
164                }
165                let parent_begin = stack.last().map_or(0, |frame| frame.begin_ms);
166                let parent_valid = stack.last().is_none_or(|frame| frame.valid_timing);
167                let in_body = name == "body" || stack.last().is_some_and(|frame| frame.in_body);
168                let (begin_ms, valid_timing) = element_begin(
169                    &start,
170                    reader.decoder(),
171                    parent_begin,
172                    parent_valid,
173                    &mut warnings,
174                )?;
175                inspect_style_attributes(&start, &mut warnings)?;
176                if has_sequential_time_container(&start, reader.decoder())? {
177                    return Err(Error::Unsupported(
178                        "TTML sequential time containers are not supported".into(),
179                    ));
180                }
181                if name == "p" && in_body {
182                    if cue.is_some() {
183                        return Err(Error::InvalidInput(
184                            "TTML paragraph cues may not be nested".into(),
185                        ));
186                    }
187                    cue = Some(build_cue(
188                        &start,
189                        reader.decoder(),
190                        parent_begin,
191                        begin_ms,
192                        valid_timing,
193                        &mut warnings,
194                    )?);
195                } else if matches!(name.as_str(), "span" | "br")
196                    && cue.is_some()
197                    && has_timing_attributes(&start)?
198                {
199                    warnings.flattened_nested_timing =
200                        warnings.flattened_nested_timing.saturating_add(1);
201                }
202                if name == "br"
203                    && let Some(cue) = cue.as_mut()
204                {
205                    cue.text.push_str(" · ");
206                }
207                if name == "image" || name == "data" {
208                    warnings.image_content = warnings.image_content.saturating_add(1);
209                }
210                if matches!(name.as_str(), "style" | "styling" | "region" | "layout") {
211                    warnings.styling = true;
212                }
213                if stack.len() >= MAX_TTML_DEPTH {
214                    return Err(Error::LimitExceeded(format!(
215                        "TTML nesting exceeds {MAX_TTML_DEPTH} elements"
216                    )));
217                }
218                stack.push(ElementFrame {
219                    name,
220                    begin_ms,
221                    in_body,
222                    valid_timing,
223                });
224            }
225            Event::Empty(start) => {
226                if root_closed {
227                    return Err(Error::InvalidInput(
228                        "TTML XML contains content after its root element".into(),
229                    ));
230                }
231                let raw_tag: &[u8] = start.as_ref();
232                if raw_tag.len() > MAX_TTML_TAG_BYTES {
233                    return Err(Error::LimitExceeded(format!(
234                        "TTML XML tag exceeds {MAX_TTML_TAG_BYTES} bytes"
235                    )));
236                }
237                let name = local_element_name(start.name().as_ref())?;
238                if !root_seen {
239                    validate_root(&start, reader.decoder())?;
240                    validate_time_base(&start, reader.decoder())?;
241                    root_seen = true;
242                    root_is_empty = true;
243                    root_closed = true;
244                }
245                let parent_begin = stack.last().map_or(0, |frame| frame.begin_ms);
246                let parent_valid = stack.last().is_none_or(|frame| frame.valid_timing);
247                let in_body = name == "body" || stack.last().is_some_and(|frame| frame.in_body);
248                let (begin_ms, valid_timing) = element_begin(
249                    &start,
250                    reader.decoder(),
251                    parent_begin,
252                    parent_valid,
253                    &mut warnings,
254                )?;
255                inspect_style_attributes(&start, &mut warnings)?;
256                if has_sequential_time_container(&start, reader.decoder())? {
257                    return Err(Error::Unsupported(
258                        "TTML sequential time containers are not supported".into(),
259                    ));
260                }
261                if name == "p" && in_body {
262                    if cue.is_some() {
263                        return Err(Error::InvalidInput(
264                            "TTML paragraph cues may not be nested".into(),
265                        ));
266                    }
267                    let empty_cue = build_cue(
268                        &start,
269                        reader.decoder(),
270                        parent_begin,
271                        begin_ms,
272                        valid_timing,
273                        &mut warnings,
274                    )?;
275                    finish_cue(
276                        empty_cue,
277                        &mut blocks,
278                        &mut warnings,
279                        &mut cue_count,
280                        &mut rendered_text_bytes,
281                    )?;
282                }
283                if name == "br"
284                    && let Some(cue) = cue.as_mut()
285                {
286                    cue.text.push_str(" · ");
287                }
288                if name == "image" || name == "data" {
289                    warnings.image_content = warnings.image_content.saturating_add(1);
290                }
291                if matches!(name.as_str(), "style" | "styling" | "region" | "layout") {
292                    warnings.styling = true;
293                }
294            }
295            Event::Text(text) => {
296                let decoded = text.decode().map_err(|error| {
297                    Error::InvalidInput(format!("invalid TTML text encoding: {error}"))
298                })?;
299                let unescaped = quick_xml::escape::unescape(&decoded).map_err(|error| {
300                    Error::InvalidInput(format!("invalid TTML text entity: {error}"))
301                })?;
302                if let Some(cue) = cue.as_mut() {
303                    append_cue_text(cue, &unescaped, &mut warnings)?;
304                } else if (root_closed || !root_seen) && !unescaped.trim().is_empty() {
305                    return Err(Error::InvalidInput(
306                        "TTML XML has text outside its root element".into(),
307                    ));
308                }
309            }
310            Event::CData(text) => {
311                if let Some(cue) = cue.as_mut() {
312                    let decoded = text.decode().map_err(|error| {
313                        Error::InvalidInput(format!("invalid TTML CDATA encoding: {error}"))
314                    })?;
315                    append_cue_text(cue, &decoded, &mut warnings)?;
316                } else {
317                    return Err(Error::InvalidInput(
318                        "TTML CDATA appears outside a text cue".into(),
319                    ));
320                }
321            }
322            Event::GeneralRef(reference) => {
323                if let Some(cue) = cue.as_mut() {
324                    let decoded = crate::ooxml::decode_xml_reference(&reference, "TTML cue text")?;
325                    append_cue_text(cue, &decoded, &mut warnings)?;
326                }
327            }
328            Event::End(end) => {
329                let name = local_element_name(end.name().as_ref())?;
330                let Some(frame) = stack.pop() else {
331                    return Err(Error::InvalidInput(
332                        "TTML XML has an unmatched end tag".into(),
333                    ));
334                };
335                if frame.name != name {
336                    return Err(Error::InvalidInput(
337                        "TTML XML element nesting is malformed".into(),
338                    ));
339                }
340                if name == "p"
341                    && frame.in_body
342                    && let Some(cue) = cue.take()
343                {
344                    finish_cue(
345                        cue,
346                        &mut blocks,
347                        &mut warnings,
348                        &mut cue_count,
349                        &mut rendered_text_bytes,
350                    )?;
351                }
352                if stack.is_empty() {
353                    root_closed = true;
354                }
355            }
356            Event::DocType(_) => {
357                return Err(Error::InvalidInput(
358                    "TTML document type declarations are not supported".into(),
359                ));
360            }
361            Event::Eof => break,
362            _ => {}
363        }
364        buffer.clear();
365    }
366
367    if !root_seen || root_is_empty || !root_closed {
368        return Err(Error::InvalidInput(
369            "TTML document has no subtitle body".into(),
370        ));
371    }
372    if !stack.is_empty() || cue.is_some() {
373        return Err(Error::InvalidInput(
374            "TTML document ended before all elements closed".into(),
375        ));
376    }
377    if cue_count == 0 {
378        return Err(Error::InvalidInput(
379            "TTML document contains no readable text cues".into(),
380        ));
381    }
382    Ok((blocks, warning_messages(warnings)))
383}
384
385fn validate_root(start: &BytesStart<'_>, decoder: quick_xml::encoding::Decoder) -> Result<()> {
386    if local_element_name(start.name().as_ref())? != "tt" || !is_ttml_root(start, decoder)? {
387        return Err(Error::InvalidInput(
388            "XML root is not a recognized TTML <tt> document".into(),
389        ));
390    }
391    Ok(())
392}
393
394fn validate_time_base(start: &BytesStart<'_>, decoder: quick_xml::encoding::Decoder) -> Result<()> {
395    let time_base = attribute_value(start, b"timeBase", decoder)?;
396    if time_base
397        .as_deref()
398        .is_some_and(|value| !value.eq_ignore_ascii_case("media"))
399    {
400        return Err(Error::Unsupported(
401            "TTML timeBase values other than media are not supported".into(),
402        ));
403    }
404    Ok(())
405}
406
407fn is_ttml_root(start: &BytesStart<'_>, decoder: quick_xml::encoding::Decoder) -> Result<bool> {
408    if local_element_name(start.name().as_ref())? != "tt" {
409        return Ok(false);
410    }
411    for attribute in start.attributes() {
412        let attribute = attribute.map_err(|error| {
413            Error::InvalidInput(format!("invalid TTML root attribute: {error}"))
414        })?;
415        let key = attribute.key.as_ref();
416        if key == b"xmlns" || key.starts_with(b"xmlns:") {
417            let value = attribute
418                .decoded_and_normalized_value(quick_xml::XmlVersion::Implicit1_0, decoder)
419                .map_err(|error| Error::InvalidInput(format!("invalid TTML namespace: {error}")))?;
420            if value == TTML_NAMESPACE || LEGACY_TTML_NAMESPACES.contains(&value.as_ref()) {
421                return Ok(true);
422            }
423        }
424    }
425    Ok(false)
426}
427
428fn element_begin(
429    start: &BytesStart<'_>,
430    decoder: quick_xml::encoding::Decoder,
431    parent_begin: u64,
432    parent_valid: bool,
433    warnings: &mut ParseWarnings,
434) -> Result<(u64, bool)> {
435    let element_name = local_element_name(start.name().as_ref())?;
436    if element_name != "p"
437        && (attribute_value(start, b"end", decoder)?.is_some()
438            || attribute_value(start, b"dur", decoder)?.is_some())
439    {
440        warnings.ignored_container_intervals =
441            warnings.ignored_container_intervals.saturating_add(1);
442    }
443    let Some(value) = attribute_value(start, b"begin", decoder)? else {
444        return Ok((parent_begin, parent_valid));
445    };
446    let Some(begin) = parse_time_expression(&value) else {
447        warnings.unsupported_times = warnings.unsupported_times.saturating_add(1);
448        return Ok((parent_begin, false));
449    };
450    let Some(begin) = parent_begin.checked_add(begin) else {
451        warnings.unsupported_times = warnings.unsupported_times.saturating_add(1);
452        return Ok((parent_begin, false));
453    };
454    Ok((begin, parent_valid))
455}
456
457fn build_cue(
458    start: &BytesStart<'_>,
459    decoder: quick_xml::encoding::Decoder,
460    parent_begin: u64,
461    absolute_begin: u64,
462    valid_timing: bool,
463    warnings: &mut ParseWarnings,
464) -> Result<CueBuilder> {
465    let mut cue = CueBuilder {
466        start_ms: Some(absolute_begin),
467        valid: valid_timing,
468        ..CueBuilder::default()
469    };
470    cue.identifier = attribute_value(start, b"id", decoder)?;
471    let end = attribute_value(start, b"end", decoder)?;
472    let duration = attribute_value(start, b"dur", decoder)?;
473    if let Some(end) = end {
474        if let Some(end) = parse_time_expression(&end)
475            && let Some(end) = parent_begin.checked_add(end)
476        {
477            cue.end_ms = Some(end);
478        } else {
479            cue.valid = false;
480            warnings.unsupported_times = warnings.unsupported_times.saturating_add(1);
481        }
482    } else if let Some(duration) = duration {
483        if let Some(duration) = parse_time_expression(&duration)
484            && let Some(end) = absolute_begin.checked_add(duration)
485        {
486            cue.end_ms = Some(end);
487        } else {
488            cue.valid = false;
489            warnings.unsupported_times = warnings.unsupported_times.saturating_add(1);
490        }
491    }
492    if let Some(id) = &cue.identifier
493        && id.len() > 1024
494    {
495        cue.valid = false;
496        warnings.malformed_cues = warnings.malformed_cues.saturating_add(1);
497    }
498    Ok(cue)
499}
500
501fn has_timing_attributes(start: &BytesStart<'_>) -> Result<bool> {
502    for attribute in start.attributes() {
503        let attribute = attribute
504            .map_err(|error| Error::InvalidInput(format!("invalid TTML cue attribute: {error}")))?;
505        if matches!(
506            local_attribute_name(attribute.key.as_ref()),
507            b"begin" | b"end" | b"dur"
508        ) {
509            return Ok(true);
510        }
511    }
512    Ok(false)
513}
514
515fn has_sequential_time_container(
516    start: &BytesStart<'_>,
517    decoder: quick_xml::encoding::Decoder,
518) -> Result<bool> {
519    Ok(attribute_value(start, b"timeContainer", decoder)?
520        .is_some_and(|value| value.eq_ignore_ascii_case("seq")))
521}
522
523fn inspect_style_attributes(start: &BytesStart<'_>, warnings: &mut ParseWarnings) -> Result<()> {
524    for attribute in start.attributes() {
525        let attribute = attribute
526            .map_err(|error| Error::InvalidInput(format!("invalid TTML attribute: {error}")))?;
527        let key = attribute.key.as_ref();
528        let local = local_attribute_name(key);
529        if key.starts_with(b"tts:")
530            || key.starts_with(b"itts:")
531            || matches!(
532                local,
533                b"style"
534                    | b"region"
535                    | b"fontSize"
536                    | b"fontFamily"
537                    | b"color"
538                    | b"backgroundColor"
539                    | b"textAlign"
540                    | b"displayAlign"
541            )
542        {
543            warnings.styling = true;
544        }
545    }
546    Ok(())
547}
548
549fn attribute_value(
550    start: &BytesStart<'_>,
551    name: &[u8],
552    decoder: quick_xml::encoding::Decoder,
553) -> Result<Option<String>> {
554    for attribute in start.attributes() {
555        let attribute = attribute
556            .map_err(|error| Error::InvalidInput(format!("invalid TTML XML attribute: {error}")))?;
557        if local_attribute_name(attribute.key.as_ref()) == name {
558            let value = attribute
559                .decoded_and_normalized_value(quick_xml::XmlVersion::Implicit1_0, decoder)
560                .map_err(|error| {
561                    Error::InvalidInput(format!("invalid TTML attribute value: {error}"))
562                })?;
563            return Ok(Some(value.into_owned()));
564        }
565    }
566    Ok(None)
567}
568
569fn local_element_name(name: &[u8]) -> Result<String> {
570    let local = name.rsplit(|byte| *byte == b':').next().unwrap_or(name);
571    std::str::from_utf8(local)
572        .map(str::to_owned)
573        .map_err(|error| Error::InvalidInput(format!("TTML element name is not UTF-8: {error}")))
574}
575
576fn local_attribute_name(name: &[u8]) -> &[u8] {
577    name.rsplit(|byte| *byte == b':').next().unwrap_or(name)
578}
579
580fn append_cue_text(cue: &mut CueBuilder, text: &str, warnings: &mut ParseWarnings) -> Result<()> {
581    for character in text.chars() {
582        if character.is_control() && !matches!(character, '\t' | '\n' | '\r') {
583            warnings.controls = warnings.controls.saturating_add(1);
584            continue;
585        }
586        cue.text.push(character);
587        if cue.text.len() > MAX_TTML_CUE_TEXT_BYTES {
588            return Err(Error::LimitExceeded(format!(
589                "TTML cue text exceeds {MAX_TTML_CUE_TEXT_BYTES} bytes"
590            )));
591        }
592    }
593    Ok(())
594}
595
596fn finish_cue(
597    cue: CueBuilder,
598    blocks: &mut Vec<HtmlBlock>,
599    warnings: &mut ParseWarnings,
600    cue_count: &mut usize,
601    rendered_text_bytes: &mut usize,
602) -> Result<()> {
603    let text = cue.text.split_whitespace().collect::<Vec<_>>().join(" ");
604    if !cue.valid || text.is_empty() {
605        warnings.malformed_cues = warnings.malformed_cues.saturating_add(1);
606        return Ok(());
607    }
608    let start = cue.start_ms.unwrap_or(0);
609    if cue.end_ms.is_some_and(|end| end < start) {
610        warnings.malformed_cues = warnings.malformed_cues.saturating_add(1);
611        return Ok(());
612    }
613    *cue_count = cue_count.saturating_add(1);
614    if *cue_count > MAX_TTML_CUES {
615        return Err(Error::LimitExceeded(format!(
616            "TTML exceeds {MAX_TTML_CUES} cues"
617        )));
618    }
619    let mut line = format!(
620        "{}–{}",
621        format_time(start),
622        cue.end_ms.map_or_else(|| "?".into(), format_time)
623    );
624    if let Some(identifier) = cue.identifier.filter(|identifier| !identifier.is_empty()) {
625        line.push_str(" [");
626        line.push_str(&sanitize_identifier(&identifier));
627        line.push(']');
628    }
629    line.push_str("  ");
630    line.push_str(&text);
631    *rendered_text_bytes = rendered_text_bytes.saturating_add(line.len());
632    if *rendered_text_bytes > MAX_TTML_RENDERED_TEXT_BYTES {
633        return Err(Error::LimitExceeded(format!(
634            "TTML rendered text exceeds {MAX_TTML_RENDERED_TEXT_BYTES} bytes"
635        )));
636    }
637    blocks.push(HtmlBlock::Paragraph { text: line });
638    Ok(())
639}
640
641fn parse_time_expression(value: &str) -> Option<u64> {
642    if value.contains(':') {
643        let (clock, fraction) = value
644            .split_once('.')
645            .map_or((value, None), |(clock, fraction)| (clock, Some(fraction)));
646        let parts = clock.split(':').collect::<Vec<_>>();
647        let [hours, minutes, seconds] = parts.as_slice() else {
648            return None;
649        };
650        if hours.len() < 2
651            || minutes.len() != 2
652            || seconds.len() != 2
653            || !hours.bytes().all(|byte| byte.is_ascii_digit())
654            || !minutes.bytes().all(|byte| byte.is_ascii_digit())
655            || !seconds.bytes().all(|byte| byte.is_ascii_digit())
656        {
657            return None;
658        }
659        let hours = hours.parse::<u64>().ok()?;
660        let minutes = minutes.parse::<u64>().ok()?;
661        let seconds = seconds.parse::<u64>().ok()?;
662        if minutes > 59 || seconds > 59 {
663            return None;
664        }
665        let mut milliseconds = hours
666            .checked_mul(3_600_000)?
667            .checked_add(minutes.checked_mul(60_000)?)?
668            .checked_add(seconds.checked_mul(1_000)?)?;
669        if let Some(fraction) = fraction {
670            milliseconds = milliseconds.checked_add(parse_fraction_ms(fraction)?)?;
671        }
672        return Some(milliseconds);
673    }
674
675    let (number, factor) = if let Some(value) = value.strip_suffix("ms") {
676        (value, 1)
677    } else if let Some(value) = value.strip_suffix('h') {
678        (value, 3_600_000)
679    } else if let Some(value) = value.strip_suffix('m') {
680        (value, 60_000)
681    } else if let Some(value) = value.strip_suffix('s') {
682        (value, 1_000)
683    } else {
684        return None;
685    };
686    parse_decimal_scaled(number, factor)
687}
688
689fn parse_decimal_scaled(value: &str, factor: u64) -> Option<u64> {
690    let (whole, fraction) = value
691        .split_once('.')
692        .map_or((value, None), |(whole, fraction)| (whole, Some(fraction)));
693    if whole.is_empty() || !whole.bytes().all(|byte| byte.is_ascii_digit()) {
694        return None;
695    }
696    let whole = whole.parse::<u64>().ok()?.checked_mul(factor)?;
697    let Some(fraction) = fraction else {
698        return Some(whole);
699    };
700    if fraction.is_empty()
701        || fraction.len() > 9
702        || !fraction.bytes().all(|byte| byte.is_ascii_digit())
703    {
704        return None;
705    }
706    let digits = fraction.parse::<u64>().ok()?;
707    let scale = 10u64.checked_pow(fraction.len() as u32)?;
708    let fractional_ms = digits
709        .checked_mul(factor)?
710        .checked_add(scale / 2)?
711        .checked_div(scale)?;
712    whole.checked_add(fractional_ms)
713}
714
715fn parse_fraction_ms(fraction: &str) -> Option<u64> {
716    if fraction.is_empty()
717        || fraction.len() > 9
718        || !fraction.bytes().all(|byte| byte.is_ascii_digit())
719    {
720        return None;
721    }
722    let digits = fraction.parse::<u64>().ok()?;
723    let scale = 10u64.checked_pow(fraction.len() as u32)?;
724    digits
725        .checked_mul(1_000)?
726        .checked_add(scale / 2)?
727        .checked_div(scale)
728}
729
730fn format_time(milliseconds: u64) -> String {
731    let hours = milliseconds / 3_600_000;
732    let minutes = milliseconds / 60_000 % 60;
733    let seconds = milliseconds / 1_000 % 60;
734    let millis = milliseconds % 1_000;
735    format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
736}
737
738fn sanitize_identifier(identifier: &str) -> String {
739    identifier
740        .chars()
741        .filter(|character| !character.is_control())
742        .take(1024)
743        .collect()
744}
745
746fn warning_messages(warnings: ParseWarnings) -> Vec<String> {
747    let mut messages = Vec::new();
748    if warnings.malformed_cues > 0 {
749        messages.push(format!(
750            "{} malformed or empty TTML cues were skipped",
751            warnings.malformed_cues
752        ));
753    }
754    if warnings.unsupported_times > 0 {
755        messages.push(format!(
756            "{} TTML time expression(s) were unsupported; affected cues were skipped",
757            warnings.unsupported_times
758        ));
759    }
760    if warnings.flattened_nested_timing > 0 {
761        messages.push(format!(
762            "{} TTML span timing value(s) were flattened into their paragraph cue",
763            warnings.flattened_nested_timing
764        ));
765    }
766    if warnings.ignored_container_intervals > 0 {
767        messages.push(format!(
768            "{} TTML container end/dur boundary value(s) were ignored; paragraph cue end/dur values are applied",
769            warnings.ignored_container_intervals
770        ));
771    }
772    if warnings.styling {
773        messages.push("TTML styles, regions, and visual layout were not reproduced".into());
774    }
775    if warnings.image_content > 0 {
776        messages.push(format!(
777            "{} TTML image/data element(s) were omitted; only text-profile content is rendered",
778            warnings.image_content
779        ));
780    }
781    if warnings.controls > 0 {
782        messages.push(format!(
783            "{} TTML control character(s) were removed",
784            warnings.controls
785        ));
786    }
787    messages
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    const SIMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
795<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter"
796    xmlns:tts="http://www.w3.org/ns/ttml#styling" ttp:timeBase="media">
797  <head><styling><style xml:id="bold" tts:fontWeight="bold"/></styling>
798    <layout><region xml:id="bottom" tts:origin="10% 80%"/></layout></head>
799  <body><div>
800    <p xml:id="cue-1" begin="00:00:01.000" end="00:00:03.500" region="bottom">Hello <span style="bold">world</span>.<br/>字幕</p>
801    <p begin="3.5s" dur="2s">Later &amp; next.</p>
802  </div></body>
803</tt>"#;
804
805    #[test]
806    fn parses_ttml_text_clock_times_offsets_styles_and_identifiers() {
807        let (blocks, warnings) = parse_ttml_blocks(SIMPLE, 10_000).unwrap();
808        let paragraphs = blocks
809            .iter()
810            .filter_map(|block| match block {
811                HtmlBlock::Paragraph { text } => Some(text.as_str()),
812                _ => None,
813            })
814            .collect::<Vec<_>>();
815        assert_eq!(paragraphs.len(), 2);
816        assert!(paragraphs[0].contains("00:00:01.000–00:00:03.500 [cue-1]  Hello world. · 字幕"));
817        assert!(paragraphs[1].contains("00:00:03.500–00:00:05.500  Later & next."));
818        assert!(
819            warnings
820                .iter()
821                .any(|warning| warning.contains("styles, regions"))
822        );
823    }
824
825    #[test]
826    fn detects_ttml_by_namespace_and_rejects_other_xml() {
827        assert!(looks_like_ttml_prefix(SIMPLE.as_bytes()));
828        assert!(!looks_like_ttml_prefix(
829            b"<root xmlns=\"urn:example\"><tt>text</tt></root>"
830        ));
831    }
832
833    #[test]
834    fn rejects_doctypes_non_media_time_bases_and_sequential_timing() {
835        let doctype =
836            r#"<!DOCTYPE tt [<!ENTITY x "expanded">]><tt xmlns="http://www.w3.org/ns/ttml"/>"#;
837        assert!(parse_ttml_blocks(doctype, 1_000).is_err());
838        let clock = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:timeBase="clock"/>"#;
839        assert!(matches!(
840            parse_ttml_blocks(clock, 1_000),
841            Err(Error::Unsupported(_))
842        ));
843        let sequential =
844            r#"<tt xmlns="http://www.w3.org/ns/ttml"><body timeContainer="seq"/></tt>"#;
845        assert!(matches!(
846            parse_ttml_blocks(sequential, 1_000),
847            Err(Error::Unsupported(_))
848        ));
849        let multiple_roots = r#"<tt xmlns="http://www.w3.org/ns/ttml"><body/></tt><tt xmlns="http://www.w3.org/ns/ttml"/>"#;
850        assert!(parse_ttml_blocks(multiple_roots, 1_000).is_err());
851    }
852
853    #[test]
854    fn skips_frame_based_times_with_warning_and_preserves_safe_text() {
855        let source = r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><p begin="00:00:01:12" end="00:00:02:00">not timed</p><p begin="00:00:02.000" end="00:00:03.000">&lt;script&gt;literal&lt;/script&gt;</p></body></tt>"#;
856        let (blocks, warnings) = parse_ttml_blocks(source, 1_000).unwrap();
857        assert!(
858            warnings
859                .iter()
860                .any(|warning| warning.contains("time expression"))
861        );
862        assert!(blocks.iter().any(|block| matches!(block, HtmlBlock::Paragraph { text } if text.contains("<script>literal</script>"))));
863    }
864
865    #[test]
866    fn warns_when_parent_end_does_not_clip_child_cue() {
867        let source = r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div begin="1s" dur="5s"><p begin="1s" end="6s">clipped later</p></div></body></tt>"#;
868        let (blocks, warnings) = parse_ttml_blocks(source, 1_000).unwrap();
869        assert!(
870            warnings
871                .iter()
872                .any(|warning| warning.contains("container end/dur boundary"))
873        );
874        assert!(blocks.iter().any(|block| matches!(block, HtmlBlock::Paragraph { text } if text.contains("00:00:02.000–00:00:07.000"))));
875    }
876
877    #[test]
878    fn omits_external_image_references_and_enforces_event_limits() {
879        let source = r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><p begin="0s" end="1s">Caption <image src="https://example.invalid/caption.png"/></p></body></tt>"#;
880        let (blocks, warnings) = parse_ttml_blocks(source, 1_000).unwrap();
881        assert!(
882            warnings
883                .iter()
884                .any(|warning| warning.contains("image/data element"))
885        );
886        assert!(blocks.iter().all(|block| match block {
887            HtmlBlock::Heading { text, .. } | HtmlBlock::Paragraph { text } =>
888                !text.contains("example.invalid"),
889            _ => true,
890        }));
891        assert!(matches!(
892            parse_ttml_blocks(SIMPLE, 1),
893            Err(Error::LimitExceeded(_))
894        ));
895    }
896}