craft-captions: TTML import keeps inherited styles; literal < and & stay text (#983)

* craft-captions: TTML cue text collapses only XML whitespace and keeps edge line breaks

Ported from storytold/filmcraft#3145. char::is_whitespace also matched U+00A0,
U+2009 and U+3000, so no-break spaces became ordinary spaces on import, and a
leading or trailing <br/> was trimmed away.

* craft-captions: TTML/DFXP export drops characters XML 1.0 forbids

Ported from storytold/filmcraft#4467. A control character in cue text made the
exported file unreadable. The filter also covers U+FFFE and U+FFFF, which the
review of that pull request asked for.

* craft-captions: WebVTT cue-text arrow escape is reversible

Ported from storytold/filmcraft#4437. A literal --&gt; in cue text came back as
--> after a write and parse; the writer now adds one amp; level and the reader
removes exactly one.

* craft-captions: a WebVTT speaker name with > no longer spills into the cue text

Ported from storytold/filmcraft#4470. The writer puts the lookalike U+203A in
place of > inside the voice tag. Also from that pull request's review: a line
break in a name is written as a space, and a name ending in - is kept apart
from the tag's closing >.

* craft-captions: an SRT text line that looks like a timing line survives a round trip

Ported from storytold/filmcraft#4448. The writer marks such a line with a
leading zero-width space and the reader removes it, so the cue is not split.

* craft-captions: back-to-back SCC captions with the same text stay separate cues

Ported from storytold/filmcraft#3808. Every End of Caption now closes the open
cue. MCC's CEA-608 path uses the same decoder.

* craft-captions: an SCC pop-on load may run across the previous caption's erase

Ported from storytold/filmcraft#1837 (filmcraft#1791). A caption that closely
follows another popped on about a second late, because its load had to be one
unbroken run of free frames. Doubled codes stay adjacent; pauses are allowed
between them.

* craft-captions: CEA-708 export starts each caption at its window's top left

Ported from storytold/filmcraft#4043 (filmcraft#4042). Each window load now
sends DeleteWindows before DefineWindow in place of the ClearWindows after it:
a window made anew is empty with the pen at its top left in any decoder, for
the same number of bytes, so cue timing is unchanged.

* 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.

* craft-captions: TTML/DFXP export keeps cue IDs unique and valid Unicode IDs as written

Ported from the TTML commit of storytold/filmcraft#1839. A cue named s1 could
collide with the style ID, and a generated c1 could take the ID of a later cue.
Valid explicit IDs are reserved first; missing, invalid or repeated ones get a
replacement that is in no one's way. IDs are checked as XML NCNames.

* craft-captions: compare the craft-xml and roxmltree views of TTML documents

The TTML reader moved from roxmltree to craft-xml. This dev-only test checks
that both hand it the same elements, attributes and text, by namespace, on
TTML documents and on the writer's output. roxmltree becomes a dev-dependency,
which the cue ID tests use too.

* craft-captions: use craft-timeline's exact time instead of a private copy

craft-captions carried a 290-line subset of the tick, frame rate and timecode
field code because craft-libs had none when it was added. craft-timeline now
has the whole module with the same tick unit. craft_captions::time is a
re-export of craft_timeline::time, so every public path still resolves and
the types are now the same ones craft-timeline's callers hold.

* craft-captions: SCC and MCC time codes count from the document's start timecode

Ported from the caption-format part of storytold/filmcraft#4131
(filmcraft#4130). Document::start_timecode is the label of time zero; the SCC
and MCC writers count from the 29.97 frame with the same label, so the label
carries across rates. Readers note the rate and drop-frame mode of a file's
labels in Document::labels, and Document::count_from counts cues back from a
start time code. charset_warnings names the time codes the file has, which
that pull request's review found missing.

* craft-captions: MCC export can write a packet on every frame

Ported from the caption-format part of storytold/filmcraft#4037
(filmcraft#4024). WriteOptions::every_frame_until fills every 29.97 fps frame
before that time that has no caption data with a packet of CEA-608 nulls and
CEA-708 padding, as SDI and frame-wrapped MXF carry one per frame. Off by
default, at most 24 hours (mcc::MAX_PADDED_FRAMES), and write_to streams the
file line by line.

* craft-captions: README has its MCC writer section, with the every-frame option

The section's text was cut off when the crate was added; only a stray heading
marker was left.

* craft-captions: export warnings name STL's ? and text cut to fit

Ported from storytold/filmcraft#4157 (filmcraft#4155, filmcraft#4156).
charset_warnings now names the characters EBU STL writes as ? (from the
writer's own encoder, at the STL file's time code) and each caption that
CEA-608's 15 rows or the CEA-708 window's 12 rows cut, with how much it lost.
The CEA-708 writer loads only the 12 rows its window holds. The time codes
count from the document's start time code, as the files do.

* craft-captions: TTML import keeps inherited and reset text styles

Ported from the TTML style part of storytold/filmcraft#1723
(filmcraft#1721). Italic, bold and underline set on <body> or on a timed
<span> cue root now reach the cue text; normal, noUnderline and inherit reset
or restore the parent value; styling is read from the TTML styling
namespaces (modern and 2006 drafts) with unqualified attributes as a
fallback, and foreign namespaces are ignored. The inline text collector is
iterative. Line breaks at the start or end of a cue are kept, as #3145's port
does, so this differs from the source pull request there.

* craft-captions: literal < and & stay text beside cue style tags

Ported from the literal-text part of storytold/filmcraft#1723. The TTML and
EBU STL readers keep a literal < as &lt; (and an & that would read as an
entity as &amp;), as a WebVTT import already keeps its entities, so text
like "&lt;b&gt;" in a TTML file never becomes a bold tag. The TTML and STL
writers decode those entities, so a cue read from WebVTT with "1 &lt; 2" is
no longer written as "1 &amp;lt; 2". entity_at and decode_entities are
shared with plain_text.

---------

Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Co-authored-by: SeeRay11 <searay112023@gmail.com>
Co-authored-by: jnohclee-rgb <336857373+jnohclee-rgb@users.noreply.github.com>
Co-authored-by: JonRC <jonarc06@gmail.com>
Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: nuclearspike <liquidmonkey@gmail.com>
This commit is contained in:
Dylan M. Taylor
2026-11-11 20:16:34 -05:00
committed by GitHub
co-authored by NetterSilver SeeRay11 jnohclee-rgb JonRC sanyamsingh001 nuclearspike
parent 6f51ec7f8d
commit fdbb9c2471
14 changed files with 918 additions and 103 deletions
+19
View File
@@ -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 `<body>`, `<p>` and timed `<span>` roots appear in
the cue text as balanced `<i>`, `<b>` and `<u>` 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 `&lt;` (and a literal `&` that would read as an entity as `&amp;`),
so it never turns into a tag. The TTML and STL writers decode the eight entities `entity_at`
knows: `&amp;`, `&lt;`, `&gt;`, `&nbsp;`, `&quot;`, `&apos;`, `&lrm;` and `&rlm;`; 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.
+1 -1
View File
@@ -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.
+12
View File
@@ -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 &lt; 2 &amp; <i>x</i>".into(), ..Default::default() });
let encoded = write(&d);
assert_eq!(encoded, "1\n00:00:01,000 --> 00:00:02,000\n1 &lt; 2 &amp; <i>x</i>\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\n<i>World</i>\n\n2\n00:00:03,000 --> 00:00:04,000\nBye\n").unwrap();
+20 -2
View File
@@ -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("&lt;"),
b'&' => out.push_str("&amp;"),
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<char>) -> Vec<u8> {
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("<i>Hi</i>"), vec![0x80, b'H', b'i', 0x81]);
assert_eq!(encode_text("<i >Hi</i >"), vec![0x80, b'H', b'i', 0x81]);
}
#[test]
fn entities_are_encoded_as_their_characters() {
// a stored `&lt;` is the character, not an entity spelled out; the tag beside it stays a tag
assert_eq!(encode_text("1 &lt; 2 &amp; <i>x</i>"), encode_text("1 < 2 & <i>x</i>"));
assert_eq!(encode_text("x &lt;y> z"), b"x <y> z".to_vec(), "a decoded bracket is text, not a tag");
assert_eq!(encode_text("a &lt;b"), encode_text("a <b"));
}
#[test]
fn unterminated_markup_is_encoded_in_one_pass() {
let text = format!("<i{}", "<".repeat(10_000));
+40 -14
View File
@@ -1,5 +1,33 @@
//! Caption text helpers shared by the writers.
/// The named entities cue text may carry, as a WebVTT import keeps them.
const ENTITIES: [(&str, char); 8] =
[("amp;", '&'), ("lt;", '<'), ("gt;", '>'), ("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 `&lt;` / `&amp;` (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("&lt;b"), Some(('<', 4)));
assert_eq!(entity_at("&eacute;"), None);
assert_eq!(entity_at("&"), None);
assert_eq!(decode_entities("1 &lt; 2 &amp; <i>x</i> &#60; &"), "1 < 2 & <i>x</i> &#60; &");
}
}
+190 -84
View File
@@ -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("<br/>");
} else {
@@ -567,92 +578,183 @@ struct Fmt {
underline: bool,
}
fn style_of(n: &El, styles: &std::collections::HashMap<String, Fmt>, 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 `<style>` sets; `None` leaves the inherited value.
#[derive(Clone, Copy, Default)]
struct FmtSpec {
italic: Option<FmtValue>,
bold: Option<FmtValue>,
underline: Option<FmtValue>,
}
impl FmtSpec {
fn apply(self, fmt: &mut Fmt, parent: Fmt) {
if let Some(v) = self.italic {
fmt.italic = v.resolve(parent.italic);
}
if let Some(v) = self.bold {
fmt.bold = v.resolve(parent.bold);
}
if let Some(v) = self.underline {
fmt.underline = v.resolve(parent.underline);
}
}
}
/// A styling attribute: from the TTML styling namespaces in a fixed order (whatever the order in
/// the file), else unqualified for compatibility. Other namespaces are ignored.
fn styling_attr<'a>(n: &'a El, name: &str) -> Option<&'a str> {
["tts", "tts10", "tts10s", "tts04", "tts04s"].into_iter().find_map(|p| n.attr(&format!("{p}:{name}"))).or_else(|| n.attr(name))
}
/// Is this element in a TTML content namespace (or none)?
fn content_element(n: &El, name: &str) -> bool {
local(&n.name) == name && matches!(prefix(&n.name), "" | "tt" | "tt10" | "tt04")
}
fn style_of(n: &El, styles: &HashMap<String, FmtSpec>, parent: Fmt) -> Fmt {
let mut f = parent;
if let Some(refs) = attr(n, "style") {
if let Some(refs) = n.attr("style") {
for r in refs.split_whitespace() {
if let Some(s) = styles.get(r) {
f.italic |= s.italic;
f.bold |= s.bold;
f.underline |= s.underline;
s.apply(&mut f, parent);
}
}
}
own_fmt(n, &mut f);
own_fmt(n).apply(&mut f, parent);
f
}
fn own_fmt(n: &El, f: &mut Fmt) {
if let Some(v) = attr(n, "fontStyle") {
f.italic = v.trim() == "italic" || v.trim() == "oblique";
}
if let Some(v) = attr(n, "fontWeight") {
f.bold = v.trim() == "bold";
}
if let Some(v) = attr(n, "textDecoration") {
let v = v.trim();
if v.contains("noUnderline") || v == "none" {
f.underline = false;
} else if v.contains("underline") {
f.underline = true;
}
fn own_fmt(n: &El) -> FmtSpec {
FmtSpec {
italic: styling_attr(n, "fontStyle").and_then(|v| match v.trim() {
"inherit" => Some(FmtValue::Inherit),
"italic" | "oblique" => Some(FmtValue::Set(true)),
"normal" | "reverseOblique" => Some(FmtValue::Set(false)),
_ => None,
}),
bold: styling_attr(n, "fontWeight").and_then(|v| match v.trim() {
"inherit" => Some(FmtValue::Inherit),
"bold" => Some(FmtValue::Set(true)),
"normal" => Some(FmtValue::Set(false)),
_ => None,
}),
underline: styling_attr(n, "textDecoration").and_then(|v| {
let v = v.trim();
if v == "inherit" {
Some(FmtValue::Inherit)
} else if v.contains("noUnderline") || v == "none" {
Some(FmtValue::Set(false))
} else if v.contains("underline") {
Some(FmtValue::Set(true))
} else {
None
}
}),
}
}
/// Text being collected from a `<p>`: whitespace collapses to one space, which is written only
/// before the next visible character or opening tag (never at a line start).
/// Text being collected from a `<p>` or timed `<span>`. XML whitespace collapses to one space in
/// the style of its first character, written only between visible text. Line breaks are kept,
/// each in the style it was in, also at the start and end of a cue.
struct Out {
s: String,
pending: bool,
pending: Option<Fmt>,
has_text: bool,
breaks: Vec<Fmt>,
tags: Vec<&'static str>,
}
impl Out {
fn flush(&mut self) {
if self.pending && !self.s.is_empty() && !self.s.ends_with('\n') {
if !self.breaks.is_empty() {
for fmt in std::mem::take(&mut self.breaks) {
self.set_fmt(fmt);
self.s.push('\n');
}
} else if self.has_text
&& let Some(fmt) = self.pending
{
self.set_fmt(fmt);
self.s.push(' ');
}
self.pending = false;
self.pending = None;
}
/// Keep the common tag prefix open and close removed tags in reverse order to preserve nesting.
fn set_fmt(&mut self, fmt: Fmt) {
let tags = [(fmt.italic, "i"), (fmt.bold, "b"), (fmt.underline, "u")].into_iter().filter(|x| x.0).map(|x| x.1);
let common = self.tags.iter().copied().zip(tags.clone()).take_while(|(a, b)| a == b).count();
for t in self.tags.iter().skip(common).rev() {
self.s.push_str(&format!("</{t}>"));
}
self.tags.truncate(common);
for t in tags.skip(common) {
self.s.push_str(&format!("<{t}>"));
self.tags.push(t);
}
}
}
/// Collect a `<p>`'s (or `<span>`'s) text with `<i>`/`<b>`/`<u>` tags, collapsing whitespace.
fn collect(n: &El, styles: &std::collections::HashMap<String, Fmt>, fmt: Fmt, out: &mut Out) {
for c in &n.children {
if let Node::Text(text) | Node::CData(text) = c {
for ch in text.chars() {
if is_xml_space(ch) {
out.pending = true;
} else {
out.flush();
out.s.push(ch);
}
}
} else if let Node::El(c) = c {
match local(&c.name) {
"br" => {
out.pending = false;
out.s.push('\n');
}
"span" => {
let f = style_of(c, styles, fmt);
let tags: Vec<&str> = [(f.italic && !fmt.italic, "i"), (f.bold && !fmt.bold, "b"), (f.underline && !fmt.underline, "u")]
.into_iter()
.filter(|x| x.0)
.map(|x| x.1)
.collect();
if !tags.is_empty() {
/// An explicit stack keeps nested inline content off the call stack.
fn collect<'a>(n: &'a El, styles: &HashMap<String, FmtSpec>, fmt: Fmt, out: &mut Out) {
// a child `<span>` takes its own style on top of its parent's
let push_children = |pending: &mut Vec<(&'a Node, Fmt)>, e: &'a El, fmt: Fmt| {
for child in e.children.iter().rev() {
let child_fmt = match child {
Node::El(c) if local(&c.name) == "span" => style_of(c, styles, fmt),
_ => fmt,
};
pending.push((child, child_fmt));
}
};
let mut pending: Vec<(&Node, Fmt)> = Vec::new();
push_children(&mut pending, n, fmt);
while let Some((node, fmt)) = pending.pop() {
match node {
Node::Text(text) | Node::CData(text) => {
for (i, ch) in text.char_indices() {
if is_xml_space(ch) {
out.pending.get_or_insert(fmt);
} else {
out.flush();
}
for t in &tags {
out.s.push_str(&format!("<{t}>"));
}
collect(c, styles, f, out);
for t in tags.iter().rev() {
out.s.push_str(&format!("</{t}>"));
out.set_fmt(fmt);
// Cue text carries its own `<i>`/`<b>`/`<u>` tags, so a literal `<` is kept
// as `&lt;` (as a WebVTT import keeps its entities), and a literal `&` that
// would read as an entity ("&lt;" typed as text) as `&amp;`; plain text and
// the writers decode them. Any other `&`, and a `>`, stay as they are.
match ch {
'<' => out.s.push_str("&lt;"),
'&' if crate::entity_at(text.get(i..).unwrap_or("")).is_some() => out.s.push_str("&amp;"),
_ => out.s.push(ch),
}
out.has_text = true;
}
}
_ => collect(c, styles, fmt, out),
}
Node::El(e) if local(&e.name) == "br" => {
// a collapsible space next to a hard line break goes
out.pending = None;
out.breaks.push(fmt);
}
Node::El(e) => push_children(&mut pending, e, fmt),
_ => {}
}
}
}
@@ -662,9 +764,18 @@ fn is_xml_space(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r')
}
fn clean(s: &str) -> String {
// `split` (not `lines`) keeps the empty edge lines produced by a leading or trailing `<br/>`.
s.split('\n').map(|l| l.trim_matches(is_xml_space)).collect::<Vec<_>>().join("\n")
/// The cue text of `n` in style `fmt`: empty when it has no visible text (only spaces or breaks).
fn cue_text(n: &El, styles: &HashMap<String, FmtSpec>, fmt: Fmt) -> String {
let mut o = Out { s: String::new(), pending: None, has_text: false, breaks: Vec::new(), tags: Vec::new() };
collect(n, styles, fmt, &mut o);
if !o.has_text {
return String::new();
}
// trailing breaks stay; a trailing space does not
o.pending = None;
o.flush();
o.set_fmt(Fmt::default());
o.s
}
pub fn parse(text: &str) -> Result<Document> {
@@ -684,12 +795,10 @@ pub fn parse(text: &str) -> Result<Document> {
}
let tm = timing(root, root_line(text))?;
let speakers = speaker_names(root);
let mut styles = std::collections::HashMap::new();
for s in root.descendants().filter(|n| local(&n.name) == "style") {
if let Some(id) = attr(s, "id") {
let mut f = Fmt::default();
own_fmt(s, &mut f);
styles.insert(id.to_string(), f);
let mut styles = HashMap::new();
for s in root.descendants().filter(|n| content_element(n, "style")) {
if let Some(id) = s.attr("xml:id").or_else(|| s.attr("id")) {
styles.insert(id.to_string(), own_fmt(s));
}
}
let mut warnings = Vec::new();
@@ -703,7 +812,15 @@ pub fn parse(text: &str) -> Result<Document> {
}
// the body is a time container like a div: its begin offsets, and its end clips, everything inside
let t = times(body, Scope { begin: Tick::ZERO, end: None }, tm, &mut doc);
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, &speakers, Fmt::default(), &mut doc);
walk(
body,
Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end },
&tm,
&styles,
&speakers,
style_of(body, &styles, Fmt::default()),
&mut doc,
);
doc.cues.sort_by_key(|c| (c.start, c.end));
Ok(doc)
}
@@ -772,7 +889,7 @@ fn times(n: &El, parent: Scope, tm: Timing, doc: &mut Document) -> Times {
Times { begin, end, bad }
}
fn walk(n: &El, parent: Scope, tm: &Timing, styles: &HashMap<String, Fmt>, speakers: &HashMap<String, String>, fmt: Fmt, doc: &mut Document) {
fn walk(n: &El, parent: Scope, tm: &Timing, styles: &HashMap<String, FmtSpec>, speakers: &HashMap<String, String>, fmt: Fmt, doc: &mut Document) {
for c in n.elements() {
let f = style_of(c, styles, fmt);
match local(&c.name) {
@@ -793,17 +910,8 @@ fn walk(n: &El, parent: Scope, tm: &Timing, styles: &HashMap<String, Fmt>, speak
let whole = if has_timing(c) || spans.is_empty() { e } else { None };
match whole {
Some(e) => {
let mut o = Out { s: String::new(), pending: false };
let tags: Vec<&str> = [(f.italic, "i"), (f.bold, "b"), (f.underline, "u")].into_iter().filter(|x| x.0).map(|x| x.1).collect();
for t in &tags {
o.s.push_str(&format!("<{t}>"));
}
collect(c, styles, f, &mut o);
for t in tags.iter().rev() {
o.s.push_str(&format!("</{t}>"));
}
let text = clean(&o.s);
if e > b && !text.trim_matches(is_xml_space).is_empty() {
let text = cue_text(c, styles, f);
if e > b && !text.is_empty() {
doc.cues.push(Cue { start: b, end: e, text, speaker: cue_speaker, id, ..Default::default() });
}
}
@@ -814,10 +922,8 @@ fn walk(n: &El, parent: Scope, tm: &Timing, styles: &HashMap<String, Fmt>, speak
let st = times(sp, scope, *tm, doc);
let sb = st.begin.unwrap_or(scope.begin);
if let (false, Some(se)) = (st.bad, st.end) {
let mut o = Out { s: String::new(), pending: false };
collect(sp, styles, style_of(sp, styles, f), &mut o);
let text = clean(&o.s);
if se > sb && !text.trim_matches(is_xml_space).is_empty() {
let text = cue_text(sp, styles, style_of(sp, styles, f));
if se > sb && !text.is_empty() {
doc.cues.push(Cue {
start: sb,
end: se,
@@ -0,0 +1,56 @@
//! Literal text stays distinct from style markup across repeated caption exports.
use craft_captions::plain_text;
use craft_captions::{Cue, Document, srt, stl};
use craft_captions::{TICKS_PER_SECOND, Tick};
fn document(text: &str) -> Document {
Document {
cues: vec![Cue { start: Tick(TICKS_PER_SECOND), end: Tick(2 * TICKS_PER_SECOND), text: text.into(), ..Default::default() }],
..Default::default()
}
}
#[test]
fn srt_roundtrips_literal_brackets_and_style_tags() {
for text in ["1 &lt; 2 & <i>x</i>", "x &lt;y> <u>z</u>", "<i>Type &lt; and &amp;lt;</i>"] {
let expected = plain_text(text);
let mut doc = document(text);
for _ in 0..2 {
let encoded = srt::write(&doc);
doc = srt::parse(&encoded).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(plain_text(&doc.cues[0].text), expected, "{text:?}: {encoded}");
assert_eq!(doc.cues[0].text, text, "literal entities and genuine tags stay distinct");
}
}
}
#[test]
fn stl_roundtrips_literal_brackets_and_style_controls() {
// The italic control and 110 letters put the literal '<' at the last text-field byte.
let boundary = format!("<i>{}&lt;i></i> &amp;lt;", "x".repeat(110));
for (text, stored) in [
("1 &lt; 2 & <i>x</i>", "1 &lt; 2 &amp; <i>x</i>"),
("x &lt;y> <u>z</u>", "x &lt;y> <u>z</u>"),
("<i>Type &lt; and &amp;lt;</i>", "<i>Type &lt; and &amp;lt;</i>"),
("<u>literal &lt;i> text</u> <i>styled</i>", "<u>literal &lt;i> text</u> <i>styled</i>"),
(boundary.as_str(), boundary.as_str()),
] {
let expected = plain_text(text);
let mut doc = document(text);
for _ in 0..2 {
let encoded = stl::write(&doc, None, "test");
if text == boundary {
assert!(encoded.len() > 1024 + 128, "the fixture crosses a text-field boundary");
}
doc = stl::parse(&encoded).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(plain_text(&doc.cues[0].text), expected, "{text:?}: {:?}", doc.cues[0].text);
assert_eq!(doc.cues[0].text, stored, "literal text and styled ranges remain distinct");
assert_eq!(stl::write(&doc, None, "test"), encoded, "the next export preserves text and style-control bytes");
for tag in ["<i>", "</i>", "<u>", "</u>"] {
assert_eq!(doc.cues[0].text.contains(tag), text.contains(tag), "style control {tag}");
}
}
}
}
+5
View File
@@ -1,11 +1,16 @@
mod limits;
mod literal_roundtrips;
mod mcc_708_timing;
mod roundtrip;
mod ttml_body_styles;
mod ttml_body_timing;
mod ttml_clock_overflow;
mod ttml_control_chars;
mod ttml_cue_ids;
mod ttml_language;
mod ttml_literal_angle;
mod ttml_literal_entities;
mod ttml_speakers;
mod ttml_style_namespaces;
mod ttml_timed_span_styles;
mod ttml_xml_views;
+7 -1
View File
@@ -175,7 +175,13 @@ proptest! {
let opts = WriteOptions { rate: Some(rate), ..Default::default() };
let bytes = write(&doc, Format::Stl, opts);
let back = parse(&bytes, Format::Stl).unwrap();
prop_assert_eq!(&back.cues, &doc.cues);
// The generator makes literal text without markup; STL import escapes its ampersands.
// Exact cue equality otherwise, so an unexpected style tag cannot disappear.
let mut expected = doc.cues.clone();
for cue in &mut expected {
cue.text = cue.text.replace('&', "&amp;");
}
prop_assert_eq!(&back.cues, &expected);
prop_assert_eq!(write(&back, Format::Stl, opts), bytes);
}
@@ -0,0 +1,227 @@
//! TTML body styles reach caption paragraphs; timed spans, whitespace, and nested cue content retain their styles.
use craft_captions::{Format, parse};
use craft_captions::{TICKS_PER_SECOND, Tick};
fn caption(xml: &str, format: Format) -> String {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!((doc.cues[0].start, doc.cues[0].end), (Tick(TICKS_PER_SECOND), Tick(TICKS_PER_SECOND * 2)));
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
doc.cues[0].text.clone()
}
#[test]
fn body_inline_styles_are_inherited_by_paragraphs() {
for format in [Format::Ttml, Format::Dfxp] {
for (attribute, expected) in [
(r#"tts:fontStyle="italic""#, "<i>Hello</i>"),
(r#"tts:fontWeight="bold""#, "<b>Hello</b>"),
(r#"tts:textDecoration="underline""#, "<u>Hello</u>"),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body {attribute}><div><p begin="1s" end="2s">Hello</p></div></body></tt>"#
);
assert_eq!(caption(&xml, format), expected, "{format:?}, {attribute}");
}
}
}
#[test]
fn body_referenced_styles_are_inherited_by_paragraphs() {
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling">
<head><styling><style xml:id="emphasis" tts:fontStyle="italic" tts:fontWeight="bold"/></styling></head>
<body style="emphasis"><div><p begin="1s" end="2s">Hello</p></div></body></tt>"#;
for format in [Format::Ttml, Format::Dfxp] {
assert_eq!(caption(xml, format), "<i><b>Hello</b></i>");
}
}
#[test]
fn child_spans_can_reset_inherited_and_paragraph_root_styles() {
for format in [Format::Ttml, Format::Dfxp] {
for (attribute, reset, tag) in [
(r#"tts:fontStyle="italic""#, r#"tts:fontStyle="normal""#, "i"),
(r#"tts:fontWeight="bold""#, r#"tts:fontWeight="normal""#, "b"),
(r#"tts:textDecoration="underline""#, r#"tts:textDecoration="noUnderline""#, "u"),
] {
for (body, paragraph) in [(attribute, ""), ("", attribute)] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body {body}><div><p {paragraph} begin="1s" end="2s">Before<span {reset}>plain</span>After</p></div></body></tt>"#
);
assert_eq!(
caption(&xml, format),
format!("<{tag}>Before</{tag}>plain<{tag}>After</{tag}>"),
"{format:?}, {body}, {paragraph}, {reset}"
);
}
}
}
}
#[test]
fn draft_inherit_keeps_parent_styles() {
for (parent, inherited, expected) in [
(r#"tts:fontStyle="italic""#, r#"tts:fontStyle="inherit""#, "<i>Hello</i>"),
(r#"tts:fontWeight="bold""#, r#"tts:fontWeight="inherit""#, "<b>Hello</b>"),
(r#"tts:textDecoration="underline""#, r#"tts:textDecoration="inherit""#, "<u>Hello</u>"),
] {
for cue in
[format!(r#"<p {inherited} begin="1s" end="2s">Hello</p>"#), format!(r#"<p><span {inherited} begin="1s" end="2s">Hello</span></p>"#)]
{
let xml = format!(
r#"<tt xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:tts="http://www.w3.org/2006/04/ttaf1#style"><body {parent}><div>{cue}</div></body></tt>"#
);
assert_eq!(caption(&xml, Format::Dfxp), expected, "{parent}, {cue}");
}
}
}
#[test]
fn referenced_draft_inherit_restores_the_parent_after_earlier_styles() {
for (property, on, off, tag) in [
("fontStyle", r#"tts:fontStyle="italic""#, r#"tts:fontStyle="normal""#, "i"),
("fontWeight", r#"tts:fontWeight="bold""#, r#"tts:fontWeight="normal""#, "b"),
("textDecoration", r#"tts:textDecoration="underline""#, r#"tts:textDecoration="noUnderline""#, "u"),
] {
let inherit = format!(r#"tts:{property}="inherit""#);
for (parent, first, expected) in [(on, off, format!("<{tag}>Hello</{tag}>")), ("", on, "Hello".to_string())] {
for cue in [
r#"<p style="first restore" begin="1s" end="2s">Hello</p>"#.to_string(),
format!(r#"<p style="first" {inherit} begin="1s" end="2s">Hello</p>"#),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:tts="http://www.w3.org/2006/04/ttaf1#style"><head><styling><style xml:id="first" {first}/><style xml:id="restore" {inherit}/></styling></head><body {parent}><div>{cue}</div></body></tt>"#
);
assert_eq!(caption(&xml, Format::Dfxp), expected, "{parent}, {first}, {cue}");
}
}
}
}
#[test]
fn unknown_font_values_do_not_reset_inherited_styles() {
for (parent, unsupported, expected) in [
(r#"tts:fontStyle="italic""#, r#"tts:fontStyle="garbage""#, "<i>Hello</i>"),
(r#"tts:fontWeight="bold""#, r#"tts:fontWeight="bolder""#, "<b>Hello</b>"),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body {parent}><div><p {unsupported} begin="1s" end="2s">Hello</p></div></body></tt>"#
);
assert_eq!(caption(&xml, Format::Ttml), expected);
}
}
#[test]
fn referenced_normal_styles_override_inherited_values() {
for format in [Format::Ttml, Format::Dfxp] {
for (attribute, reset) in [
(r#"tts:fontStyle="italic""#, r#"tts:fontStyle="normal""#),
(r#"tts:fontWeight="bold""#, r#"tts:fontWeight="normal""#),
(r#"tts:textDecoration="underline""#, r#"tts:textDecoration="noUnderline""#),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><head><styling><style xml:id="plain" {reset}/></styling></head><body {attribute}><div><p style="plain" begin="1s" end="2s">Hello</p></div></body></tt>"#
);
assert_eq!(caption(&xml, format), "Hello", "{format:?}, {attribute}, {reset}");
}
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><head><styling><style xml:id="plain" tts:fontStyle="normal"/></styling></head><body tts:fontStyle="italic" tts:fontWeight="bold"><div><p style="plain" begin="1s" end="2s">Hello</p></div></body></tt>"#;
assert_eq!(caption(xml, format), "<b>Hello</b>", "{format:?}, unspecified bold remains inherited");
}
}
#[test]
fn pending_spaces_keep_their_source_text_style() {
for format in [Format::Ttml, Format::Dfxp] {
for (body_style, text, expected) in [
("", r#"Before<span tts:textDecoration="underline"> world</span>After"#, "Before<u> world</u>After"),
(
r#"tts:textDecoration="underline""#,
r#"Before<span tts:textDecoration="noUnderline"> world</span>After"#,
"<u>Before</u> world<u>After</u>",
),
(
r#"tts:textDecoration="underline""#,
r#"Before <span tts:textDecoration="noUnderline">world</span>After"#,
"<u>Before </u>world<u>After</u>",
),
("", r#"Before<span tts:textDecoration="underline">world </span>After"#, "Before<u>world </u>After"),
("", r#"Before<span tts:textDecoration="underline"> </span><span tts:textDecoration="noUnderline"> </span>After"#, "Before<u> </u>After"),
("", r#"Before<span tts:textDecoration="noUnderline"> </span><span tts:textDecoration="underline"> </span>After"#, "Before After"),
("", r#"<span tts:textDecoration="underline"> </span><span tts:textDecoration="noUnderline"> </span>After"#, "After"),
(r#"tts:textDecoration="underline""#, r#"Before<span style="plain"> world</span>After"#, "<u>Before</u> world<u>After</u>"),
] {
for cue in [format!(r#"<p begin="1s" end="2s">{text}</p>"#), format!(r#"<p><span begin="1s" end="2s">{text}</span></p>"#)] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><head><styling><style xml:id="plain" tts:textDecoration="noUnderline"/></styling></head><body {body_style}><div>{cue}</div></body></tt>"#
);
assert_eq!(caption(&xml, format), expected, "{format:?}, {body_style}, {cue}");
}
}
}
}
#[test]
fn root_styles_keep_fractional_timing_and_speaker_metadata() {
for format in [Format::Ttml, Format::Dfxp] {
for cue in
[r#"<p begin="1.5t" end="2.5t" ttm:agent="voice">Hello</p>"#, r#"<p><span begin="1.5t" end="2.5t" ttm:agent="voice">Hello</span></p>"#]
{
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" xmlns:ttm="http://www.w3.org/ns/ttml#metadata" ttp:tickRate="10"><head><metadata><ttm:agent xml:id="voice" type="person"><ttm:name type="full">Voice_One 日本</ttm:name></ttm:agent></metadata></head><body begin="5s" tts:fontStyle="italic"><div begin="2s">{cue}</div></body></tt>"#
);
let doc = parse(xml.as_bytes(), format).unwrap();
assert!(doc.warnings.is_empty(), "{format:?}, {cue}: {:?}", doc.warnings);
assert_eq!(doc.cues.len(), 1, "{format:?}, {cue}");
let parsed = &doc.cues[0];
assert_eq!(
(parsed.start, parsed.end),
(Tick(TICKS_PER_SECOND * 7 + TICKS_PER_SECOND * 3 / 20), Tick(TICKS_PER_SECOND * 7 + TICKS_PER_SECOND / 4))
);
assert_eq!(parsed.text, "<i>Hello</i>", "{format:?}, {cue}");
assert_eq!(parsed.speaker.as_deref(), Some("Voice_One 日本"), "{format:?}, {cue}");
}
}
}
#[test]
fn descendant_overrides_and_unstyled_body_keep_their_behavior() {
for xml in [
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body tts:fontStyle="italic"><div><p tts:fontStyle="normal" begin="1s" end="2s">Hello</p></div></body></tt>"#,
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="2s">Hello</p></div></body></tt>"#,
] {
assert_eq!(caption(xml, Format::Ttml), "Hello");
}
}
#[test]
fn non_xml_spaces_remain_visible_inside_styled_cues() {
for space in ['\u{a0}', '\u{202f}', '\u{3000}'] {
let visible = format!("{space}A{space}B{space}");
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body tts:fontStyle="italic"><div><p begin="1s" end="2s">{visible}</p></div></body></tt>"#
);
assert_eq!(caption(&xml, Format::Ttml), format!("<i>{visible}</i>"));
}
}
#[test]
fn deeply_nested_inline_styles_respect_the_xml_depth_limit() {
for format in [Format::Ttml, Format::Dfxp] {
for depth in [123, 124, 1_000] {
let mut xml = String::from(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div><p begin="1s" end="2s">"#,
);
xml.push_str(&"<span>".repeat(depth));
xml.push_str("<span tts:fontStyle=\"italic\">Deep</span>");
xml.push_str(&"</span>".repeat(depth));
xml.push_str("</p></div></body></tt>");
// Four containers plus the styled span leave room for 123 enclosing spans.
if depth == 123 {
assert_eq!(caption(&xml, format), "<i>Deep</i>");
} else {
assert!(parse(xml.as_bytes(), format).is_err(), "{format:?}, depth {depth}");
}
}
}
}
@@ -0,0 +1,61 @@
//! TTML text that looks like markup stays text, beside the cue's own style tags.
use craft_captions::ttml::{Flavor, write};
use craft_captions::{Format, TICKS_PER_SECOND, Tick, parse};
fn caption(xml: &str, format: Format) -> String {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!((doc.cues[0].start, doc.cues[0].end), (Tick(TICKS_PER_SECOND), Tick(TICKS_PER_SECOND * 2)));
doc.cues[0].text.clone()
}
/// A literal `<` in cue text is kept as `&lt;`, the way a VTT import keeps its entities, so the
/// style tags around it stay tags and plain text (burn-in, export) decodes the bracket instead of
/// cutting at it; a `>` starts no tag and stays literal.
#[test]
fn literal_brackets_keep_their_words_and_their_style() {
for (xml, stored, plain) in [
(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body tts:fontStyle="italic"><div><p begin="1s" end="2s">1 &lt; 2</p></div></body></tt>"#,
"<i>1 &lt; 2</i>",
"1 < 2",
),
(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div><p begin="1s" end="2s">a <span tts:fontWeight="bold">b &gt; c</span> d</p></div></body></tt>"#,
"a <b>b > c</b> d",
"a b > c d",
),
(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div><p begin="1s" end="2s">Use &lt;<span tts:fontWeight="bold">input</span>&gt;</p></div></body></tt>"#,
"Use &lt;<b>input</b>>",
"Use <input>",
),
(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body tts:fontStyle="italic"><div><p begin="1s" end="2s">2 &lt; 3 &gt; 1</p></div></body></tt>"#,
"<i>2 &lt; 3 > 1</i>",
"2 < 3 > 1",
),
(
// a literal "&lt;" in the source text (an escaped ampersand) stays distinct from `<`
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div><p begin="1s" end="2s">Type &lt; and &amp;lt;</p></div></body></tt>"#,
"Type &lt; and &amp;lt;",
"Type < and &lt;",
),
] {
let text = caption(xml, Format::Ttml);
assert_eq!(text, stored);
assert_eq!(craft_captions::plain_text(&text), plain);
// written back out, the XML text is the source text escaped once (not the stored entity
// escaped again), and imported again, in both flavours, the cue reads the same
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
for flavor in [Flavor::Imsc1, Flavor::Dfxp] {
let again = write(&doc, flavor, None, "en");
assert!(!again.contains("&amp;lt;") || stored.contains("&amp;lt;"), "{flavor:?}: {again}");
if stored == "<i>1 &lt; 2</i>" {
assert!(again.contains(">1 &lt; 2<"), "{flavor:?}: {again}");
}
assert_eq!(caption(&again, Format::Ttml), stored, "{flavor:?}: {again}");
}
}
}
@@ -0,0 +1,149 @@
//! Styling identity must not depend on XML attribute order or foreign extensions.
use craft_captions::{Format, parse};
use craft_captions::{TICKS_PER_SECOND, Tick};
#[test]
fn recognized_styling_namespaces_have_fixed_priority() {
let namespaces = [
"http://www.w3.org/ns/ttml#styling",
"http://www.w3.org/2006/10/ttaf1#style",
"http://www.w3.org/2006/10/ttaf1#styling",
"http://www.w3.org/2006/04/ttaf1#style",
"http://www.w3.org/2006/04/ttaf1#styling",
];
for (property, positive, reset, tag) in
[("fontStyle", "italic", "normal", "i"), ("fontWeight", "bold", "normal", "b"), ("textDecoration", "underline", "noUnderline", "u")]
{
for (i, high) in namespaces.iter().enumerate() {
for low in namespaces.iter().skip(i + 1) {
for attrs in [
format!(r#"lo:{property}="{reset}" hi:{property}="{positive}""#),
format!(r#"hi:{property}="{positive}" lo:{property}="{reset}""#),
] {
check(
"",
&format!(r#"<div><p xmlns:hi="{high}" xmlns:lo="{low}" {attrs} begin="1s" end="2s">Hello</p></div>"#),
&format!("<{tag}>Hello</{tag}>"),
);
}
}
}
}
}
fn check(head: &str, body: &str, expected: &str) {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:app="urn:example">{head}<body>{body}</body></tt>"#
);
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
assert!(doc.warnings.is_empty(), "{xml}");
assert_eq!(doc.cues.len(), 1, "{xml}");
assert_eq!((doc.cues[0].start, doc.cues[0].end), (Tick(TICKS_PER_SECOND), Tick(2 * TICKS_PER_SECOND)));
assert_eq!(doc.cues[0].text, expected, "{format:?}, {xml}");
}
}
#[test]
fn genuine_styling_wins_over_foreign_and_unqualified_properties() {
for (property, positive, reset, tag) in
[("fontStyle", "italic", "normal", "i"), ("fontWeight", "bold", "normal", "b"), ("textDecoration", "underline", "noUnderline", "u")]
{
for other in ["app:", ""] {
for reversed in [false, true] {
let actual = format!(r#"tts:{property}="{positive}""#);
let collision = format!(r#"{other}{property}="{reset}""#);
let attrs = if reversed { format!("{actual} {collision}") } else { format!("{collision} {actual}") };
for body in [
format!(r#"<div {attrs}><p begin="1s" end="2s">Hello</p></div>"#),
format!(r#"<div><p {attrs} begin="1s" end="2s">Hello</p></div>"#),
format!(r#"<div><p><span {attrs} begin="1s" end="2s">Hello</span></p></div>"#),
] {
check("", &body, &format!("<{tag}>Hello</{tag}>"));
}
let head = format!(r#"<head><styling><style xml:id="emphasis" {attrs}/></styling></head>"#);
check(&head, r#"<div style="emphasis"><p begin="1s" end="2s">Hello</p></div>"#, &format!("<{tag}>Hello</{tag}>"));
let actual_reset = format!(r#"tts:{property}="{reset}""#);
let foreign_positive = format!(r#"app:{property}="{positive}""#);
let reset_attrs = if reversed { format!("{actual_reset} {foreign_positive}") } else { format!("{foreign_positive} {actual_reset}") };
check("", &format!(r#"<div tts:{property}="{positive}"><p {reset_attrs} begin="1s" end="2s">Hello</p></div>"#), "Hello");
}
}
check("", &format!(r#"<div><p app:{property}="{positive}" begin="1s" end="2s">Hello</p></div>"#), "Hello");
check(
"",
&format!(r#"<div tts:{property}="{positive}"><p app:{property}="{reset}" begin="1s" end="2s">Hello</p></div>"#),
&format!("<{tag}>Hello</{tag}>"),
);
// Body styling resolves before the cue walk, so namespace priority must hold on that path too.
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:app="urn:example"><body app:{property}="{reset}" tts:{property}="{positive}"><div><p begin="1s" end="2s">Hello</p></div></body></tt>"#
);
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].text, format!("<{tag}>Hello</{tag}>"));
}
}
}
#[test]
fn foreign_style_references_and_definitions_are_ignored() {
let real = r#"<style xml:id="emphasis" tts:fontStyle="italic"/>"#;
let foreign = r#"<app:style xml:id="emphasis" tts:fontStyle="normal"/>"#;
for definitions in [format!("{real}{foreign}"), format!("{foreign}{real}")] {
let head = format!("<head><styling>{definitions}</styling></head>");
for attrs in [r#"app:style="missing" style="emphasis""#, r#"style="emphasis" app:style="missing""#] {
check(&head, &format!(r#"<div><p {attrs} begin="1s" end="2s">Hello</p></div>"#), "<i>Hello</i>");
}
check(&head, r#"<div><p app:style="emphasis" begin="1s" end="2s">Hello</p></div>"#, "Hello");
}
}
#[test]
fn genuine_style_ids_win_over_foreign_and_legacy_ids() {
for attrs in [
r#"app:id="wrong" xml:id="emphasis""#,
r#"xml:id="emphasis" app:id="wrong""#,
r#"id="wrong" xml:id="emphasis""#,
r#"xml:id="emphasis" id="wrong""#,
r#"id="emphasis""#,
] {
check(
&format!(r#"<head><styling><style {attrs} tts:fontStyle="italic"/></styling></head>"#),
r#"<div><p style="emphasis" begin="1s" end="2s">Hello</p></div>"#,
"<i>Hello</i>",
);
}
check(
r#"<head><styling><style app:id="emphasis" tts:fontStyle="italic"/></styling></head>"#,
r#"<div><p style="emphasis" begin="1s" end="2s">Hello</p></div>"#,
"Hello",
);
}
#[test]
fn modern_draft_and_unqualified_styling_remain_supported() {
for (tt, tts, id) in [
("http://www.w3.org/ns/ttml", "http://www.w3.org/ns/ttml#styling", "xml:id"),
("http://www.w3.org/2006/04/ttaf1", "http://www.w3.org/2006/04/ttaf1#style", "id"),
("http://www.w3.org/2006/10/ttaf1", "http://www.w3.org/2006/10/ttaf1#style", "xml:id"),
("http://www.w3.org/2006/04/ttaf1", "http://www.w3.org/2006/04/ttaf1#styling", "id"),
("http://www.w3.org/2006/10/ttaf1", "http://www.w3.org/2006/10/ttaf1#styling", "xml:id"),
("", "", "id"),
] {
let ns = if tt.is_empty() { String::new() } else { format!(r#"xmlns="{tt}" xmlns:tts="{tts}""#) };
let prefix = if tts.is_empty() { "" } else { "tts:" };
let xml = format!(
r#"<tt {ns}><head><styling><style {id}="emphasis" {prefix}fontStyle="italic"/></styling></head><body style="emphasis"><div><p {prefix}fontWeight="bold" begin="1s" end="2s">Hello</p></div></body></tt>"#
);
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert!(doc.warnings.is_empty());
assert_eq!((doc.cues[0].start, doc.cues[0].end), (Tick(TICKS_PER_SECOND), Tick(2 * TICKS_PER_SECOND)));
assert_eq!(doc.cues[0].text, "<i><b>Hello</b></i>", "{format:?}, {xml}");
}
}
}
@@ -0,0 +1,125 @@
//! Timed spans emitted as cues must keep their own resolved text styles.
use craft_captions::{Format, WriteOptions, parse, write};
use craft_captions::{TICKS_PER_SECOND, Tick};
fn caption(xml: &str, format: Format) -> String {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!((doc.cues[0].start, doc.cues[0].end), (Tick(TICKS_PER_SECOND), Tick(TICKS_PER_SECOND * 2)));
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
doc.cues[0].text.clone()
}
#[test]
fn timed_span_root_styles_match_timed_paragraph_styles() {
for format in [Format::Ttml, Format::Dfxp] {
for (attribute, expected) in [
(r#"tts:fontStyle="italic""#, "<i>Hello</i>"),
(r#"tts:fontWeight="bold""#, "<b>Hello</b>"),
(r#"tts:textDecoration="underline""#, "<u>Hello</u>"),
] {
let head = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div>"#;
let paragraph = format!(r#"{head}<p {attribute} begin="1s" end="2s">Hello</p></div></body></tt>"#);
let span = format!(r#"{head}<p><span {attribute} begin="1s" end="2s">Hello</span></p></div></body></tt>"#);
assert_eq!(caption(&span, format), expected, "{format:?}, timed span, {attribute}");
assert_eq!(caption(&paragraph, format), expected, "{format:?}, paragraph, {attribute}");
}
}
}
#[test]
fn timed_span_referenced_and_nested_styles_are_kept() {
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling">
<head><styling><style xml:id="emphasis" tts:fontStyle="italic"/></styling></head>
<body><div><p><span style="emphasis" begin="1s" end="2s">Hello <span tts:fontWeight="bold">world</span></span></p></div></body></tt>"#;
for format in [Format::Ttml, Format::Dfxp] {
assert_eq!(caption(xml, format), "<i>Hello <b>world</b></i>");
}
}
#[test]
fn body_and_paragraph_styles_are_kept_on_timed_spans() {
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body tts:fontStyle="italic"><div><p tts:fontWeight="bold"><span tts:textDecoration="underline" begin="1s" end="2s">Hello</span></p></div></body></tt>"#;
for format in [Format::Ttml, Format::Dfxp] {
assert_eq!(caption(xml, format), "<i><b><u>Hello</u></b></i>");
}
}
#[test]
fn empty_styled_timed_spans_remain_empty() {
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div><p><span tts:fontStyle="italic" begin="1s" end="2s"> <br/> </span></p></div></body></tt>"#;
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
assert!(doc.cues.is_empty());
assert!(doc.warnings.is_empty());
}
}
#[test]
fn timed_span_children_can_reset_root_styles() {
for format in [Format::Ttml, Format::Dfxp] {
for (attribute, reset, tag) in [
(r#"tts:fontStyle="italic""#, r#"tts:fontStyle="normal""#, "i"),
(r#"tts:fontWeight="bold""#, r#"tts:fontWeight="normal""#, "b"),
(r#"tts:textDecoration="underline""#, r#"tts:textDecoration="noUnderline""#, "u"),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div><p><span {attribute} begin="1s" end="2s">Before<span {reset}>plain</span>After</span></p></div></body></tt>"#
);
assert_eq!(caption(&xml, format), format!("<{tag}>Before</{tag}>plain<{tag}>After</{tag}>"), "{format:?}, {attribute}, {reset}");
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div><p><span {attribute} begin="1s" end="2s"><span {reset}>Hello</span></span></p></div></body></tt>"#
);
assert_eq!(caption(&xml, format), "Hello", "{format:?}, {attribute}, {reset}");
}
}
}
#[test]
/// Line breaks are content: one at the start or end of a cue is kept (in its own style), as the
/// writer writes them, and only XML whitespace next to a break goes.
fn styled_roots_keep_edge_and_interior_lines() {
for format in [Format::Ttml, Format::Dfxp] {
for (text, expected) in [
("<br/>Hello<br/>", "<i>\nHello\n</i>"),
("Hello<br/><br/>world<br/>", "<i>Hello\n\nworld\n</i>"),
("Hello <br/>world", "<i>Hello\nworld</i>"),
(
r#"<span tts:fontStyle="normal"><span tts:fontStyle="italic">Hello</span><br/><span tts:fontWeight="bold">world</span></span>"#,
"<i>Hello</i>\n<b>world</b>",
),
("<br/> <br/>Hello<br/> <br/>", "<i>\n\nHello\n\n</i>"),
(r#"Hello<span tts:fontWeight="bold">world<br/></span>"#, "<i>Hello<b>world\n</b></i>"),
] {
for cue in [
format!(r#"<p tts:fontStyle="italic" begin="1s" end="2s">{text}</p>"#),
format!(r#"<p><span tts:fontStyle="italic" begin="1s" end="2s">{text}</span></p>"#),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div>{cue}</div></body></tt>"#
);
assert_eq!(caption(&xml, format), expected, "{format:?}, {cue}");
}
}
}
}
#[test]
fn styled_breaks_roundtrip_through_ttml_export() {
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling"><body><div><p begin="1s" end="2s"><span tts:fontStyle="italic">Hello</span><br/><span tts:fontWeight="bold">world</span></p></div></body></tt>"#;
for format in [Format::Ttml, Format::Dfxp] {
let imported = parse(xml.as_bytes(), format).unwrap();
assert_eq!(imported.cues[0].text, "<i>Hello</i>\n<b>world</b>");
let exported = write(&imported, format, WriteOptions::default());
let reparsed = parse(&exported, format).unwrap();
assert_eq!(reparsed.cues.len(), 1);
assert_eq!(reparsed.cues[0].text, imported.cues[0].text, "{format:?}");
}
}
#[test]
fn unstyled_timed_span_remains_plain() {
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p><span begin="1s" end="2s">Hello</span></p></div></body></tt>"#;
assert_eq!(caption(xml, Format::Ttml), "Hello");
}
+6 -1
View File
@@ -4,13 +4,18 @@
use craft_captions::{Cue, Document, Format, FrameRate, WriteOptions, write};
const NAMESPACES: [(&str, &str); 6] = [
const NAMESPACES: [(&str, &str); 11] = [
("tt", "http://www.w3.org/ns/ttml"),
("tt10", "http://www.w3.org/2006/10/ttaf1"),
("tt04", "http://www.w3.org/2006/04/ttaf1"),
("ttm", "http://www.w3.org/ns/ttml#metadata"),
("ttm10", "http://www.w3.org/2006/10/ttaf1#metadata"),
("ttm04", "http://www.w3.org/2006/04/ttaf1#metadata"),
("tts", "http://www.w3.org/ns/ttml#styling"),
("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"),
];
/// A name the way the reader's canonical names spell it.