captions: preserve speaker names in TTML metadata

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
sanyamsingh001 2026-10-13 11:07:57 +10:00
parent 0541d4d182
commit 2343fc8fa3
3 changed files with 340 additions and 15 deletions

View File

@ -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

View File

@ -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', "&#13;")
));
}
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()
});
}
}
}

View File

@ -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""#);
}