Files
craft-libs/crates/captions/tests/it/roundtrip.rs
fdbb9c2471 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 --&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.

* 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 &lt; (and an & that would read as an
entity as &amp;), as a WebVTT import already keeps its entities, so text
like "&lt;b&gt;" 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 &lt; 2" is
no longer written as "1 &amp;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>
2026-11-11 20:16:34 -05:00

316 lines
14 KiB
Rust

//! Round-trip property tests: write(parse(write(doc))) is stable and parse(write(doc)) == doc for
//! documents each format can represent exactly.
use craft_captions::{Cue, Document, Format, WriteOptions, parse, scc, write};
use craft_captions::{FrameRate, TICKS_PER_SECOND, Tick};
use proptest::prelude::*;
const MS: i64 = TICKS_PER_SECOND / 1000;
/// A text line: printable words without markup-significant sequences.
fn line() -> impl Strategy<Value = String> {
proptest::collection::vec("[A-Za-z0-9éñü,.!?'\"()&%-]{1,10}", 1..6).prop_map(|w| w.join(" "))
}
fn text() -> impl Strategy<Value = String> {
proptest::collection::vec(line(), 1..4).prop_map(|l| l.join("\n"))
}
/// Cues on a millisecond grid, non-overlapping, sorted.
fn ms_cues(max: usize) -> impl Strategy<Value = Vec<Cue>> {
proptest::collection::vec((0i64..5_000, 1i64..8_000, text()), 0..max).prop_map(|v| {
let mut t = 0i64;
v.into_iter()
.map(|(gap, dur, text)| {
let start = t + gap;
t = start + dur;
Cue { start: Tick(start * MS), end: Tick(t * MS), text, ..Default::default() }
})
.collect()
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn srt_roundtrip(cues in ms_cues(12)) {
let doc = Document { cues, ..Default::default() };
let bytes = write(&doc, Format::Srt, WriteOptions::default());
let back = parse(&bytes, Format::Srt).unwrap();
prop_assert_eq!(&back.cues, &doc.cues);
prop_assert_eq!(write(&back, Format::Srt, WriteOptions::default()), bytes);
}
#[test]
fn srt_crlf_bom_roundtrip(cues in ms_cues(6)) {
let doc = Document { cues, ..Default::default() };
let s = String::from_utf8(write(&doc, Format::Srt, WriteOptions::default())).unwrap();
let mut bytes = vec![0xef, 0xbb, 0xbf];
bytes.extend(s.replace('\n', "\r\n").into_bytes());
let back = parse(&bytes, Format::Srt).unwrap();
prop_assert_eq!(back.cues, doc.cues);
}
#[test]
fn vtt_roundtrip(
cues in ms_cues(12),
ids in proptest::collection::vec(proptest::option::of("[a-z][a-z0-9-]{0,8}"), 12),
speakers in proptest::collection::vec(proptest::option::of("[A-Z][a-z]{1,6}( [A-Z][a-z]{1,6})?"), 12),
settings in proptest::collection::vec(prop_oneof![Just(String::new()), Just("line:90%".to_string()), Just("align:start position:10%".to_string())], 12),
) {
let cues: Vec<Cue> = cues
.into_iter()
.enumerate()
.map(|(i, mut c)| {
c.id = ids[i].clone();
c.speaker = speakers[i].clone();
c.settings = settings[i].clone();
c
})
.collect();
let doc = Document { cues, blocks: vec!["STYLE\n::cue { color: #ff0 }".into()], ..Default::default() };
let bytes = write(&doc, Format::WebVtt, WriteOptions::default());
let back = parse(&bytes, Format::WebVtt).unwrap();
prop_assert_eq!(&back.cues, &doc.cues);
prop_assert_eq!(&back.blocks, &doc.blocks);
prop_assert_eq!(write(&back, Format::WebVtt, WriteOptions::default()), bytes);
}
/// SCC: frame-exact at 29.97 fps for cues with room to load (≥ 2 s apart) and 608-safe text.
#[test]
fn scc_roundtrip(
v in proptest::collection::vec((60i64..400, 3i64..300, proptest::collection::vec("[A-Za-z0-9,.!?'()é-]{1,8}", 1..4)), 1..8),
drop_frame in any::<bool>(),
) {
let mut f = 0i64;
let cues: Vec<Cue> = v
.into_iter()
.map(|(gap, dur, words)| {
let start = f + gap;
f = start + dur;
Cue { start: scc::RATE.tick_of(start), end: scc::RATE.tick_of(f), text: words.join(" "), ..Default::default() }
})
.collect();
let doc = Document { cues, ..Default::default() };
let bytes = write(&doc, Format::Scc, WriteOptions { drop_frame, ..Default::default() });
let back = parse(&bytes, Format::Scc).unwrap();
prop_assert_eq!(&back.cues, &doc.cues);
prop_assert_eq!(write(&back, Format::Scc, WriteOptions { drop_frame, ..Default::default() }), bytes);
}
/// Back-to-back SCC captions (no gap) still come back frame-exact when each lasts long enough
/// for the next one to load while it is showing.
#[test]
fn scc_back_to_back(v in proptest::collection::vec((45i64..200, "[A-Z][a-z]{1,8}( [a-z]{1,8}){0,2}"), 1..6)) {
let mut f = 90i64;
let cues: Vec<Cue> = v
.into_iter()
.map(|(dur, text)| {
let start = f;
f += dur;
Cue { start: scc::RATE.tick_of(start), end: scc::RATE.tick_of(f), text, ..Default::default() }
})
.collect();
let doc = Document { cues, ..Default::default() };
let back = parse(&write(&doc, Format::Scc, WriteOptions::default()), Format::Scc).unwrap();
prop_assert_eq!(back.cues, doc.cues);
}
/// MCC: frame-exact at 29.97 fps (CEA-608 path), like SCC.
#[test]
fn mcc_roundtrip(
v in proptest::collection::vec((60i64..400, 3i64..300, proptest::collection::vec("[A-Za-z0-9,.!?'()é-]{1,8}", 1..4)), 1..6),
drop_frame in any::<bool>(),
) {
let mut f = 0i64;
let cues: Vec<Cue> = v
.into_iter()
.map(|(gap, dur, words)| {
let start = f + gap;
f = start + dur;
Cue { start: scc::RATE.tick_of(start), end: scc::RATE.tick_of(f), text: words.join(" "), ..Default::default() }
})
.collect();
let doc = Document { cues, ..Default::default() };
let bytes = write(&doc, Format::Mcc, WriteOptions { drop_frame, ..Default::default() });
let back = parse(&bytes, Format::Mcc).unwrap();
prop_assert_eq!(&back.cues, &doc.cues);
prop_assert_eq!(write(&back, Format::Mcc, WriteOptions { drop_frame, ..Default::default() }), bytes);
}
/// MCC CEA-708 path alone is frame-exact too.
#[test]
fn mcc_708_roundtrip(v in proptest::collection::vec((60i64..400, 3i64..300, proptest::collection::vec("[A-Za-z0-9,.!?'()é-]{1,8}", 1..4)), 1..6)) {
let mut f = 0i64;
let cues: Vec<Cue> = v
.into_iter()
.map(|(gap, dur, words)| {
let start = f + gap;
f = start + dur;
Cue { start: scc::RATE.tick_of(start), end: scc::RATE.tick_of(f), text: words.join(" "), ..Default::default() }
})
.collect();
let doc = Document { cues, ..Default::default() };
let text = craft_captions::mcc::write_with(&doc, true, false, true, "test");
let back = parse(text.as_bytes(), Format::Mcc).unwrap();
prop_assert_eq!(&back.cues, &doc.cues);
}
/// EBU STL: frame-exact at 25 and 29.97 fps, multi-line text with accents, long subtitles in
/// extension blocks.
#[test]
fn stl_roundtrip(v in proptest::collection::vec((0i64..200, 1i64..300, text()), 0..10), ntsc in any::<bool>()) {
let rate = if ntsc { FrameRate::FPS_29_97 } else { FrameRate::FPS_25 };
let mut f = 0i64;
let cues: Vec<Cue> = v
.into_iter()
.map(|(gap, dur, text)| {
let start = f + gap;
f = start + dur;
Cue { start: rate.tick_of(start), end: rate.tick_of(f), text, ..Default::default() }
})
.collect();
let doc = Document { cues, ..Default::default() };
let opts = WriteOptions { rate: Some(rate), ..Default::default() };
let bytes = write(&doc, Format::Stl, opts);
let back = parse(&bytes, Format::Stl).unwrap();
// 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('&', "&amp;");
}
prop_assert_eq!(&back.cues, &expected);
prop_assert_eq!(write(&back, Format::Stl, opts), bytes);
}
/// TTML (IMSC1): frame-exact at the sequence rate; ids and speakers kept.
#[test]
fn ttml_roundtrip(
v in proptest::collection::vec((0i64..200, 1i64..300, text()), 0..10),
rate in prop_oneof![Just(FrameRate::FPS_23_976), Just(FrameRate::FPS_25), Just(FrameRate::FPS_29_97), Just(FrameRate::FPS_59_94)],
speakers in proptest::collection::vec(proptest::option::of("[A-Z][a-z]{1,6}( [A-Z][a-z]{1,6})?"), 10),
) {
let mut f = 0i64;
let cues: Vec<Cue> = v
.into_iter()
.enumerate()
.map(|(i, (gap, dur, text))| {
let start = f + gap;
f = start + dur;
Cue { start: rate.tick_of(start), end: rate.tick_of(f), text, speaker: speakers[i].clone(), id: Some(format!("c{}", i + 1)), ..Default::default() }
})
.collect();
let doc = Document { cues, ..Default::default() };
let opts = WriteOptions { rate: Some(rate), ..Default::default() };
let bytes = write(&doc, Format::Ttml, opts);
let back = parse(&bytes, Format::Ttml).unwrap();
prop_assert_eq!(&back.cues, &doc.cues);
prop_assert_eq!(write(&back, Format::Ttml, opts), bytes);
}
/// DFXP: clock times in milliseconds; frame-exact after snapping to the sequence rate.
#[test]
fn dfxp_roundtrip(v in proptest::collection::vec((0i64..200, 1i64..300, text()), 0..10), ntsc in any::<bool>()) {
let rate = if ntsc { FrameRate::FPS_29_97 } else { FrameRate::FPS_25 };
let mut f = 0i64;
let cues: Vec<Cue> = v
.into_iter()
.enumerate()
.map(|(i, (gap, dur, text))| {
let start = f + gap;
f = start + dur;
Cue { start: rate.tick_of(start), end: rate.tick_of(f), text, id: Some(format!("c{}", i + 1)), ..Default::default() }
})
.collect();
let doc = Document { cues, ..Default::default() };
let bytes = write(&doc, Format::Dfxp, WriteOptions { rate: Some(rate), ..Default::default() });
let mut back = parse(&bytes, Format::Dfxp).unwrap();
back.snap_to_frames(rate);
prop_assert_eq!(&back.cues, &doc.cues);
}
/// Readers never panic on arbitrary input.
#[test]
fn readers_are_total(bytes in proptest::collection::vec(any::<u8>(), 0..400)) {
for f in Format::ALL {
let _ = parse(&bytes, f);
}
}
/// The binary STL reader never panics on a valid header followed by arbitrary blocks, and
/// the MCC reader on arbitrary data lines.
#[test]
fn stl_and_mcc_readers_are_total(tail in proptest::collection::vec(any::<u8>(), 0..1200), line in "[0-9A-Za-z:;\t ]{0,200}") {
let mut stl = write(&Document::default(), Format::Stl, WriteOptions::default());
stl.extend(tail);
let _ = parse(&stl, Format::Stl);
let mcc = format!("File Format=MacCaption_MCC V1.0\nTime Code Rate=30DF\n\n00:00:00;00\t{line}\n");
let _ = parse(mcc.as_bytes(), Format::Mcc);
}
}
#[test]
fn srt_to_vtt_to_scc_keeps_text_and_frames() {
let srt = b"1\r\n00:00:02,002 --> 00:00:04,004\r\nHello there\r\n\r\n2\r\n00:00:05,005 --> 00:00:07,007\r\n<i>General</i> Kenobi\r\n";
let mut doc = parse(srt, Format::Srt).unwrap();
doc.snap_to_frames(scc::RATE);
let vtt = write(&doc, Format::WebVtt, WriteOptions::default());
let doc2 = parse(&vtt, Format::WebVtt).unwrap();
let scc_bytes = write(&doc2, Format::Scc, WriteOptions::default());
let doc3 = parse(&scc_bytes, Format::Scc).unwrap();
let texts: Vec<&str> = doc3.cues.iter().map(|c| c.text.as_str()).collect();
assert_eq!(texts, vec!["Hello there", "General Kenobi"]);
// 2.002 s = frame 60 at 29.97
assert_eq!(scc::frame_of(doc3.cues[0].start), 60);
assert_eq!(scc::frame_of(doc3.cues[1].end), 210);
}
#[test]
fn tight_pop_on_load_can_cross_the_previous_caption_end() {
let text = "A second caption that needs more time to load";
let doc = Document {
cues: vec![
Cue { start: scc::RATE.tick_of(30), end: scc::RATE.tick_of(60), text: "First".into(), ..Default::default() },
Cue { start: scc::RATE.tick_of(67), end: scc::RATE.tick_of(150), text: text.into(), ..Default::default() },
],
..Default::default()
};
for format in [Format::Scc, Format::Mcc] {
let back = parse(&write(&doc, format, WriteOptions::default()), format).unwrap();
assert_eq!(back.cues.len(), 2);
assert_eq!(scc::frame_of(back.cues[0].end), 60);
assert_eq!(scc::frame_of(back.cues[1].start), 67, "{format:?}");
assert_eq!(scc::frame_of(back.cues[1].end), 150);
}
}
#[test]
fn interrupted_load_keeps_extended_characters_and_repeated_basic_pairs() {
for text in
["A second caption that needs more time to load", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "ÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀ\nÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀ"]
{
let doc = Document {
cues: vec![
Cue { start: scc::RATE.tick_of(30), end: scc::RATE.tick_of(60), text: "First".into(), ..Default::default() },
Cue { start: scc::RATE.tick_of(67), end: scc::RATE.tick_of(180), text: text.into(), ..Default::default() },
],
..Default::default()
};
for format in [Format::Scc, Format::Mcc] {
let back = parse(&write(&doc, format, WriteOptions::default()), format).unwrap();
assert_eq!(back.cues.len(), 2);
assert_eq!(back.cues[1].text.replace(['\n', ' '], ""), text.replace(['\n', ' '], ""), "{format:?}");
assert_eq!(scc::frame_of(back.cues[0].end), 60);
assert_eq!(scc::frame_of(back.cues[1].end), 180);
}
let pairs = scc::schedule(&doc);
for (&frame, &pair) in &pairs {
if (0x10..=0x1f).contains(&pair.0) {
assert!(pairs.get(&(frame - 1)) == Some(&pair) || pairs.get(&(frame + 1)) == Some(&pair), "split doubled code at {frame}");
}
}
}
}