mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +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>
116 lines
4.1 KiB
Rust
116 lines
4.1 KiB
Rust
//! Caption text helpers shared by the writers.
|
|
|
|
/// The named entities cue text may carry, as a WebVTT import keeps them.
|
|
const ENTITIES: [(&str, char); 8] =
|
|
[("amp;", '&'), ("lt;", '<'), ("gt;", '>'), ("nbsp;", '\u{a0}'), ("quot;", '"'), ("apos;", '\''), ("lrm;", '\u{200e}'), ("rlm;", '\u{200f}')];
|
|
|
|
/// The entity at the start of `s`, as the character it names and its length in bytes. Cue text
|
|
/// keeps a literal `<` or `&` as `<` / `&` (as a WebVTT import keeps its entities) so that
|
|
/// its style tags stay tags; plain text and the writers decode them here.
|
|
pub fn entity_at(s: &str) -> Option<(char, usize)> {
|
|
let rest = s.strip_prefix('&')?;
|
|
ENTITIES.iter().find(|(name, _)| rest.starts_with(name)).map(|(name, ch)| (*ch, name.len() + 1))
|
|
}
|
|
|
|
/// `s` with its entities decoded and its tags kept: for a format that has tags but no entities.
|
|
pub fn decode_entities(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut rest = s;
|
|
while let Some(ch) = rest.chars().next() {
|
|
if let Some((decoded, len)) = entity_at(rest) {
|
|
out.push(decoded);
|
|
rest = rest.get(len..).unwrap_or("");
|
|
} else {
|
|
out.push(ch);
|
|
rest = rest.get(ch.len_utf8()..).unwrap_or("");
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Strip `<…>` tags and decode the common HTML/WebVTT entities.
|
|
pub fn plain_text(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut chars = s.chars().peekable();
|
|
while let Some(c) = chars.next() {
|
|
match c {
|
|
'<' => {
|
|
// A tag runs to the next '>'. Scan forward once; preserving an unterminated
|
|
// tag as literal text must not rescan every later opening bracket.
|
|
let mut literal = String::new();
|
|
let mut closed = false;
|
|
for next in chars.by_ref() {
|
|
if next == '>' {
|
|
closed = true;
|
|
break;
|
|
}
|
|
literal.push(next);
|
|
}
|
|
if !closed {
|
|
out.push('<');
|
|
out.push_str(&literal);
|
|
}
|
|
}
|
|
'{' if chars.peek() == Some(&'\\') => {
|
|
// SSA-style override blocks some SRT files carry: {\an8}
|
|
let mut literal = String::new();
|
|
let mut closed = false;
|
|
for next in chars.by_ref() {
|
|
if next == '}' {
|
|
closed = true;
|
|
break;
|
|
}
|
|
literal.push(next);
|
|
}
|
|
if !closed {
|
|
out.push('{');
|
|
out.push_str(&literal);
|
|
}
|
|
}
|
|
'&' => {
|
|
let rest: String = std::iter::once('&').chain(chars.clone().take(8)).collect();
|
|
if let Some((ch, len)) = entity_at(&rest) {
|
|
for _ in 1..len {
|
|
chars.next();
|
|
}
|
|
out.push(ch);
|
|
} else {
|
|
out.push('&');
|
|
}
|
|
}
|
|
'\r' => {}
|
|
_ => out.push(c),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn plain() {
|
|
assert_eq!(plain_text("<i>Hello</i> & <b>bye</b>"), "Hello & bye");
|
|
assert_eq!(plain_text("{\\an8}Top"), "Top");
|
|
assert_eq!(plain_text("a < b"), "a < b");
|
|
assert_eq!(plain_text("<v Bob>Hi</v>"), "Hi");
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_caption_markup_is_preserved_without_repeated_suffix_scans() {
|
|
let angle = "<".repeat(10_000);
|
|
let ssa = format!("{{\\{}", "{".repeat(10_000));
|
|
assert_eq!(plain_text(&angle), angle);
|
|
assert_eq!(plain_text(&ssa), ssa);
|
|
}
|
|
|
|
#[test]
|
|
fn entities() {
|
|
assert_eq!(entity_at("<b"), Some(('<', 4)));
|
|
assert_eq!(entity_at("é"), None);
|
|
assert_eq!(entity_at("&"), None);
|
|
assert_eq!(decode_entities("1 < 2 & <i>x</i> < &"), "1 < 2 & <i>x</i> < &");
|
|
}
|
|
}
|