mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 10:03:51 +00:00
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:
committed by
Dylan M. Taylor
parent
0da0d9b2e8
commit
aecd7d8a6c
+75
-19
@@ -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,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 < 2 and 3 > 2"), ("a<b and c>d", "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("<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 < 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))));
|
||||
}
|
||||
Reference in New Issue
Block a user