diff --git a/docs/changelog.rst b/docs/changelog.rst
index 728542e4..6f9535b5 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -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
diff --git a/pycaption/dfxp/writer.py b/pycaption/dfxp/writer.py
index c17219b1..c1e9aeea 100644
--- a/pycaption/dfxp/writer.py
+++ b/pycaption/dfxp/writer.py
@@ -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):
diff --git a/pycaption/webvtt/constants.py b/pycaption/webvtt/constants.py
index 8c4ea59b..c5626bb5 100644
--- a/pycaption/webvtt/constants.py
+++ b/pycaption/webvtt/constants.py
@@ -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
diff --git a/pycaption/webvtt/reader.py b/pycaption/webvtt/reader.py
index 3235938b..1a58b907 100644
--- a/pycaption/webvtt/reader.py
+++ b/pycaption/webvtt/reader.py
@@ -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":
@@ -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.
@@ -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
diff --git a/pycaption/webvtt/writer.py b/pycaption/webvtt/writer.py
index 5e7e7bba..b7f5273a 100644
--- a/pycaption/webvtt/writer.py
+++ b/pycaption/webvtt/writer.py
@@ -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": ("", ""),
"underline": ("", ""),
diff --git a/tests/test_sami.py b/tests/test_sami.py
index b7461722..f700e5e2 100644
--- a/tests/test_sami.py
+++ b/tests/test_sami.py
@@ -123,11 +123,11 @@ def test_sami_with_invalid_inline_style(
def test_text_align_recognized_after_other_property(self):
sami = (
- "\n"
- "\n"
+ '\n'
''
"Hello
\n"
""
diff --git a/tests/test_webvtt.py b/tests/test_webvtt.py
index d928fb77..132b380b 100644
--- a/tests/test_webvtt.py
+++ b/tests/test_webvtt.py
@@ -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"
+ "Hello\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 = (
@@ -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"
+ "Hello\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"
+ "Hello\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"
+ "Hello\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"
+ "Hello\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"
+ "Hello\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"
+ "Hello\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"
+ "Hello\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]
diff --git a/tests/test_webvtt_conversion.py b/tests/test_webvtt_conversion.py
index f7e1f070..0c40f898 100644
--- a/tests/test_webvtt_conversion.py
+++ b/tests/test_webvtt_conversion.py
@@ -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"
+ "Hello styled\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"
@@ -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"
+ "Hello styled\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