Files
craft-libs/crates/captions/tests/it/ttml_cue_ids.rs
86e59ebc08 craft-captions: ten caption fixes ported from open FilmCraft pull requests (#932)
* craft-captions: TTML cue text collapses only XML whitespace and keeps edge line breaks

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* craft-captions: TTML/DFXP export keeps literal < and > text

Ported from storytold/filmcraft#2830. The writer treated every < with a later >
as a tag and wrote nothing for unknown names, so "1 < 2 and 3 > 2" lost its
middle. Only a well-formed tag is markup now; a > inside a quoted attribute
value does not end the tag.

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

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

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

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

---------

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>
2026-11-11 18:44:12 -05:00

363 lines
18 KiB
Rust

use std::collections::BTreeSet;
use craft_captions::{Cue, Document, Format, WriteOptions, parse, write};
use craft_captions::{FrameRate, TICKS_PER_SECOND, Tick};
const XML: &str = "http://www.w3.org/XML/1998/namespace";
const TT: &str = "http://www.w3.org/ns/ttml";
fn timeline(doc: &Document) -> Vec<(Tick, Tick, &str)> {
doc.cues.iter().map(|cue| (cue.start, cue.end, cue.text.as_str())).collect()
}
fn distinct_vtt_ids_do_not_collide_with_document_ids(format: Format) {
// Every WebVTT identifier is distinct. Spaces are legal in WebVTT identifiers.
// The fourth identifier requires an XML-name fallback, which must not reuse c4.
let vtt = br#"WEBVTT
s1
00:00:01.000 --> 00:00:02.000
Opening
r1
00:00:03.000 --> 00:00:04.000
Reply
c4
00:00:05.000 --> 00:00:06.000
Explicit identifier
display label
00:00:07.000 --> 00:00:08.000
Display identifier
keep
00:00:09.000 --> 00:00:10.000
Closing
"#;
let doc = parse(vtt, Format::WebVtt).unwrap();
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
let input_ids: Vec<_> = doc.cues.iter().map(|cue| cue.id.as_deref().unwrap()).collect();
assert_eq!(input_ids, ["s1", "r1", "c4", "display label", "keep"]);
assert_eq!(input_ids.iter().collect::<BTreeSet<_>>().len(), input_ids.len(), "the source identifiers are unique");
let expected = [
(Tick(TICKS_PER_SECOND), Tick(2 * TICKS_PER_SECOND), "Opening"),
(Tick(3 * TICKS_PER_SECOND), Tick(4 * TICKS_PER_SECOND), "Reply"),
(Tick(5 * TICKS_PER_SECOND), Tick(6 * TICKS_PER_SECOND), "Explicit identifier"),
(Tick(7 * TICKS_PER_SECOND), Tick(8 * TICKS_PER_SECOND), "Display identifier"),
(Tick(9 * TICKS_PER_SECOND), Tick(10 * TICKS_PER_SECOND), "Closing"),
];
assert_eq!(timeline(&doc), expected);
// Check all document IDs independently of the production allocator.
let options = WriteOptions { rate: Some(FrameRate::FPS_25), ..Default::default() };
let bytes = write(&doc, format, options);
assert_eq!(bytes, write(&doc, format, options), "stable export for {format:?}");
let text = std::str::from_utf8(&bytes).unwrap();
let xml = roxmltree::Document::parse(text).unwrap();
let paragraphs: Vec<_> = xml.descendants().filter(|node| node.has_tag_name((TT, "p"))).collect();
assert_eq!(paragraphs.len(), expected.len());
assert!(paragraphs.iter().all(|node| node.attribute((XML, "id")).is_some_and(|id| !id.is_empty())));
for paragraph in &paragraphs {
assert_ncname(paragraph.attribute((XML, "id")).unwrap());
}
assert_eq!(paragraphs.last().unwrap().attribute((XML, "id")), Some("keep"), "an unrelated valid ID stays intact");
assert_eq!(paragraphs[2].attribute((XML, "id")), Some("c4"), "the existing explicit collision ID stays intact");
assert_ne!(
paragraphs[3].attribute((XML, "id")),
Some("display label"),
"{format:?}: do not emit the space-containing display label verbatim as xml:id"
);
let mut seen = BTreeSet::new();
let repeated: Vec<_> =
xml.descendants().filter_map(|node| node.attribute((XML, "id"))).filter(|id| !seen.insert(*id)).map(str::to_owned).collect();
let back = parse(&bytes, format).unwrap();
assert_eq!(timeline(&back), expected, "{format:?}: renaming IDs must preserve every cue and its literal timing");
assert!(repeated.is_empty(), "{format:?}: all document xml:id values must be unique: {repeated:?}");
}
// Validate the public writer's observed ID independently of its allocator and
// without requiring ASCII: an NCName is an XML name with no colon.
fn assert_ncname(id: &str) {
assert!(!id.contains(':'), "an xml:id must not contain a colon: {id:?}");
let witness = format!("<{id}/>");
let xml = roxmltree::Document::parse(&witness).expect("the generated ID must be a valid XML name");
assert_eq!(xml.root_element().tag_name().name(), id, "the entire generated ID must be the XML name");
}
fn valid_ascii_and_unicode_cue_ids_survive_export(format: Format) {
// U+00E9 followed by ASCII pisode_1 is a valid Unicode NCName.
let ids = ["c4", "épisode_1"];
for id in ids {
assert_ncname(id);
}
let doc = Document {
cues: vec![
Cue {
start: Tick(TICKS_PER_SECOND),
end: Tick(2 * TICKS_PER_SECOND),
text: "ASCII identifier".into(),
id: Some(ids[0].into()),
..Default::default()
},
Cue {
start: Tick(3 * TICKS_PER_SECOND),
end: Tick(4 * TICKS_PER_SECOND),
text: "Unicode identifier".into(),
id: Some(ids[1].into()),
..Default::default()
},
],
..Default::default()
};
let expected = [
(Tick(TICKS_PER_SECOND), Tick(2 * TICKS_PER_SECOND), "ASCII identifier"),
(Tick(3 * TICKS_PER_SECOND), Tick(4 * TICKS_PER_SECOND), "Unicode identifier"),
];
assert_eq!(timeline(&doc), expected);
let expected_ids: Vec<_> = ids.into_iter().map(|id| Some(id.to_string())).collect();
let options = WriteOptions { rate: Some(FrameRate::FPS_25), ..Default::default() };
let bytes = write(&doc, format, options);
assert_eq!(bytes, write(&doc, format, options), "stable export for {format:?}");
let xml = roxmltree::Document::parse(std::str::from_utf8(&bytes).unwrap()).unwrap();
let exported_ids: Vec<_> =
xml.descendants().filter(|node| node.has_tag_name((TT, "p"))).map(|node| node.attribute((XML, "id")).map(str::to_string)).collect();
let mut seen = BTreeSet::new();
let duplicates: Vec<_> =
xml.descendants().filter_map(|node| node.attribute((XML, "id"))).filter(|id| !seen.insert(*id)).map(str::to_string).collect();
let back = parse(&bytes, format).unwrap();
assert_eq!(timeline(&back), expected, "{format:?}: valid IDs must preserve literal cue timing and text");
let imported_ids: Vec<_> = back.cues.iter().map(|cue| cue.id.clone()).collect();
assert_eq!(exported_ids, expected_ids, "{format:?}: valid ASCII and Unicode IDs must survive verbatim in XML");
assert_eq!(imported_ids, expected_ids, "{format:?}: valid ASCII and Unicode IDs must survive owned-reader import");
assert!(duplicates.is_empty(), "{format:?}: all document xml:id values must be unique: {duplicates:?}");
}
fn a_generated_id_does_not_take_a_later_explicit_cue_id(format: Format) {
let earlier = Cue { start: Tick(TICKS_PER_SECOND), end: Tick(2 * TICKS_PER_SECOND), text: "Earlier unlabeled cue".into(), ..Default::default() };
assert!(earlier.id.is_none());
let probe = Document { cues: vec![earlier.clone()], ..Default::default() };
let expected = [
(Tick(TICKS_PER_SECOND), Tick(2 * TICKS_PER_SECOND), "Earlier unlabeled cue"),
(Tick(3 * TICKS_PER_SECOND), Tick(4 * TICKS_PER_SECOND), "Later explicit cue"),
];
assert_eq!(timeline(&probe), expected[..1]);
let options = WriteOptions { rate: Some(FrameRate::FPS_25), ..Default::default() };
// The first ordinary export selects a collision input, not the expected
// replacement spelling. An unsuitable generated ID is a failure, not a skip.
let probe_bytes = write(&probe, format, options);
let probe_xml = roxmltree::Document::parse(std::str::from_utf8(&probe_bytes).unwrap()).unwrap();
let probe_paragraphs: Vec<_> = probe_xml.descendants().filter(|node| node.has_tag_name((TT, "p"))).collect();
assert_eq!(probe_paragraphs.len(), 1);
let observed = probe_paragraphs[0].attribute((XML, "id")).expect("an unlabeled cue must receive an ID").to_string();
assert_ncname(&observed);
let probe_back = parse(&probe_bytes, format).unwrap();
assert_eq!(timeline(&probe_back), expected[..1], "{format:?}: the ordinary probe must preserve literal timing and text");
let doc = Document {
cues: vec![
earlier.clone(),
Cue {
start: Tick(3 * TICKS_PER_SECOND),
end: Tick(4 * TICKS_PER_SECOND),
text: "Later explicit cue".into(),
id: Some(observed.clone()),
..Default::default()
},
],
..Default::default()
};
assert_eq!(timeline(&doc), expected);
assert!(doc.cues[0].id.is_none());
assert_eq!(doc.cues[1].id.as_deref(), Some(observed.as_str()));
let bytes = write(&doc, format, options);
assert_eq!(bytes, write(&doc, format, options), "stable export for {format:?}");
let xml = roxmltree::Document::parse(std::str::from_utf8(&bytes).unwrap()).unwrap();
let paragraphs: Vec<_> = xml.descendants().filter(|node| node.has_tag_name((TT, "p"))).collect();
assert_eq!(paragraphs.len(), expected.len());
let exported_ids: Vec<_> = paragraphs.iter().map(|node| node.attribute((XML, "id")).expect("each cue must have an ID").to_string()).collect();
for id in &exported_ids {
assert_ncname(id);
}
let mut seen = BTreeSet::new();
let duplicates: Vec<_> =
xml.descendants().filter_map(|node| node.attribute((XML, "id"))).filter(|id| !seen.insert(*id)).map(str::to_string).collect();
let back = parse(&bytes, format).unwrap();
assert_eq!(timeline(&back), expected, "{format:?}: reserving the later ID must preserve every cue and its literal timing");
assert_eq!(exported_ids[1], observed, "{format:?}: the later explicit ID must survive verbatim");
assert_ne!(exported_ids[0], observed, "{format:?}: the earlier generated ID must not take the later explicit ID");
assert_eq!(back.cues[1].id.as_deref(), Some(observed.as_str()), "{format:?}: the later explicit ID must survive owned-reader import");
assert!(duplicates.is_empty(), "{format:?}: all document xml:id values must be unique: {duplicates:?}");
}
#[test]
fn ttml_distinct_vtt_ids_do_not_collide_with_document_ids() {
distinct_vtt_ids_do_not_collide_with_document_ids(Format::Ttml);
}
#[test]
fn dfxp_distinct_vtt_ids_do_not_collide_with_document_ids() {
distinct_vtt_ids_do_not_collide_with_document_ids(Format::Dfxp);
}
#[test]
fn ttml_valid_ascii_and_unicode_cue_ids_survive_export() {
valid_ascii_and_unicode_cue_ids_survive_export(Format::Ttml);
}
#[test]
fn dfxp_valid_ascii_and_unicode_cue_ids_survive_export() {
valid_ascii_and_unicode_cue_ids_survive_export(Format::Dfxp);
}
#[test]
fn ttml_generated_id_reserves_later_explicit_cue_id() {
a_generated_id_does_not_take_a_later_explicit_cue_id(Format::Ttml);
}
#[test]
fn dfxp_generated_id_reserves_later_explicit_cue_id() {
a_generated_id_does_not_take_a_later_explicit_cue_id(Format::Dfxp);
}
fn duplicate_parser_accepted_cue_ids_keep_first_id(format: Format) {
// FilmCraft tolerates duplicate input IDs; WebVTT itself requires unique identifiers.
// Keep the first usable ID and give the later cue a distinct ID when writing XML.
let vtt = br#"WEBVTT
shared
00:00:01.000 --> 00:00:02.000
First duplicate identifier
shared
00:00:03.000 --> 00:00:04.000
Second duplicate identifier
"#;
assert_ncname("shared");
let doc = parse(vtt, Format::WebVtt).unwrap();
let imported_ids: Vec<_> = doc.cues.iter().map(|cue| cue.id.as_deref()).collect();
assert_eq!(imported_ids, [Some("shared"), Some("shared")], "both input IDs must reach the writer unchanged");
let expected = [
(Tick(TICKS_PER_SECOND), Tick(2 * TICKS_PER_SECOND), "First duplicate identifier"),
(Tick(3 * TICKS_PER_SECOND), Tick(4 * TICKS_PER_SECOND), "Second duplicate identifier"),
];
assert_eq!(timeline(&doc), expected);
let options = WriteOptions { rate: Some(FrameRate::FPS_25), ..Default::default() };
let bytes = write(&doc, format, options);
assert_eq!(bytes, write(&doc, format, options), "stable export for {format:?}");
let xml = roxmltree::Document::parse(std::str::from_utf8(&bytes).unwrap()).unwrap();
let paragraphs: Vec<_> = xml.descendants().filter(|node| node.has_tag_name((TT, "p"))).collect();
assert_eq!(paragraphs.len(), expected.len());
let exported_ids: Vec<_> = paragraphs.iter().map(|node| node.attribute((XML, "id")).expect("each cue must have an ID")).collect();
for id in &exported_ids {
assert_ncname(id);
}
assert_eq!(exported_ids[0], "shared", "{format:?}: preserve the first usable explicit ID verbatim");
assert_ne!(exported_ids[1], "shared", "{format:?}: the second explicit ID must become distinct");
let mut seen = BTreeSet::new();
let duplicates: Vec<_> =
xml.descendants().filter_map(|node| node.attribute((XML, "id"))).filter(|id| !seen.insert(*id)).map(str::to_owned).collect();
let back = parse(&bytes, format).unwrap();
assert_eq!(timeline(&back), expected, "{format:?}: duplicate-ID tolerance must preserve literal cue timing and text");
assert_eq!(back.cues[0].id.as_deref(), Some("shared"), "{format:?}: the first explicit ID must survive owned-reader import");
assert!(duplicates.is_empty(), "{format:?}: all document xml:id values must be unique: {duplicates:?}");
}
#[test]
fn ttml_duplicate_parser_accepted_cue_ids_keep_first_id() {
duplicate_parser_accepted_cue_ids_keep_first_id(Format::Ttml);
}
#[test]
fn dfxp_duplicate_parser_accepted_cue_ids_keep_first_id() {
duplicate_parser_accepted_cue_ids_keep_first_id(Format::Dfxp);
}
fn decomposed_and_composed_cue_ids_stay_distinct(format: Format) {
// XML 1.0 Fifth Edition NameChar permits U+0301 after e. Do not normalize either ID.
let ids = ["e\u{301}_1", "é_1"];
assert_ne!(ids[0], ids[1]);
for id in ids {
assert_ncname(id);
}
let vtt = format!(
"WEBVTT\n\n{}\n00:00:01.000 --> 00:00:02.000\nDecomposed identifier\n\n{}\n00:00:03.000 --> 00:00:04.000\nComposed identifier\n",
ids[0], ids[1]
);
let doc = parse(vtt.as_bytes(), Format::WebVtt).unwrap();
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
let input_ids: Vec<_> = doc.cues.iter().map(|cue| cue.id.as_deref()).collect();
assert_eq!(input_ids, [Some(ids[0]), Some(ids[1])], "the parser must preserve both exact input IDs");
let expected = [
(Tick(TICKS_PER_SECOND), Tick(2 * TICKS_PER_SECOND), "Decomposed identifier"),
(Tick(3 * TICKS_PER_SECOND), Tick(4 * TICKS_PER_SECOND), "Composed identifier"),
];
assert_eq!(timeline(&doc), expected);
let options = WriteOptions { rate: Some(FrameRate::FPS_25), ..Default::default() };
let bytes = write(&doc, format, options);
assert_eq!(bytes, write(&doc, format, options), "stable export for {format:?}");
let xml = roxmltree::Document::parse(std::str::from_utf8(&bytes).unwrap()).unwrap();
let exported_ids: Vec<_> = xml.descendants().filter(|node| node.has_tag_name((TT, "p"))).map(|node| node.attribute((XML, "id"))).collect();
assert_eq!(exported_ids, input_ids, "{format:?}: preserve the decomposed and composed IDs verbatim without normalization");
let mut seen = BTreeSet::new();
assert!(xml.descendants().filter_map(|node| node.attribute((XML, "id"))).all(|id| seen.insert(id)), "all document xml:id values must be unique");
let back = parse(&bytes, format).unwrap();
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
assert_eq!(timeline(&back), expected, "{format:?}: preserve literal cue timing and text");
let imported_ids: Vec<_> = back.cues.iter().map(|cue| cue.id.as_deref()).collect();
assert_eq!(imported_ids, input_ids, "{format:?}: both exact IDs must survive owned-reader import");
}
fn non_xml_numeric_cue_id_uses_fallback(format: Format) {
// Leading combining marks and colons are unchanged invalid controls.
// U+00B2 is numeric in Rust, but is not XML 1.0 Fifth Edition NameChar.
for id in ["\u{301}_1", "a:b", "a\u{b2}"] {
let vtt = format!("WEBVTT\n\n{id}\n00:00:01.000 --> 00:00:02.000\nIdentifier fallback\n");
let doc = parse(vtt.as_bytes(), Format::WebVtt).unwrap();
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].id.as_deref(), Some(id));
let expected = [(Tick(TICKS_PER_SECOND), Tick(2 * TICKS_PER_SECOND), "Identifier fallback")];
assert_eq!(timeline(&doc), expected);
let options = WriteOptions { rate: Some(FrameRate::FPS_25), ..Default::default() };
let bytes = write(&doc, format, options);
assert_eq!(bytes, write(&doc, format, options), "stable export for {format:?}: {id:?}");
let xml = roxmltree::Document::parse(std::str::from_utf8(&bytes).unwrap()).unwrap();
let paragraphs: Vec<_> = xml.descendants().filter(|node| node.has_tag_name((TT, "p"))).collect();
assert_eq!(paragraphs.len(), 1);
let output_id = paragraphs[0].attribute((XML, "id")).expect("the cue must have an ID");
assert_ne!(output_id, id, "{format:?}: an invalid XML NCName must receive a fallback: {id:?}");
assert_ncname(output_id);
let mut seen = BTreeSet::new();
assert!(
xml.descendants().filter_map(|node| node.attribute((XML, "id"))).all(|id| seen.insert(id)),
"all document xml:id values must be unique"
);
let back = parse(&bytes, format).unwrap();
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
assert_eq!(timeline(&back), expected, "{format:?}: fallback must preserve literal cue timing and text");
assert_eq!(back.cues[0].id.as_deref(), Some(output_id));
}
}
#[test]
fn ttml_decomposed_and_composed_cue_ids_stay_distinct() {
decomposed_and_composed_cue_ids_stay_distinct(Format::Ttml);
}
#[test]
fn dfxp_decomposed_and_composed_cue_ids_stay_distinct() {
decomposed_and_composed_cue_ids_stay_distinct(Format::Dfxp);
}
#[test]
fn ttml_non_xml_numeric_cue_id_uses_fallback() {
non_xml_numeric_cue_id_uses_fallback(Format::Ttml);
}
#[test]
fn dfxp_non_xml_numeric_cue_id_uses_fallback() {
non_xml_numeric_cue_id_uses_fallback(Format::Dfxp);
}