From 77ba1b0bfaf8456f9bf6686ff5d585a483ac83c9 Mon Sep 17 00:00:00 2001 From: OlteanuRares Date: Mon, 17 Aug 2026 14:28:39 +0300 Subject: [PATCH 1/2] OCTO-11591 Parse compound class selectors (::cue(.a.b)) in WebVTTReader STYLE blocks --- docs/changelog.rst | 7 ++ pycaption/dfxp/writer.py | 4 +- pycaption/webvtt/constants.py | 4 +- pycaption/webvtt/reader.py | 10 ++- tests/test_sami.py | 4 +- tests/test_webvtt.py | 114 +++++++++++++++++++++++++++++++- tests/test_webvtt_conversion.py | 13 ++++ 7 files changed, 145 insertions(+), 11 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 728542e4..ea4b034e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,6 +30,13 @@ 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``. + 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..6f3a0cfe 100644 --- a/pycaption/webvtt/reader.py +++ b/pycaption/webvtt/reader.py @@ -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/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..1975f0b9 100644 --- a/tests/test_webvtt.py +++ b/tests/test_webvtt.py @@ -1086,9 +1086,7 @@ 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_font_size_parsed(self): vtt = ( @@ -1159,6 +1157,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..12863aca 100644 --- a/tests/test_webvtt_conversion.py +++ b/tests/test_webvtt_conversion.py @@ -641,6 +641,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 From 24d7a678cd87ca74a9660e4f698ea8606679e7a8 Mon Sep 17 00:00:00 2001 From: OlteanuRares Date: Mon, 17 Aug 2026 14:57:29 +0300 Subject: [PATCH 2/2] OCTO-11591 Fix quoted font-family names --- docs/changelog.rst | 4 ++++ pycaption/webvtt/reader.py | 2 +- pycaption/webvtt/writer.py | 8 ++++++++ tests/test_webvtt.py | 17 ++++++++++++++++- tests/test_webvtt_conversion.py | 16 ++++++++++++++++ 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ea4b034e..6f9535b5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,6 +37,10 @@ Changelog 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/webvtt/reader.py b/pycaption/webvtt/reader.py index 6f3a0cfe..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": 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_webvtt.py b/tests/test_webvtt.py index 1975f0b9..132b380b 100644 --- a/tests/test_webvtt.py +++ b/tests/test_webvtt.py @@ -1086,7 +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 = ( diff --git a/tests/test_webvtt_conversion.py b/tests/test_webvtt_conversion.py index 12863aca..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"