craft-captions: TTML/DFXP export keeps literal < and > text

Ported from storytold/filmcraft#2830. The writer treated every < with a later >
as a tag and wrote nothing for unknown names, so "1 < 2 and 3 > 2" lost its
middle. Only a well-formed tag is markup now; a > inside a quoted attribute
value does not end the tag.
This commit is contained in:
NetterSilver
2026-11-11 23:14:28 +00:00
committed by Dylan M. Taylor
parent 0da0d9b2e8
commit aecd7d8a6c
3 changed files with 149 additions and 19 deletions
+75 -19
View File
@@ -108,6 +108,75 @@ fn time_expr(t: Tick, rate: Option<FrameRate>) -> String {
}
}
/// Memoised "next occurrence of a quote byte" searches, so unterminated quotes are not rescanned.
#[derive(Default)]
struct QuoteCache {
found: [Option<(usize, Option<usize>)>; 2],
}
impl QuoteCache {
/// Index of the next `quote` byte at or after `from`.
fn next(&mut self, bytes: &[u8], quote: u8, from: usize) -> Option<usize> {
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, `<i>`/`<b>`/`<u>` 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 "a<b and c>d" 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 };
+1
View File
@@ -1,4 +1,5 @@
mod limits;
mod ttml_literal_angle;
mod mcc_708_timing;
mod roundtrip;
mod ttml_body_timing;
@@ -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 &lt; 2 and 3 &gt; 2"), ("a<b and c>d", "a&lt;b and c&gt;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("<b>text</b> x <y> z <i>it</i>", format);
assert!(text.contains("<span tts:fontWeight=\"bold\">text</span>"), "{format:?}: {text}");
assert!(text.contains("<span tts:fontStyle=\"italic\">it</span>"), "{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<br/>b <b/>c", format);
assert!(text.contains(">ab c</p>"), "{format:?}: {text}");
}
}
#[test]
fn quoted_gt_in_attribute_does_not_end_tag() {
for format in [Format::Ttml, Format::Dfxp] {
let text = export("<b title=\">\">text</b>", format);
assert!(text.contains("<span tts:fontWeight=\"bold\">text</span>"), "{format:?}: {text}");
}
}
#[test]
fn single_quoted_attributes_are_stripped() {
for format in [Format::Ttml, Format::Dfxp] {
let text = export("<b title='caption'>word</b>", format);
assert!(text.contains("<span tts:fontWeight=\"bold\">word</span>"), "{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 &lt; b &gt; 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!("{}&gt;", "&lt;".repeat(256_000))));
}