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#"AnnAnn Lee"# .to_string(), r#"Ann Lee"#.to_string(), r#"Ann Lee"#.to_string(), ] { let xml = format!( r#"{metadata}

Hello

"# ); 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 & ", "_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#" Ann Lee O’Connor

Together

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

Hello

"# ); 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#" Ann Lee Guest

Hello Welcome

"# ); 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#"Ann Lee"#; for (metadata, attributes) in [ ( format!(r#"{real}Library editor"#), r#"ttm:agent="speaker1""#, ), (real.into(), r#"app:agent="library" ttm:agent="speaker1""#), ] { let xml = format!( r#"{metadata}

Hello

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

Hello

"# ); 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#"Ann Lee

Hello

"# ); 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#" Ann Lee

Hello

"#; 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#" Ann-Marie Lee

Welcome

"#; 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#" AnnAnn Lee

Hello

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