Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ Changelog
whitespace from semicolon splitting was not stripped, causing the
property name comparison to fail silently.

- Add compound class selector parsing (``::cue(.bold.yellow)``) to
``WebVTTReader``'s STYLE block handling. Selectors with multiple
dot-separated classes now match spans carrying all listed classes
(including superset spans with additional classes). Compound selectors
cascade with higher specificity than single-class selectors, and
round-trip correctly through ``WebVTTWriter`` and ``DFXPWriter``.

- Fix quoted ``font-family`` names (e.g. ``"Helvetica Neue"``) producing
malformed XML in DFXP output. The reader now strips CSS quotes at parse
time, and ``WebVTTWriter`` re-quotes multi-word font names on output.

2.3.7
^^^^^^
- Fix ``SCCWriter`` merging multi-line captions into a single line when
Expand Down
4 changes: 1 addition & 3 deletions pycaption/dfxp/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,9 +462,7 @@ def cleanup_regions(self):


_CSS_LENGTH_RE = re.compile(r"^-?[\d.]+(?:px|em|%|pt|rem|c)?$")
_CSS_COLOR_RE = re.compile(
r"^(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-zA-Z]{3,})$"
)
_CSS_COLOR_RE = re.compile(r"^(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-zA-Z]{3,})$")


def _text_shadow_to_outline(value):
Expand Down
4 changes: 3 additions & 1 deletion pycaption/webvtt/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
comma-separated alignment qualifiers per W3C WebVTT spec:
position:50% position:50%,line-left line:80%,center align:center
"""
STYLE_SELECTOR_PATTERN = re.compile(r"::cue(?:\((\.?[\w-]+)\))?\s*\{([^}]*)\}")
STYLE_SELECTOR_PATTERN = re.compile(
r"::cue(?:\((\.[\w-]+(?:\.[\w-]+)*|[\w-]+)\))?\s*\{([^}]*)\}"
)
"""
Matches ::cue selectors with their declaration blocks:
::cue { color: white } -> group(1)=None
Expand Down
12 changes: 9 additions & 3 deletions pycaption/webvtt/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1069,7 +1069,7 @@ def _parse_css_declarations(declarations):
elif prop_name == "background-color":
props["background-color"] = prop_value
elif prop_name == "font-family":
props["font-family"] = prop_value
props["font-family"] = prop_value.replace('"', "").replace("'", "")
elif prop_name == "font-size":
props["font-size"] = prop_value
elif prop_name == "text-shadow":
Expand Down Expand Up @@ -1110,8 +1110,8 @@ def _cascade_class_styles(captions, styles, base_style):
def _apply_cascade(content, styles, base_style):
"""Merge base and class-resolved styles into a node's content dict.

Resolution order: base (::cue) → each class in order. Existing
keys in content are not overwritten (inline > cascade).
Resolution order: base (::cue) → single class → compound selectors.
Existing keys in content are not overwritten (inline > cascade).

:param content: The STYLE node's content dict (mutated in place).
:param styles: Full styles dict with class entries.
Expand All @@ -1129,6 +1129,12 @@ def _apply_cascade(content, styles, base_style):
if class_style:
resolved.update(class_style)

if len(classes) > 1:
class_set = set(classes)
for key, style in styles.items():
if "." in key and set(key.split(".")).issubset(class_set):
resolved.update(style)

for key, value in resolved.items():
if key not in content:
content[key] = value
Expand Down
8 changes: 8 additions & 0 deletions pycaption/webvtt/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,17 @@ def _format_css_declarations(cls, props):
css_prop, css_val = cls._INTERNAL_TO_CSS[k]
declarations.append(f"{css_prop}: {css_val}")
elif k not in cls._INTERNAL_STYLE_KEYS:
if k == "font-family":
v = cls._quote_font_family(v)
declarations.append(f"{k}: {v}")
return "; ".join(declarations)

@staticmethod
def _quote_font_family(value):
"""Re-quote multi-word font names for valid CSS output."""
parts = [p.strip() for p in value.split(",")]
return ", ".join(f'"{p}"' if " " in p else p for p in parts)

_STYLE_TO_TAG = {
"italics": ("<i>", "</i>"),
"underline": ("<u>", "</u>"),
Expand Down
4 changes: 2 additions & 2 deletions tests/test_sami.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,11 @@ def test_sami_with_invalid_inline_style(

def test_text_align_recognized_after_other_property(self):
sami = (
"<SAMI><HEAD><STYLE TYPE=\"Text/css\">\n"
'<SAMI><HEAD><STYLE TYPE="Text/css">\n'
"P { margin-left: 20px; }\n"
".ENCC { Name: 'Subtitles'; Lang: en-US; SAMIType: CC; }\n"
"</STYLE></HEAD><BODY>\n"
"<SYNC start=\"100\">\n"
'<SYNC start="100">\n'
'<P class="ENCC" Style="color:white; text-align:right;">'
"Hello</P>\n"
"</SYNC></BODY></SAMI>"
Expand Down
129 changes: 126 additions & 3 deletions tests/test_webvtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -1086,9 +1086,22 @@ def test_font_family_comma_separated_parsed(self):
captions = self.reader.read(vtt)
styles = dict(captions.get_styles())

assert styles["::cue"] == {
"font-family": 'Arial, "Helvetica Neue", sans-serif'
}
assert styles["::cue"] == {"font-family": "Arial, Helvetica Neue, sans-serif"}

def test_quoted_font_family_roundtrips_with_quotes(self):
from pycaption import WebVTTWriter

vtt = (
"WEBVTT\n\n"
"STYLE\n"
'::cue(.fancy) { font-family: "Courier New", monospace }\n\n'
"00:00:01.000 --> 00:00:03.000\n"
"<c.fancy>Hello</c>\n"
)
captions = self.reader.read(vtt)
result = WebVTTWriter().write(captions)

assert 'font-family: "Courier New", monospace' in result

def test_font_size_parsed(self):
vtt = (
Expand Down Expand Up @@ -1159,6 +1172,116 @@ def test_line_height_parsed(self):

assert styles["::cue"] == {"line-height": "1.5"}

def test_compound_selector_parsed(self):
vtt = (
"WEBVTT\n\n"
"STYLE\n"
"::cue(.bold.yellow) { color: yellow }\n\n"
"00:00:01.000 --> 00:00:03.000\n"
"<c.bold.yellow>Hello</c>\n"
)
captions = self.reader.read(vtt)
styles = dict(captions.get_styles())

assert "bold.yellow" in styles
assert styles["bold.yellow"] == {"color": "yellow"}

def test_compound_selector_cascades_to_span(self):
vtt = (
"WEBVTT\n\n"
"STYLE\n"
"::cue(.bold.yellow) { color: yellow }\n\n"
"00:00:01.000 --> 00:00:03.000\n"
"<c.bold.yellow>Hello</c>\n"
)
captions = self.reader.read(vtt)
cue = captions.get_captions("en-US")[0]

for node in cue.nodes:
if node.type_ == CaptionNode.STYLE and node.start:
assert node.content.get("color") == "yellow"
break

def test_compound_selector_no_partial_match(self):
vtt = (
"WEBVTT\n\n"
"STYLE\n"
"::cue(.bold.yellow) { color: yellow }\n\n"
"00:00:01.000 --> 00:00:03.000\n"
"<c.bold>Hello</c>\n"
)
captions = self.reader.read(vtt)
cue = captions.get_captions("en-US")[0]

for node in cue.nodes:
if node.type_ == CaptionNode.STYLE and node.start:
assert "color" not in node.content
break

def test_compound_selector_vtt_roundtrip(self):
from pycaption import WebVTTWriter

vtt = (
"WEBVTT\n\n"
"STYLE\n"
"::cue(.bold.yellow) { color: yellow }\n\n"
"00:00:01.000 --> 00:00:03.000\n"
"<c.bold.yellow>Hello</c>\n"
)
captions = self.reader.read(vtt)
result = WebVTTWriter().write(captions)

assert "::cue(.bold.yellow)" in result

def test_compound_selector_three_classes(self):
vtt = (
"WEBVTT\n\n"
"STYLE\n"
"::cue(.a.b.c) { color: red }\n\n"
"00:00:01.000 --> 00:00:03.000\n"
"<c.a.b.c>Hello</c>\n"
)
captions = self.reader.read(vtt)
cue = captions.get_captions("en-US")[0]

for node in cue.nodes:
if node.type_ == CaptionNode.STYLE and node.start:
assert node.content.get("color") == "red"
break

def test_compound_selector_superset_span(self):
vtt = (
"WEBVTT\n\n"
"STYLE\n"
"::cue(.bold.yellow) { color: gold }\n\n"
"00:00:01.000 --> 00:00:03.000\n"
"<c.bold.yellow.italic>Hello</c>\n"
)
captions = self.reader.read(vtt)
cue = captions.get_captions("en-US")[0]

for node in cue.nodes:
if node.type_ == CaptionNode.STYLE and node.start:
assert node.content.get("color") == "gold"
break

def test_compound_selector_overrides_single(self):
vtt = (
"WEBVTT\n\n"
"STYLE\n"
"::cue(.bold) { color: white }\n"
"::cue(.bold.yellow) { color: gold }\n\n"
"00:00:01.000 --> 00:00:03.000\n"
"<c.bold.yellow>Hello</c>\n"
)
captions = self.reader.read(vtt)
cue = captions.get_captions("en-US")[0]

for node in cue.nodes:
if node.type_ == CaptionNode.STYLE and node.start:
assert node.content.get("color") == "gold"
break

def test_cascade_specificity(self, sample_webvtt_with_style_block_cascade):
captions = self.reader.read(sample_webvtt_with_style_block_cascade)
cue = captions.get_captions("en-US")[0]
Expand Down
29 changes: 29 additions & 0 deletions tests/test_webvtt_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,22 @@ def test_font_family_to_dfxp(self):

assert 'tts:fontFamily="Arial"' in result

def test_quoted_font_family_to_valid_dfxp(self):
vtt = (
"WEBVTT\n\n"
"STYLE\n"
'::cue(.fancy) { font-family: "Helvetica Neue", Arial, sans-serif }\n\n'
"00:00:01.000 --> 00:00:03.000\n"
"<c.fancy>Hello styled</c>\n"
)
caption_set = WebVTTReader().read(vtt)
result = DFXPWriter().write(caption_set)

assert 'tts:fontFamily="Helvetica Neue, Arial, sans-serif"' in result
from lxml import etree

etree.fromstring(result.encode("utf-8"))

def test_font_size_to_dfxp(self):
vtt = (
"WEBVTT\n\n"
Expand Down Expand Up @@ -641,6 +657,19 @@ def test_line_height_to_dfxp(self):

assert 'tts:lineHeight="1.5"' in result

def test_compound_selector_to_dfxp(self):
vtt = (
"WEBVTT\n\n"
"STYLE\n"
"::cue(.bold.yellow) { color: yellow }\n\n"
"00:00:01.000 --> 00:00:03.000\n"
"<c.bold.yellow>Hello styled</c>\n"
)
caption_set = WebVTTReader().read(vtt)
result = DFXPWriter().write(caption_set)

assert 'tts:color="yellow"' in result

def test_classes_not_in_dfxp_output(self):
from lxml import etree

Expand Down
Loading