1use 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 & 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"><script>literal</script></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}