diff --git a/crates/captions/README.md b/crates/captions/README.md index 6a0331da..0676d1d7 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -144,3 +144,22 @@ Text is encoded in ISO/IEC 6937 (character code table `00`). Characters it has n the ellipsis, the em dash, the euro sign and most scripts other than Latin, are written as `?`; `charset_warnings` names each one with how often it occurs and the first caption that has it, at the STL file's own time code. + +### TTML inherited text styles + +On import, italic, bold and underline styles from ``, `

` and timed `` roots appear in +the cue text as balanced ``, `` and `` tags. Descendant overrides and explicit +`normal` / `noUnderline` resets are kept, and `inherit` restores the parent value. Styling +attributes are read from the TTML styling namespaces (and those of the 2006 drafts), with +unqualified attributes as a compatibility fallback; other namespaces are ignored. Named-style +chaining and `xml:space="preserve"` are not supported. The inline text collector does not +recurse. Content style inheritance follows [W3C TTML1, second edition (2013)](https://www.w3.org/TR/2013/REC-ttml1-20130924/#semantics-style-inheritance-content). + +### Literal text and style markup + +Cue text combines style tags with character entities, as WebVTT does. The TTML and EBU STL +readers keep a literal `<` as `<` (and a literal `&` that would read as an entity as `&`), +so it never turns into a tag. The TTML and STL writers decode the eight entities `entity_at` +knows: `&`, `<`, `>`, ` `, `"`, `'`, `‎` and `‏`; other named +entities and numeric references stay literal. The SRT writer keeps stored entities next to real +style tags, so a reimport does not turn a literal bracket into markup. diff --git a/crates/captions/src/lib.rs b/crates/captions/src/lib.rs index 0859b3d6..0ba9f2ba 100644 --- a/crates/captions/src/lib.rs +++ b/crates/captions/src/lib.rs @@ -43,7 +43,7 @@ pub use craft_timeline::time; pub mod ttml; pub mod vtt; -pub use text::plain_text; +pub use text::{decode_entities, entity_at, plain_text}; pub use time::{FrameRate, TICKS_PER_SECOND, Tick}; /// The creating program MCC and EBU STL files name when the caller gives none. diff --git a/crates/captions/src/srt.rs b/crates/captions/src/srt.rs index 0c51eede..83c19275 100644 --- a/crates/captions/src/srt.rs +++ b/crates/captions/src/srt.rs @@ -99,6 +99,7 @@ pub fn write(doc: &Document) -> String { out.push_str(&format!("{}\n{} --> {}\n", n + 1, format_clock(c.start, ','), format_clock(c.end, ','))); // blank lines inside text would end the cue: drop them for l in c.text.lines().filter(|l| !l.trim().is_empty()) { + // stored entities stay as they are, so a literal bracket does not become a tag on reimport out.push_str(&escape(l)); out.push('\n'); } @@ -116,6 +117,17 @@ mod tests { Tick(v * TICKS_PER_MS) } + #[test] + fn entities_stay_distinct_from_style_tags_after_reimport() { + let mut d = Document::default(); + d.cues.push(Cue { start: ms(1000), end: ms(2000), text: "1 < 2 & x".into(), ..Default::default() }); + let encoded = write(&d); + assert_eq!(encoded, "1\n00:00:01,000 --> 00:00:02,000\n1 < 2 & x\n\n"); + let decoded = parse(&encoded).unwrap(); + assert_eq!(decoded.cues, d.cues); + assert_eq!(crate::plain_text(&decoded.cues[0].text), "1 < 2 & x"); + } + #[test] fn basic() { let d = parse("1\n00:00:01,000 --> 00:00:02,500\nHello\nWorld\n\n2\n00:00:03,000 --> 00:00:04,000\nBye\n").unwrap(); diff --git a/crates/captions/src/stl.rs b/crates/captions/src/stl.rs index 82bd79ff..93dc9b89 100644 --- a/crates/captions/src/stl.rs +++ b/crates/captions/src/stl.rs @@ -189,6 +189,9 @@ fn decode_tf(tf: &[u8], out: &mut String, italic: &mut bool, underline: &mut boo *underline = false; } 0x00..=0x1f | 0x7f..=0x9f => {} + // literal text, kept apart from the style tags this reader makes + b'<' => out.push_str("<"), + b'&' => out.push_str("&"), 0x20..=0x7e => out.push(b as char), 0xc1..=0xcf => { if let Some(&base) = tf.get(i) @@ -256,10 +259,16 @@ fn encode_text_noting(text: &str, lost: &mut Vec) -> Vec { continue; } } + // a stored entity is the character it names + if let Some((decoded, len)) = crate::entity_at(rest) { + put(&mut out, decoded); + rest = rest.get(len..).unwrap_or(""); + continue; + } if is_tag_start && !rest.contains('>') { // This cannot become markup later. Encode the remaining literal text in one pass // instead of searching the shrinking suffix after every opening bracket. - for ch in rest.chars() { + for ch in crate::decode_entities(rest).chars() { if ch == '\n' { out.push(0x8a); } else { @@ -582,12 +591,21 @@ mod tests { let encoded = write(&doc, Some(FrameRate::FPS_25), "test"); let decoded = parse(&encoded).expect("valid EBU STL"); assert_eq!(decoded.cues.len(), 1); - assert_eq!(decoded.cues[0].text, literal); + assert_eq!(crate::plain_text(&decoded.cues[0].text), literal); + assert_eq!(write(&decoded, Some(FrameRate::FPS_25), "test"), encoded, "another export preserves literal bytes"); // Recognized styling still becomes STL control bytes. assert_eq!(encode_text("Hi"), vec![0x80, b'H', b'i', 0x81]); assert_eq!(encode_text("Hi"), vec![0x80, b'H', b'i', 0x81]); } + #[test] + fn entities_are_encoded_as_their_characters() { + // a stored `<` is the character, not an entity spelled out; the tag beside it stays a tag + assert_eq!(encode_text("1 < 2 & x"), encode_text("1 < 2 & x")); + assert_eq!(encode_text("x <y> z"), b"x z".to_vec(), "a decoded bracket is text, not a tag"); + assert_eq!(encode_text("a <b"), encode_text("a '), ("nbsp;", '\u{a0}'), ("quot;", '"'), ("apos;", '\''), ("lrm;", '\u{200e}'), ("rlm;", '\u{200f}')]; + +/// The entity at the start of `s`, as the character it names and its length in bytes. Cue text +/// keeps a literal `<` or `&` as `<` / `&` (as a WebVTT import keeps its entities) so that +/// its style tags stay tags; plain text and the writers decode them here. +pub fn entity_at(s: &str) -> Option<(char, usize)> { + let rest = s.strip_prefix('&')?; + ENTITIES.iter().find(|(name, _)| rest.starts_with(name)).map(|(name, ch)| (*ch, name.len() + 1)) +} + +/// `s` with its entities decoded and its tags kept: for a format that has tags but no entities. +pub fn decode_entities(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(ch) = rest.chars().next() { + if let Some((decoded, len)) = entity_at(rest) { + out.push(decoded); + rest = rest.get(len..).unwrap_or(""); + } else { + out.push(ch); + rest = rest.get(ch.len_utf8()..).unwrap_or(""); + } + } + out +} + /// Strip `<…>` tags and decode the common HTML/WebVTT entities. pub fn plain_text(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -40,22 +68,12 @@ pub fn plain_text(s: &str) -> String { } } '&' => { - let rest: String = chars.clone().take(8).collect(); - let ents = [ - ("amp;", '&'), - ("lt;", '<'), - ("gt;", '>'), - ("nbsp;", '\u{a0}'), - ("quot;", '"'), - ("apos;", '\''), - ("lrm;", '\u{200e}'), - ("rlm;", '\u{200f}'), - ]; - if let Some((name, ch)) = ents.iter().find(|(n, _)| rest.starts_with(n)) { - for _ in 0..name.len() { + let rest: String = std::iter::once('&').chain(chars.clone().take(8)).collect(); + if let Some((ch, len)) = entity_at(&rest) { + for _ in 1..len { chars.next(); } - out.push(*ch); + out.push(ch); } else { out.push('&'); } @@ -86,4 +104,12 @@ mod tests { assert_eq!(plain_text(&angle), angle); assert_eq!(plain_text(&ssa), ssa); } + + #[test] + fn entities() { + assert_eq!(entity_at("<b"), Some(('<', 4))); + assert_eq!(entity_at("é"), None); + assert_eq!(entity_at("&"), None); + assert_eq!(decode_entities("1 < 2 & x < &"), "1 < 2 & x < &"); + } } diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index c439c2ff..78db4763 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -53,6 +53,11 @@ const CANONICAL: craft_xml::Canonical<'static> = craft_xml::Canonical::new(&[ ("ttm", NS_TTM), ("ttm10", NS_TTM_DRAFT), ("ttm04", NS_TTM_DRAFT_APRIL), + ("tts", NS_TTS), + ("tts10", "http://www.w3.org/2006/10/ttaf1#style"), + ("tts10s", "http://www.w3.org/2006/10/ttaf1#styling"), + ("tts04", "http://www.w3.org/2006/04/ttaf1#style"), + ("tts04s", "http://www.w3.org/2006/04/ttaf1#styling"), ]); /// The line (from 1) of the root element's start tag, for error messages. @@ -222,6 +227,12 @@ fn inline(text: &str) -> String { } let Some(ch) = rest.chars().next() else { break }; sync(&mut out, &active, &mut emitted); + // a stored entity is the character it names, escaped once for XML + if let Some((decoded, len)) = crate::entity_at(rest) { + out.push_str(&esc(&decoded.to_string())); + rest = rest.get(len..).unwrap_or(""); + continue; + } if ch == '\n' { out.push_str("
"); } else { @@ -567,92 +578,183 @@ struct Fmt { underline: bool, } -fn style_of(n: &El, styles: &std::collections::HashMap, parent: Fmt) -> Fmt { +/// An explicit `inherit` restores the parent value, even after earlier style references. +#[derive(Clone, Copy)] +enum FmtValue { + Set(bool), + Inherit, +} + +impl FmtValue { + fn resolve(self, inherited: bool) -> bool { + match self { + Self::Set(value) => value, + Self::Inherit => inherited, + } + } +} + +/// The style properties an element or a `