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 `