Files
craft-libs/crates/captions/src/text.rs
T
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

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 `&lt;` / `&amp;` (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> &amp; <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("&lt;b"), Some(('<', 4)));
assert_eq!(entity_at("&eacute;"), None);
assert_eq!(entity_at("&"), None);
assert_eq!(decode_entities("1 &lt; 2 &amp; <i>x</i> &#60; &"), "1 < 2 & <i>x</i> &#60; &");
}
}