mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 07:46:08 +00:00
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 --> 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 < (and an & that would read as an entity as &), as a WebVTT import already keeps its entities, so text like "<b>" 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 < 2" is no longer written as "1 &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:
co-authored by
NetterSilver
SeeRay11
jnohclee-rgb
JonRC
sanyamsingh001
nuclearspike
parent
6f51ec7f8d
commit
fdbb9c2471
@@ -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 `<` (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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & <i>x</i>".into(), ..Default::default() });
|
||||
let encoded = write(&d);
|
||||
assert_eq!(encoded, "1\n00:00:01,000 --> 00:00:02,000\n1 < 2 & <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();
|
||||
|
||||
@@ -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<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 `<` is the character, not an entity spelled out; the tag beside it stays a tag
|
||||
assert_eq!(encode_text("1 < 2 & <i>x</i>"), encode_text("1 < 2 & <i>x</i>"));
|
||||
assert_eq!(encode_text("x <y> z"), b"x <y> z".to_vec(), "a decoded bracket is text, not a tag");
|
||||
assert_eq!(encode_text("a <b"), encode_text("a <b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_markup_is_encoded_in_one_pass() {
|
||||
let text = format!("<i{}", "<".repeat(10_000));
|
||||
|
||||
+40
-14
@@ -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 `<` / `&` (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 & <i>x</i> < &"), "1 < 2 & <i>x</i> < &");
|
||||
}
|
||||
}
|
||||
|
||||
+190
-84
@@ -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 `<` (as a WebVTT import keeps its entities), and a literal `&` that
|
||||
// would read as an entity ("<" typed as text) as `&`; plain text and
|
||||
// the writers decode them. Any other `&`, and a `>`, stay as they are.
|
||||
match ch {
|
||||
'<' => out.s.push_str("<"),
|
||||
'&' if crate::entity_at(text.get(i..).unwrap_or("")).is_some() => out.s.push_str("&"),
|
||||
_ => 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 < 2 & <i>x</i>", "x <y> <u>z</u>", "<i>Type < and &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>{}<i></i> &lt;", "x".repeat(110));
|
||||
for (text, stored) in [
|
||||
("1 < 2 & <i>x</i>", "1 < 2 & <i>x</i>"),
|
||||
("x <y> <u>z</u>", "x <y> <u>z</u>"),
|
||||
("<i>Type < and &lt;</i>", "<i>Type < and &lt;</i>"),
|
||||
("<u>literal <i> text</u> <i>styled</i>", "<u>literal <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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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('&', "&");
|
||||
}
|
||||
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 `<`, 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 < 2</p></div></body></tt>"#,
|
||||
"<i>1 < 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 > 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 <<span tts:fontWeight="bold">input</span>></p></div></body></tt>"#,
|
||||
"Use <<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 < 3 > 1</p></div></body></tt>"#,
|
||||
"<i>2 < 3 > 1</i>",
|
||||
"2 < 3 > 1",
|
||||
),
|
||||
(
|
||||
// a literal "<" 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 < and &lt;</p></div></body></tt>"#,
|
||||
"Type < and &lt;",
|
||||
"Type < and <",
|
||||
),
|
||||
] {
|
||||
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("&lt;") || stored.contains("&lt;"), "{flavor:?}: {again}");
|
||||
if stored == "<i>1 < 2</i>" {
|
||||
assert!(again.contains(">1 < 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(¶graph, 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");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user