diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs
index 3755202d..152aa3e0 100644
--- a/crates/captions/src/ttml.rs
+++ b/crates/captions/src/ttml.rs
@@ -108,6 +108,75 @@ fn time_expr(t: Tick, rate: Option) -> String {
}
}
+/// Memoised "next occurrence of a quote byte" searches, so unterminated quotes are not rescanned.
+#[derive(Default)]
+struct QuoteCache {
+ found: [Option<(usize, Option)>; 2],
+}
+
+impl QuoteCache {
+ /// Index of the next `quote` byte at or after `from`.
+ fn next(&mut self, bytes: &[u8], quote: u8, from: usize) -> Option {
+ let slot = usize::from(quote == b'\'');
+ if let Some((searched, result)) = self.found[slot]
+ && searched <= from
+ && result.is_none_or(|r| r >= from)
+ {
+ return result;
+ }
+ let result = bytes.get(from..).and_then(|t| t.iter().position(|&c| c == quote)).map(|i| from + i);
+ self.found[slot] = Some((from, result));
+ result
+ }
+}
+
+/// Parses a tag starting at `bytes[start] == b'<'`: optional `/` (closing), a name of ASCII
+/// letters/digits starting with a letter immediately after, then whitespace-separated
+/// `name="value"` / `name='value'` attributes, optional `/` before `>`. A `>` inside a quoted
+/// value does not end the tag. Returns `(index after '>', closing, lowercase name)`; the name is empty for a self-closing tag.
+fn parse_tag(bytes: &[u8], start: usize, cache: &mut QuoteCache) -> Option<(usize, bool, String)> {
+ let mut i = start + 1;
+ let closing = bytes.get(i) == Some(&b'/');
+ if closing {
+ i += 1;
+ }
+ let name_start = i;
+ if !bytes.get(i)?.is_ascii_alphabetic() {
+ return None;
+ }
+ while bytes.get(i).is_some_and(u8::is_ascii_alphanumeric) {
+ i += 1;
+ }
+ let name = String::from_utf8_lossy(bytes.get(name_start..i)?).to_ascii_lowercase();
+ loop {
+ let ws_start = i;
+ while bytes.get(i).is_some_and(u8::is_ascii_whitespace) {
+ i += 1;
+ }
+ match bytes.get(i)? {
+ b'>' => return Some((i + 1, closing, name)),
+ // self-closing: no markup
+ b'/' if !closing && bytes.get(i + 1) == Some(&b'>') => return Some((i + 2, closing, String::new())),
+ _ => {}
+ }
+ if i == ws_start {
+ return None;
+ }
+ let key_start = i;
+ while bytes.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b':' | b'_')) {
+ i += 1;
+ }
+ if i == key_start || bytes.get(i) != Some(&b'=') {
+ return None;
+ }
+ let quote = *bytes.get(i + 1)?;
+ if quote != b'"' && quote != b'\'' {
+ return None;
+ }
+ i = cache.next(bytes, quote, i + 2)? + 1;
+ }
+}
+
/// Caption text (`\n` lines, ``/``/`` tags) → TTML inline content.
fn inline(text: &str) -> String {
let mut out = String::new();
@@ -129,25 +198,12 @@ fn inline(text: &str) -> String {
}
};
let mut rest = text;
- // Once no `>` remains, no later `<` can start a tag either. Remember that instead of searching
- // the shrinking suffix again after every literal `<`, which made `<<<<…` quadratic.
- let mut closer_remains = true;
+ let mut cache = QuoteCache::default();
while !rest.is_empty() {
- let tag_end = match rest.strip_prefix('<') {
- Some(after) if closer_remains => {
- let end = after.find('>');
- closer_remains = end.is_some();
- end
- }
- _ => None,
- };
- if let Some(end) = tag_end {
- let after = &rest[1..];
- let tag = after[..end].trim().to_ascii_lowercase();
- let (closing, name) = match tag.strip_prefix('/') {
- Some(n) => (true, n.trim().to_string()),
- None => (false, tag.split_whitespace().next().unwrap_or("").to_string()),
- };
+ // Only a well-formed tag is markup; "1 < 2 and 3 > 2" and "ad" are text.
+ if rest.starts_with('<')
+ && let Some((end, closing, name)) = parse_tag(text.as_bytes(), text.len() - rest.len(), &mut cache)
+ {
let attr = match name.as_str() {
"i" => Some("tts:fontStyle=\"italic\""),
"b" => Some("tts:fontWeight=\"bold\""),
@@ -161,7 +217,7 @@ fn inline(text: &str) -> String {
active.push(a);
}
}
- rest = &after[end + 1..];
+ rest = text.get(end..).unwrap_or("");
continue;
}
let Some(ch) = rest.chars().next() else { break };
diff --git a/crates/captions/tests/it/main.rs b/crates/captions/tests/it/main.rs
index 2efd793c..13b70a9f 100644
--- a/crates/captions/tests/it/main.rs
+++ b/crates/captions/tests/it/main.rs
@@ -1,4 +1,5 @@
mod limits;
+mod ttml_literal_angle;
mod mcc_708_timing;
mod roundtrip;
mod ttml_body_timing;
diff --git a/crates/captions/tests/it/ttml_literal_angle.rs b/crates/captions/tests/it/ttml_literal_angle.rs
new file mode 100644
index 00000000..65682630
--- /dev/null
+++ b/crates/captions/tests/it/ttml_literal_angle.rs
@@ -0,0 +1,73 @@
+//! TTML / DFXP export keeps literal `<` and `>` text that is not markup.
+
+use craft_captions::{Cue, Document, Format, WriteOptions, write};
+use craft_captions::FrameRate;
+
+fn export(text: &str, format: Format) -> String {
+ let rate = FrameRate::FPS_25;
+ let doc = Document { cues: vec![Cue { start: rate.tick_of(25), end: rate.tick_of(75), text: text.into(), ..Default::default() }], ..Default::default() };
+ String::from_utf8(write(&doc, format, WriteOptions::default())).unwrap()
+}
+
+#[test]
+fn literal_angle_brackets_keep_middle_text() {
+ for format in [Format::Ttml, Format::Dfxp] {
+ for (input, expected) in [("1 < 2 and 3 > 2", "1 < 2 and 3 > 2"), ("ad", "a<b and c>d")] {
+ let text = export(input, format);
+ assert!(text.contains(expected), "{format:?}: {text}");
+ }
+ }
+}
+
+#[test]
+fn real_tags_still_produce_markup() {
+ for format in [Format::Ttml, Format::Dfxp] {
+ let text = export("text x z it", format);
+ assert!(text.contains("text"), "{format:?}: {text}");
+ assert!(text.contains("it"), "{format:?}: {text}");
+ assert!(text.contains(" x z "), "{format:?}: {text}");
+ }
+}
+
+#[test]
+fn self_closing_tags_are_still_stripped() {
+ for format in [Format::Ttml, Format::Dfxp] {
+ let text = export("a
b c", format);
+ assert!(text.contains(">ab c
"), "{format:?}: {text}");
+ }
+}
+
+#[test]
+fn quoted_gt_in_attribute_does_not_end_tag() {
+ for format in [Format::Ttml, Format::Dfxp] {
+ let text = export("\">text", format);
+ assert!(text.contains("text"), "{format:?}: {text}");
+ }
+}
+
+#[test]
+fn single_quoted_attributes_are_stripped() {
+ for format in [Format::Ttml, Format::Dfxp] {
+ let text = export("word", format);
+ assert!(text.contains("word"), "{format:?}: {text}");
+ assert!(!text.contains("caption"), "{format:?}: {text}");
+ }
+}
+
+#[test]
+fn whitespace_after_lt_is_literal_text() {
+ for format in [Format::Ttml, Format::Dfxp] {
+ let text = export("1 < b > 2", format);
+ assert!(text.contains("1 < b > 2"), "{format:?}: {text}");
+ assert!(!text.contains("fontWeight"), "{format:?}: {text}");
+ }
+}
+
+#[test]
+fn many_unmatched_lt_is_fast() {
+ let input = format!("{}>", "<".repeat(256_000));
+ let start = std::time::Instant::now();
+ let text = export(&input, Format::Ttml);
+ assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed());
+ assert!(text.contains(&format!("{}>", "<".repeat(256_000))));
+}