mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 10:03:51 +00:00
* 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 --> 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 < (and an & that would read as an entity as &), as a WebVTT import already keeps its entities, so text like "<b>" 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 < 2" is no longer written as "1 &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>
316 lines
14 KiB
Rust
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('&', "&");
|
|
}
|
|
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}");
|
|
}
|
|
}
|
|
}
|
|
}
|