captions: preserve speaker names in TTML metadata
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
0541d4d182
commit
2343fc8fa3
|
|
@ -23,6 +23,27 @@ All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 00
|
|||
frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's
|
||||
frame grid (the engine does this on import) and removes overlaps.
|
||||
|
||||
### TTML speaker metadata
|
||||
|
||||
TTML and DFXP exports define each distinct speaker label once in the document head and refer to
|
||||
it with a stable, unique `ttm:agent` ID. Names are XML-escaped full-name text with preserved
|
||||
whitespace; punctuation, Unicode and literal underscores survive a round trip. Agent type is
|
||||
`other` because a caption's speaker label does not say whether it denotes a person or a group.
|
||||
|
||||
On import, agent definitions directly under `head` or `head/metadata` resolve paragraph and timed
|
||||
span speakers. Speaker metadata uses the standard TTML namespace or the April and October 2006
|
||||
TTaf1 draft namespaces. Agent IDs use `xml:id`, except in the April draft, which uses unqualified
|
||||
`id`. Agent definitions from unrelated application metadata are ignored. Recognized `ttm:agent`
|
||||
attributes take precedence over the legacy local-name `agent` fallback. A full name is preferred,
|
||||
otherwise the first nonempty name (or an explicitly empty name) is used. Multiple agent references become one display label, joined with `, ` in
|
||||
reference order; the project does not retain the individual agent identities. Older FilmCraft
|
||||
files without agent definitions remain readable through the previous underscore-to-space
|
||||
fallback. Other agent metadata, including actor relationships, is not retained.
|
||||
|
||||
This mapping follows [W3C TTML1, third edition (2018), §§12.1.5–12.2.1](https://www.w3.org/TR/2018/REC-ttml1-20181108/#metadata-vocabulary-agent).
|
||||
The [April 2006 TTaf1 draft, §§7.2.1 and 12.1.5–12.2.1](https://www.w3.org/TR/2006/WD-ttaf1-dfxp-20060427/)
|
||||
defines its earlier metadata namespace and agent ID form.
|
||||
|
||||
### SCC writer
|
||||
|
||||
Each caption is sent as a pop-on load (RCL, ENM, then per row a preamble address code and tab
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@
|
|||
//! offsets (`123f`) at `ttp:frameRate` / `ttp:frameRateMultiplier`, which are exact; without
|
||||
//! one, and always for DFXP (for older players), times are clock times with milliseconds.
|
||||
//!
|
||||
//! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read):
|
||||
//! **Reading** accepts TTML1 2010, the 2006 `ttaf1` drafts and TTML2:
|
||||
//! clock times (`HH:MM:SS`, `.fraction`, `:FF` frames and `.subframes`), offset times (`h`, `m`,
|
||||
//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested `par` time
|
||||
//! containers (`body` / `div` begins add up and their ends clip what is inside), timed `<span>`s inside an untimed `<p>`, `<br/>`, whitespace
|
||||
//! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
||||
use crate::{Cue, Document, Error, Result, TICKS_PER_MS};
|
||||
|
|
@ -31,6 +33,9 @@ const NS_TT: &str = "http://www.w3.org/ns/ttml";
|
|||
const NS_TTP: &str = "http://www.w3.org/ns/ttml#parameter";
|
||||
const NS_TTS: &str = "http://www.w3.org/ns/ttml#styling";
|
||||
const NS_TTM: &str = "http://www.w3.org/ns/ttml#metadata";
|
||||
const NS_TTM_DRAFT: &str = "http://www.w3.org/2006/10/ttaf1#metadata";
|
||||
const NS_TTM_DRAFT_APRIL: &str = "http://www.w3.org/2006/04/ttaf1#metadata";
|
||||
const NS_XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text";
|
||||
const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation";
|
||||
|
||||
|
|
@ -122,6 +127,10 @@ fn is_ncname(s: &str) -> bool {
|
|||
c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || matches!(x, '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
fn cue_id(cue: &Cue, index: usize) -> String {
|
||||
cue.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", index + 1))
|
||||
}
|
||||
|
||||
/// Write `doc` as TTML. `rate` (IMSC1 only) makes times exact frame offsets; `lang` is the
|
||||
/// document language (`xml:lang`).
|
||||
pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str) -> String {
|
||||
|
|
@ -140,13 +149,38 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
|
|||
}
|
||||
}
|
||||
o.push_str(&format!(" xml:lang=\"{}\">\n", esc(if lang.is_empty() { "en" } else { lang })));
|
||||
// Keep agent identifiers distinct from the IDs already emitted for cues, style and region.
|
||||
let mut ids: HashSet<String> = ["s1".into(), "r1".into()].into_iter().collect();
|
||||
ids.extend(doc.cues.iter().enumerate().map(|(i, c)| cue_id(c, i)));
|
||||
let mut speakers = Vec::new();
|
||||
let mut speaker_ids = HashMap::new();
|
||||
for speaker in doc.cues.iter().filter_map(|c| c.speaker.as_deref()) {
|
||||
if speaker_ids.contains_key(speaker) {
|
||||
continue;
|
||||
}
|
||||
let mut id = format!("agent{}", speakers.len() + 1);
|
||||
// Each collision consumes a distinct reserved ID, so this is bounded by ids.len().
|
||||
while ids.contains(&id) {
|
||||
id.push('_');
|
||||
}
|
||||
ids.insert(id.clone());
|
||||
speaker_ids.insert(speaker, id.clone());
|
||||
speakers.push((speaker, id));
|
||||
}
|
||||
o.push_str(" <head>\n");
|
||||
for (speaker, id) in speakers {
|
||||
o.push_str(&format!(
|
||||
" <ttm:agent xml:id=\"{id}\" type=\"other\"><ttm:name type=\"full\" xml:space=\"preserve\">{}</ttm:name></ttm:agent>\n",
|
||||
// XML 1.0 §2.11 normalizes literal CR even with xml:space="preserve".
|
||||
esc(speaker).replace('\r', " ")
|
||||
));
|
||||
}
|
||||
o.push_str(" <styling>\n <style xml:id=\"s1\" tts:color=\"white\" tts:fontFamily=\"proportionalSansSerif\" tts:fontSize=\"100%\" tts:textAlign=\"center\"/>\n </styling>\n");
|
||||
o.push_str(" <layout>\n <region xml:id=\"r1\" tts:origin=\"10% 10%\" tts:extent=\"80% 80%\" tts:displayAlign=\"after\"/>\n </layout>\n");
|
||||
o.push_str(" </head>\n <body region=\"r1\" style=\"s1\">\n <div>\n");
|
||||
for (i, c) in doc.cues.iter().enumerate() {
|
||||
let id = c.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", i + 1));
|
||||
let agent = c.speaker.as_deref().map(|s| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{}\"", esc(&agent_id(s)))).unwrap_or_default();
|
||||
let id = cue_id(c, i);
|
||||
let agent = c.speaker.as_deref().and_then(|s| speaker_ids.get(s)).map(|id| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{id}\"")).unwrap_or_default();
|
||||
o.push_str(&format!(
|
||||
" <p xml:id=\"{}\" begin=\"{}\" end=\"{}\"{agent}>{}</p>\n",
|
||||
esc(&id),
|
||||
|
|
@ -159,12 +193,6 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
|
|||
o
|
||||
}
|
||||
|
||||
/// `ttm:agent` refers to an agent id; we write the speaker name itself (made an NCName).
|
||||
fn agent_id(s: &str) -> String {
|
||||
let v: String = s.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect();
|
||||
if v.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { v } else { format!("_{v}") }
|
||||
}
|
||||
|
||||
fn gcd(a: i64, b: i64) -> i64 {
|
||||
if b == 0 { a.abs().max(1) } else { gcd(b, a % b) }
|
||||
}
|
||||
|
|
@ -203,6 +231,56 @@ fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
|
|||
n.attributes().find(|a| a.name() == local).map(|a| a.value())
|
||||
}
|
||||
|
||||
fn metadata_namespace(namespace: Option<&str>) -> bool {
|
||||
matches!(namespace, Some(NS_TTM) | Some(NS_TTM_DRAFT) | Some(NS_TTM_DRAFT_APRIL))
|
||||
}
|
||||
|
||||
/// Significant TTML agents are children of head or of head's metadata children (TTML1 §12.1.5).
|
||||
fn speaker_names(root: roxmltree::Node) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
let content_namespace = root.tag_name().namespace();
|
||||
let Some(head) = root.children().find(|n| n.is_element() && n.tag_name().name() == "head" && n.tag_name().namespace() == content_namespace) else {
|
||||
return out;
|
||||
};
|
||||
for agent in head.descendants().filter(|n| n.is_element() && n.tag_name().name() == "agent" && metadata_namespace(n.tag_name().namespace())) {
|
||||
if !agent
|
||||
.parent()
|
||||
.is_some_and(|p| p == head || (p.tag_name().name() == "metadata" && p.tag_name().namespace() == content_namespace && p.parent() == Some(head)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let id = if agent.tag_name().namespace() == Some(NS_TTM_DRAFT_APRIL) {
|
||||
// The April 2006 draft defines an unqualified id, before TTML adopted xml:id.
|
||||
agent.attributes().find(|a| a.name() == "id" && a.namespace().is_none()).map(|a| a.value())
|
||||
} else {
|
||||
agent.attribute((NS_XML, "id"))
|
||||
};
|
||||
let Some(id) = id else { continue };
|
||||
let names: Vec<_> = agent
|
||||
.children()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "name" && metadata_namespace(n.tag_name().namespace()))
|
||||
.map(|n| {
|
||||
let kind = n.attributes().find(|a| a.name() == "type" && a.namespace().is_none()).map(|a| a.value());
|
||||
(kind, n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>())
|
||||
})
|
||||
.collect();
|
||||
let name = names.iter().find(|(kind, _)| *kind == Some("full")).or_else(|| names.iter().find(|(_, text)| !text.is_empty())).or_else(|| names.first());
|
||||
if let Some((_, name)) = name {
|
||||
// An explicitly empty full name remains empty, including our own Some("") speakers.
|
||||
out.insert(id.to_string(), name.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn speaker(n: roxmltree::Node, names: &HashMap<String, String>) -> Option<String> {
|
||||
let refs = n.attributes().find(|a| a.name() == "agent" && metadata_namespace(a.namespace())).map(|a| a.value()).or_else(|| n.attribute("agent"));
|
||||
refs.map(|refs| {
|
||||
// The project stores one speaker label; multiple agents become an ordered display label.
|
||||
refs.split_whitespace().map(|id| names.get(id).cloned().unwrap_or_else(|| id.trim_start_matches('_').replace('_', " "))).collect::<Vec<_>>().join(", ")
|
||||
})
|
||||
}
|
||||
|
||||
fn timing(root: roxmltree::Node) -> Result<Timing> {
|
||||
let invalid =
|
||||
|name: &str| Error::Syntax { line: root.document().text_pos_at(root.range().start).row as usize, msg: format!("invalid TTML timing parameter {name}") };
|
||||
|
|
@ -411,6 +489,7 @@ pub fn parse(text: &str) -> Result<Document> {
|
|||
return Err(Error::NotFormat("TTML"));
|
||||
}
|
||||
let tm = timing(root)?;
|
||||
let speakers = speaker_names(root);
|
||||
let mut styles = std::collections::HashMap::new();
|
||||
for s in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
|
||||
if let Some(id) = s.attributes().find(|a| a.name() == "id").map(|a| a.value()) {
|
||||
|
|
@ -428,7 +507,7 @@ 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, Fmt::default(), &mut doc);
|
||||
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, &speakers, Fmt::default(), &mut doc);
|
||||
doc.cues.sort_by_key(|c| (c.start, c.end));
|
||||
Ok(doc)
|
||||
}
|
||||
|
|
@ -480,13 +559,13 @@ fn times(n: roxmltree::Node, parent: Scope, tm: Timing, doc: &mut Document) -> T
|
|||
Times { begin, end, bad }
|
||||
}
|
||||
|
||||
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, fmt: Fmt, doc: &mut Document) {
|
||||
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String, Fmt>, speakers: &HashMap<String, String>, fmt: Fmt, doc: &mut Document) {
|
||||
for c in n.children().filter(|c| c.is_element()) {
|
||||
let f = style_of(c, styles, fmt);
|
||||
match c.tag_name().name() {
|
||||
"div" => {
|
||||
let t = times(c, parent, *tm, doc);
|
||||
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, f, doc);
|
||||
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, speakers, f, doc);
|
||||
}
|
||||
"p" => {
|
||||
let t = times(c, parent, *tm, doc);
|
||||
|
|
@ -494,7 +573,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
|
|||
continue;
|
||||
}
|
||||
let (b, e) = (t.begin.unwrap_or(parent.begin), t.end);
|
||||
let speaker = attr(c, "agent").map(|a| a.trim().trim_start_matches('_').replace('_', " "));
|
||||
let cue_speaker = speaker(c, speakers);
|
||||
let id = c.attributes().find(|a| a.name() == "id").map(|a| a.value().to_string());
|
||||
let spans: Vec<roxmltree::Node> = c.children().filter(|x| x.is_element() && x.tag_name().name() == "span" && has_timing(*x)).collect();
|
||||
// a timed paragraph, or one without timed spans in a container with an end, is one cue
|
||||
|
|
@ -512,7 +591,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
|
|||
}
|
||||
let text = clean(&o.s);
|
||||
if e > b && !text.is_empty() {
|
||||
doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() });
|
||||
doc.cues.push(Cue { start: b, end: e, text, speaker: cue_speaker, id, ..Default::default() });
|
||||
}
|
||||
}
|
||||
None => {
|
||||
|
|
@ -526,7 +605,13 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
|
|||
collect(sp, styles, style_of(sp, styles, f), &mut o);
|
||||
let text = clean(&o.s);
|
||||
if se > sb && !text.is_empty() {
|
||||
doc.cues.push(Cue { start: sb, end: se, text, speaker: speaker.clone(), ..Default::default() });
|
||||
doc.cues.push(Cue {
|
||||
start: sb,
|
||||
end: se,
|
||||
text,
|
||||
speaker: speaker(sp, speakers).or_else(|| cue_speaker.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use filmcraft_captions::{Cue, Document, Format, WriteOptions, parse, write};
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
const TT: &str = "http://www.w3.org/ns/ttml";
|
||||
const META: &str = "http://www.w3.org/ns/ttml#metadata";
|
||||
const XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
|
||||
#[test]
|
||||
fn imports_speaker_names_from_significant_ttml_metadata() {
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
for metadata in [
|
||||
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="alias">Ann</ttm:name><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#
|
||||
.to_string(),
|
||||
r#"<metadata><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></metadata>"#.to_string(),
|
||||
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann<!-- editorial note --> Lee</ttm:name></ttm:agent>"#.to_string(),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>{metadata}</head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
|
||||
assert_eq!(doc.cues[0].text, "Hello");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_speakers_have_unique_resolvable_ids_and_exact_names() {
|
||||
let rate = FrameRate::FPS_25;
|
||||
let speakers =
|
||||
["Ann Lee", "Ann_Lee", "O’Connor & <Guest>", "_Ann", "山田 花子", "Ann Lee", "", " Ann Lee ", "Ann\rLee", "Ann\r\nLee", "Ann\nLee", "Ann\tLee"];
|
||||
let ids = ["agent1", "agent2", "speaker1", "speaker2", "c5", "c6", "c7", "c8", "c9", "c10", "c11", "c12"];
|
||||
let doc = Document {
|
||||
cues: speakers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| Cue {
|
||||
start: rate.tick_of(i as i64 * 50),
|
||||
end: rate.tick_of(i as i64 * 50 + 25),
|
||||
text: format!("Line {}", i + 1),
|
||||
speaker: Some((*name).into()),
|
||||
id: Some(ids[i].into()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let bytes = write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() });
|
||||
assert_eq!(bytes, write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() }), "stable output");
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
let xml = roxmltree::Document::parse(text).unwrap();
|
||||
let mut all_ids = HashSet::new();
|
||||
for id in xml.descendants().filter_map(|n| n.attribute((XML, "id"))) {
|
||||
assert!(all_ids.insert(id), "duplicate xml:id {id}");
|
||||
}
|
||||
let mut agents = HashMap::new();
|
||||
for agent in xml.descendants().filter(|n| n.has_tag_name((META, "agent"))) {
|
||||
assert_eq!(agent.attribute("type"), Some("other"));
|
||||
let name = agent.children().find(|n| n.has_tag_name((META, "name"))).unwrap();
|
||||
assert_eq!(name.attribute("type"), Some("full"));
|
||||
assert_eq!(name.attribute((XML, "space")), Some("preserve"));
|
||||
agents.insert(agent.attribute((XML, "id")).unwrap(), name.text().unwrap_or(""));
|
||||
}
|
||||
assert_eq!(agents.len(), 11, "each exact name is defined once");
|
||||
let paragraphs: Vec<_> = xml.descendants().filter(|n| n.has_tag_name((TT, "p"))).collect();
|
||||
assert_eq!(paragraphs.len(), speakers.len());
|
||||
for (paragraph, expected) in paragraphs.iter().zip(speakers) {
|
||||
let id = paragraph.attribute((META, "agent")).unwrap();
|
||||
assert_eq!(agents.get(id).copied(), Some(expected), "reference {id}");
|
||||
}
|
||||
let back = parse(&bytes, format).unwrap();
|
||||
assert_eq!(back.cues, doc.cues, "{format:?} names and cue content survive");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_multiple_agent_names_in_reference_order() {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
|
||||
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
|
||||
<ttm:agent xml:id="guest" type="person"><ttm:name type="alias">O’Connor</ttm:name></ttm:agent>
|
||||
</head><body><div><p begin="1s" end="2s" ttm:agent="guest ann">Together</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("O’Connor, Ann Lee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_legacy_filmcraft_speaker_references_readable() {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><body><div>
|
||||
<p begin="1s" end="2s" ttm:agent="Ann_Lee">Hello</p>
|
||||
</div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_span_can_name_its_own_speaker() {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
|
||||
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
|
||||
<ttm:agent xml:id="guest" type="person"><ttm:name type="full">Guest</ttm:name></ttm:agent>
|
||||
</head><body><div><p ttm:agent="ann"><span begin="1s" end="2s" ttm:agent="guest">Hello</span>
|
||||
<span begin="3s" end="4s">Welcome</span></p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues.len(), 2);
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Guest"));
|
||||
assert_eq!(doc.cues[1].speaker.as_deref(), Some("Ann Lee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_metadata_does_not_replace_ttml_speakers() {
|
||||
let real = r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#;
|
||||
for (metadata, attributes) in [
|
||||
(
|
||||
format!(r#"{real}<metadata><app:agent id="speaker1"><app:name type="full">Library editor</app:name></app:agent></metadata>"#),
|
||||
r#"ttm:agent="speaker1""#,
|
||||
),
|
||||
(real.into(), r#"app:agent="library" ttm:agent="speaker1""#),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head>{metadata}</head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "{xml}");
|
||||
}
|
||||
// A TTML agent nested inside another application's metadata container is not significant.
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head><app:metadata>{real}</app:metadata></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("speaker1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_draft_metadata_and_legacy_unqualified_speaker_references() {
|
||||
for (tt, metadata, attributes) in
|
||||
[("http://www.w3.org/2006/10/ttaf1", "http://www.w3.org/2006/10/ttaf1#metadata", r#"ttm:agent="speaker1""#), (TT, META, r#"agent="speaker1""#)]
|
||||
{
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{tt}" xmlns:ttm="{metadata}"><head><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_april_2006_draft_speaker_references_readable() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
|
||||
<head><metadata>
|
||||
<ttm:agent id="Ann_Lee" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
|
||||
</metadata></head>
|
||||
<body><div><p begin="1s" end="2s" ttm:agent="Ann_Lee">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, "Hello");
|
||||
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
|
||||
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "the documented draft keeps its previously readable speaker");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_april_2006_agent_definitions_with_unqualified_ids() {
|
||||
let xml = r#"<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
|
||||
<head><ttm:agent id="speaker1" type="person"><ttm:name type="full">Ann-Marie Lee</ttm:name></ttm:agent></head>
|
||||
<body><div><p begin="1s" end="2s" ttm:agent="speaker1">Welcome</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, "Welcome");
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann-Marie Lee"), "the name resolves through the draft's unqualified id");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_full_speaker_name_with_type(attributes: &str) {
|
||||
let xml = format!(
|
||||
r#"<tt xml:lang="en" xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library">
|
||||
<head><ttm:agent xml:id="speaker1" type="person">
|
||||
<ttm:name type="alias">Ann</ttm:name><ttm:name {attributes}>Ann Lee</ttm:name>
|
||||
</ttm:agent></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let mut speakers = Vec::new();
|
||||
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, "Hello");
|
||||
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
|
||||
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
|
||||
speakers.push(doc.cues[0].speaker.clone());
|
||||
}
|
||||
assert_eq!(speakers, [Some("Ann Lee".into()), Some("Ann Lee".into())], "TTML and DFXP prefer the full name: {attributes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_type_without_application_attribute_prefers_full_name() {
|
||||
assert_full_speaker_name_with_type(r#"type="full""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_type_after_application_attribute_prefers_full_name() {
|
||||
assert_full_speaker_name_with_type(r#"app:type="contact" type="full""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_type_before_application_attribute_prefers_full_name() {
|
||||
assert_full_speaker_name_with_type(r#"type="full" app:type="contact""#);
|
||||
}
|
||||
Loading…
Reference in New Issue