From 055fca4a5dc16786b8fcca61f5e2f0e76719de16 Mon Sep 17 00:00:00 2001 From: OlteanuRares Date: Tue, 14 Jul 2026 15:58:48 +0300 Subject: [PATCH] =?UTF-8?q?OCTO-11513=20=20-=20Reader=20wraps=20bare=20tex?= =?UTF-8?q?t=20in=20STYLE=20nodes=20when=20::cue=20declares=20italic/bold/?= =?UTF-8?q?underline=20=20=20-=20Emit=20CaptionReadWarning=20when=20multil?= =?UTF-8?q?ine=20cue=20line:%=20extends=20beyond=20viewport=20=20=20-=20Pr?= =?UTF-8?q?eserve=20REGION=20blocks=20through=20VTT=E2=86=92VTT=20read/wri?= =?UTF-8?q?te=20cycle=20=20=20-=20Add=20regions=20param=20to=20CaptionSet?= =?UTF-8?q?=20with=20get/set=20accessors=20=20=20-=20Compact=20changelog?= =?UTF-8?q?=20for=202.2.29?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.rst | 33 +++-- pycaption/__init__.py | 2 + pycaption/base.py | 12 +- pycaption/exceptions.py | 5 + pycaption/webvtt/reader.py | 109 +++++++++++++- pycaption/webvtt/writer.py | 23 +++ tests/test_webvtt.py | 297 +++++++++++++++++++++++++++++++++++++ 7 files changed, 457 insertions(+), 24 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f573fbf9..88d85242 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,23 +2,24 @@ Changelog --------- 2.2.29 ^^^^^^ - - Re-emit STYLE blocks on WebVTT→WebVTT conversion. When the - CaptionSet has ::cue-prefixed styles, emit them as a STYLE section - between the WEBVTT header and the first cue. Global ::cue rules are - emitted before class-specific rules. - - Emit writing direction from Layout in _convert_positioning(). When - layout.writing_direction is set and non-HORIZONTAL, emit vertical:rl - or vertical:lr in the cue settings string (only when - webvtt_positioning passthrough isn't available). - - Preserve writing_direction through Layout.as_percentage_of() and - Layout.fit_to_screen() so it survives intermediate transformations. - - Fix _format_css_declarations to reverse-map internal style keys - (italics → font-style: italic, bold → font-weight: bold, - underline → text-decoration: underline) instead of emitting invalid - CSS like "italics: True". + - Re-emit STYLE blocks on WebVTT→WebVTT roundtrip (global ::cue + rules before class-specific rules). + - Emit writing direction (vertical:rl|lr) from Layout when + webvtt_positioning passthrough isn't available; preserve it through + as_percentage_of() and fit_to_screen(). + - Fix _format_css_declarations to reverse-map internal keys + (italics → font-style: italic, etc.) instead of emitting invalid CSS. - Split pycaption/webvtt.py into pycaption/webvtt/ package - (reader.py, writer.py, constants.py) for maintainability. Public - API unchanged. + (reader.py, writer.py, constants.py). Public API unchanged. + - Propagate ::cue base text-decoration (italic/bold/underline) onto + bare text as STYLE node wrappers so all writers emit inline + formatting. Already-styled cues are not double-wrapped. + - Add CaptionReadWarning (non-fatal) when a multiline cue's line:% + would place text partially off-screen. + - Preserve REGION blocks through VTT→VTT roundtrip via raw settings + on CaptionSet; region:id cue references stay valid. + - Add ``regions`` param to CaptionSet (defaults to {}) with + get_regions()/set_regions(). 2.2.28 ^^^^^^ diff --git a/pycaption/__init__.py b/pycaption/__init__.py index 3e60702f..5fc0eb7e 100644 --- a/pycaption/__init__.py +++ b/pycaption/__init__.py @@ -5,6 +5,7 @@ CaptionReadError, CaptionReadNoCaptions, CaptionReadSyntaxError, + CaptionReadWarning, ) from .microdvd import MicroDVDReader, MicroDVDWriter from .sami import SAMIReader, SAMIWriter @@ -32,6 +33,7 @@ "CaptionReadError", "CaptionReadNoCaptions", "CaptionReadSyntaxError", + "CaptionReadWarning", "detect_format", "CaptionNode", "Caption", diff --git a/pycaption/base.py b/pycaption/base.py index 1fa77895..4534104e 100644 --- a/pycaption/base.py +++ b/pycaption/base.py @@ -306,14 +306,16 @@ class CaptionSet: by all the children. """ - def __init__(self, captions, styles={}, layout_info=None): + def __init__(self, captions, styles={}, layout_info=None, regions=None): """ :param captions: A dictionary of the format {'language': CaptionList} :param styles: A dictionary with CSS-like styling rules :param Layout layout_info: A Layout object with the positioning info + :param regions: A dictionary mapping region id to raw settings dict """ self._captions = captions self._styles = styles + self._regions = regions or {} self.layout_info = layout_info def set_captions(self, lang, captions): @@ -347,6 +349,14 @@ def get_styles(self): def set_styles(self, styles): self._styles = styles + def get_regions(self): + """Return raw region definitions {id: {setting: value}} for the + writer to re-emit REGION blocks.""" + return self._regions + + def set_regions(self, regions): + self._regions = regions + def is_empty(self): return all([len(captions) == 0 for captions in list(self._captions.values())]) diff --git a/pycaption/exceptions.py b/pycaption/exceptions.py index 7afcf2f3..93d2d5e9 100644 --- a/pycaption/exceptions.py +++ b/pycaption/exceptions.py @@ -42,3 +42,8 @@ class CaptionLineLengthError(CaptionReadError): """ Error raised when a Caption has a line longer than 32 characters. """ + + +class CaptionReadWarning(UserWarning): + """Warning emitted when caption content is parseable but may cause + rendering issues (e.g. cue positioned partially off-screen).""" diff --git a/pycaption/webvtt/reader.py b/pycaption/webvtt/reader.py index 86c379e5..ec37a977 100644 --- a/pycaption/webvtt/reader.py +++ b/pycaption/webvtt/reader.py @@ -1,11 +1,13 @@ import html import sys +import warnings from ..base import BaseReader, Caption, CaptionList, CaptionNode, CaptionSet from ..exceptions import ( CaptionReadError, CaptionReadNoCaptions, CaptionReadSyntaxError, + CaptionReadWarning, InvalidInputError, ) from ..geometry import ( @@ -72,7 +74,12 @@ def read(self, content, lang="en-US"): styles = self._parse_style_blocks(lines) captions = self._parse(lines) self._resolve_cue_styles(captions, styles) - caption_set = CaptionSet({lang: captions}, styles=styles) + # regions_raw carries the original REGION settings so the writer + # can re-emit them (regions_layout is only used internally for + # position inheritance during parsing). + caption_set = CaptionSet( + {lang: captions}, styles=styles, regions=self._regions_raw + ) if caption_set.is_empty(): raise CaptionReadNoCaptions("empty caption file") @@ -108,7 +115,10 @@ def _parse(self, lines): in_note_block = False in_style_block = False - self._regions = self._parse_regions(lines) + # Two dicts: _regions has computed Layouts (for inherit_from when + # a cue references region:id), _regions_raw has the verbatim + # key:value pairs (for the writer to reproduce REGION blocks). + self._regions, self._regions_raw = self._parse_regions(lines) for i, line in enumerate(lines): if in_note_block: @@ -147,6 +157,7 @@ def _parse(self, lines): found_timing = False self._close_unclosed_tags(nodes, open_tags) caption = Caption(start, end, nodes, layout_info=layout_info) + self._check_line_overflow(caption) captions.append(caption) nodes = [] open_tags = [] @@ -159,10 +170,40 @@ def _parse(self, lines): if nodes: self._close_unclosed_tags(nodes, open_tags) caption = Caption(start, end, nodes, layout_info=layout_info) + self._check_line_overflow(caption) captions.append(caption) return captions + @staticmethod + def _check_line_overflow(caption): + """Emit a warning if a multiline cue's line:% would place text + partially off-screen. + + Uses WebVTT's 15-line grid (~6.67% per line) to estimate whether + the cue's starting position plus its line count exceeds 100% of + the viewport height. + """ + layout = caption.layout_info + if not layout or not layout.origin: + return + origin_y = layout.origin.y + if origin_y is None or origin_y.unit != UnitEnum.PERCENT: + return + line_pct = origin_y.value + if line_pct <= 0: + return + # BREAK nodes separate lines, so count + 1 = total lines + num_lines = sum(1 for n in caption.nodes if n.type_ == CaptionNode.BREAK) + 1 + line_height = 100.0 / LINE_GRID_SIZE + if line_pct + num_lines * line_height > 100.0: + warnings.warn( + f"Cue at {caption.format_start()} has line:{line_pct:.0f}% " + f"with {num_lines} lines — extends beyond viewport.", + CaptionReadWarning, + stacklevel=4, + ) + def _validate_timings(self, start, end, last_start_time): if start is None: raise CaptionReadSyntaxError("Invalid cue start timestamp.") @@ -415,9 +456,12 @@ def _parse_regions(self, lines): viewportanchor - anchor point on viewport as x%,y% (default: 0%,100%) scroll - scroll behavior, only "up" is valid (default: none) - :returns: dict mapping region id -> Layout + :returns: tuple (regions_layout, regions_raw) + regions_layout: dict mapping region id -> Layout (for inherit_from) + regions_raw: dict mapping region id -> {key: value} (for writer) """ - regions = {} + regions_layout = {} + regions_raw = {} in_note_block = False i = 0 while i < len(lines): @@ -447,13 +491,18 @@ def _parse_regions(self, lines): i += 1 if "id" in settings: region_id = settings["id"] - if region_id not in regions: - regions[region_id] = self._region_to_layout(settings) + if region_id not in regions_layout: + regions_layout[region_id] = self._region_to_layout(settings) + # Store raw settings (minus id) for the writer to + # reconstruct the REGION block verbatim. + regions_raw[region_id] = { + k: v for k, v in settings.items() if k != "id" + } elif "-->" in line: break else: i += 1 - return regions + return regions_layout, regions_raw def _region_to_layout(self, settings): """Convert parsed region settings dict into a Layout with origin/extent. @@ -734,3 +783,49 @@ def _resolve_cue_styles(captions, styles): for key, value in resolved.items(): if key not in content: content[key] = value + + # Wrap bare text in STYLE nodes when a ::cue base rule has + # text-decoration properties (italic/bold/underline). This ensures + # downstream writers (SCC, DFXP, VTT) all see the styling — they + # iterate caption.nodes looking for STYLE nodes to trigger + # formatting. Only text-style keys are used; color/background-color + # can't be represented as inline tags and stay in the STYLE block. + _TEXT_STYLE_KEYS = {"italics", "bold", "underline"} + base_text_props = { + k: v for k, v in base_style.items() if k in _TEXT_STYLE_KEYS and v + } + if base_text_props: + for caption in captions: + if WebVTTReader._caption_needs_base_wrap( + caption.nodes, base_text_props + ): + caption.nodes.insert( + 0, + CaptionNode.create_style(True, dict(base_text_props)), + ) + caption.nodes.append( + CaptionNode.create_style(False, dict(base_text_props)) + ) + + @staticmethod + def _caption_needs_base_wrap(nodes, base_props): + """Return True if any TEXT node is not fully covered by a STYLE + opener carrying all base_props. + + Tracks a "depth" of open STYLE nodes that carry the required + properties. A TEXT node at depth 0 means it's unstyled and needs + wrapping. This avoids double-wrapping captions that are already + entirely inside e.g. .... + """ + depth = 0 + for node in nodes: + if node.type_ == CaptionNode.STYLE: + if node.start: + if all(node.content.get(k) == v for k, v in base_props.items()): + depth += 1 + else: + if depth > 0: + depth -= 1 + elif node.type_ == CaptionNode.TEXT and depth == 0: + return True + return False diff --git a/pycaption/webvtt/writer.py b/pycaption/webvtt/writer.py index 0eb8eabb..0a33265e 100644 --- a/pycaption/webvtt/writer.py +++ b/pycaption/webvtt/writer.py @@ -50,6 +50,10 @@ def write(self, caption_set, lang=None): if style_block: output += style_block + region_blocks = self._build_region_blocks(caption_set) + if region_blocks: + output += region_blocks + if lang is None: lang = caption_set.get_languages()[0] @@ -118,6 +122,25 @@ def _build_style_block(self, caption_set): output += "\n" return output + @staticmethod + def _build_region_blocks(caption_set): + """Build REGION blocks from the raw settings stored by the reader. + + Reconstructs the original REGION text so that cue settings like + region:r1 remain valid references in the output. + """ + regions = caption_set.get_regions() + if not regions: + return "" + output = "" + for region_id, settings in regions.items(): + output += "REGION\n" + output += f"id:{region_id}\n" + for key, value in settings.items(): + output += f"{key}:{value}\n" + output += "\n" + return output + @classmethod def _format_css_declarations(cls, props): """Format a style dict as a CSS declaration string. diff --git a/tests/test_webvtt.py b/tests/test_webvtt.py index 4706c844..d9121516 100644 --- a/tests/test_webvtt.py +++ b/tests/test_webvtt.py @@ -1,9 +1,12 @@ +import warnings + import pytest from pycaption import ( CaptionReadError, CaptionReadNoCaptions, CaptionReadSyntaxError, + CaptionReadWarning, DFXPReader, SAMIReader, WebVTTReader, @@ -1008,3 +1011,297 @@ def test_unicode_not_normalized(self): text = captions.get_captions("en-US")[0].get_text() assert precomposed in text assert decomposed in text + + +class TestWebVTTCueBaseStylePropagation: + """Change 1: ::cue base style wraps bare text in STYLE nodes.""" + + def setup_method(self): + self.reader = WebVTTReader() + + def test_bare_text_gets_italic_from_base_cue(self): + vtt = ( + "WEBVTT\n\n" + "STYLE\n" + "::cue { font-style: italic }\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "Plain text\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "Plain text" in result + + def test_bare_text_gets_bold_underline_from_base_cue(self): + vtt = ( + "WEBVTT\n\n" + "STYLE\n" + "::cue { font-weight: bold; text-decoration: underline }\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "Text\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "" in result + assert "" in result + assert "" in result + assert "" in result + + def test_style_nodes_created_in_internal_model(self): + vtt = ( + "WEBVTT\n\n" + "STYLE\n" + "::cue { font-style: italic }\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "Hello\n" + ) + caption_set = self.reader.read(vtt) + nodes = caption_set.get_captions("en-US")[0].nodes + style_nodes = [n for n in nodes if n.type_ == CaptionNode.STYLE] + assert len(style_nodes) == 2 + assert style_nodes[0].start is True + assert style_nodes[0].content == {"italics": True} + assert style_nodes[1].start is False + + def test_no_double_wrap_when_already_italic(self): + vtt = ( + "WEBVTT\n\n" + "STYLE\n" + "::cue { font-style: italic }\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "Already italic\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert result.count("") == 1 + + def test_wraps_when_mixed_bare_and_span(self): + vtt = ( + "WEBVTT\n\n" + "STYLE\n" + "::cue { font-style: italic }\n" + "::cue(.yellow) { color: yellow }\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "Hello world\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "" in result + + def test_color_only_base_style_no_wrap(self): + vtt = ( + "WEBVTT\n\n" + "STYLE\n" + "::cue { color: white }\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "Plain text\n" + ) + caption_set = self.reader.read(vtt) + nodes = caption_set.get_captions("en-US")[0].nodes + style_nodes = [n for n in nodes if n.type_ == CaptionNode.STYLE] + assert len(style_nodes) == 0 + + +class TestWebVTTRoundtripPreservation: + """Change 2: VTT→VTT roundtrip preserves positioning and styles.""" + + def setup_method(self): + self.reader = WebVTTReader() + + def test_positioning_roundtrip(self): + vtt = ( + "WEBVTT\n\n" + "00:00:01.000 --> 00:00:03.000 line:50% align:start position:25%\n" + "Hello world\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "line:50%" in result + assert "align:start" in result + assert "position:25%" in result + + def test_inline_style_roundtrip(self): + vtt = ( + "WEBVTT\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "italic bold underline\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "italic" in result + assert "bold" in result + assert "underline" in result + + def test_style_block_roundtrip(self): + vtt = ( + "WEBVTT\n\n" + "STYLE\n" + "::cue { font-style: italic }\n" + "::cue(.yellow) { color: yellow }\n\n" + "00:00:01.000 --> 00:00:03.000 line:80%\n" + "highlighted\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "line:80%" in result + assert "::cue { font-style: italic }" in result + assert "::cue(.yellow)" in result + assert "color: yellow" in result + + def test_base_style_roundtrip_with_bare_text(self): + vtt = ( + "WEBVTT\n\n" + "STYLE\n" + "::cue { font-style: italic }\n\n" + "00:00:01.000 --> 00:00:03.000 line:50%\n" + "Hello world\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "line:50%" in result + assert "::cue { font-style: italic }" in result + assert "" in result + + +class TestWebVTTLineOverflowWarning: + """Change 3: Warn when line:% would place text off-screen.""" + + def setup_method(self): + self.reader = WebVTTReader() + + def test_warns_on_overflow(self): + vtt = ( + "WEBVTT\n\n" + "00:00:01.000 --> 00:00:03.000 line:90%\n" + "Line one\n" + "Line two\n" + "Line three\n" + ) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.reader.read(vtt) + caption_warnings = [ + x for x in w if issubclass(x.category, CaptionReadWarning) + ] + assert len(caption_warnings) == 1 + assert "90%" in str(caption_warnings[0].message) + assert "3 lines" in str(caption_warnings[0].message) + + def test_no_warning_within_bounds(self): + vtt = ( + "WEBVTT\n\n" + "00:00:01.000 --> 00:00:03.000 line:50%\n" + "Single line\n" + ) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.reader.read(vtt) + caption_warnings = [ + x for x in w if issubclass(x.category, CaptionReadWarning) + ] + assert len(caption_warnings) == 0 + + def test_no_warning_without_line_setting(self): + vtt = ( + "WEBVTT\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "Line one\n" + "Line two\n" + "Line three\n" + ) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.reader.read(vtt) + caption_warnings = [ + x for x in w if issubclass(x.category, CaptionReadWarning) + ] + assert len(caption_warnings) == 0 + + def test_no_warning_at_boundary(self): + # line:80% + 2 lines × 6.67% = 93.3% — within bounds + vtt = ( + "WEBVTT\n\n" + "00:00:01.000 --> 00:00:03.000 line:80%\n" + "Line one\n" + "Line two\n" + ) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.reader.read(vtt) + caption_warnings = [ + x for x in w if issubclass(x.category, CaptionReadWarning) + ] + assert len(caption_warnings) == 0 + + +class TestWebVTTRegionRoundtrip: + """Change 4: REGION blocks are preserved through read→write.""" + + def setup_method(self): + self.reader = WebVTTReader() + + def test_region_block_roundtrip(self): + vtt = ( + "WEBVTT\n\n" + "REGION\n" + "id:r1\n" + "width:40%\n" + "lines:3\n" + "regionanchor:0%,100%\n" + "viewportanchor:10%,90%\n" + "scroll:up\n\n" + "00:00:01.000 --> 00:00:03.000 region:r1\n" + "Hello\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "REGION" in result + assert "id:r1" in result + assert "width:40%" in result + assert "lines:3" in result + assert "regionanchor:0%,100%" in result + assert "viewportanchor:10%,90%" in result + assert "scroll:up" in result + + def test_multiple_regions(self): + vtt = ( + "WEBVTT\n\n" + "REGION\n" + "id:top\n" + "width:50%\n" + "lines:2\n\n" + "REGION\n" + "id:bottom\n" + "width:80%\n" + "lines:4\n\n" + "00:00:01.000 --> 00:00:03.000 region:top\n" + "Hello\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "id:top" in result + assert "id:bottom" in result + assert "width:50%" in result + assert "width:80%" in result + + def test_no_region_block_when_none_defined(self): + vtt = ( + "WEBVTT\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "Hello\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "REGION" not in result + + def test_region_reference_still_in_cue_settings(self): + vtt = ( + "WEBVTT\n\n" + "REGION\n" + "id:r1\n" + "width:40%\n\n" + "00:00:01.000 --> 00:00:03.000 region:r1\n" + "Hello\n" + ) + caption_set = self.reader.read(vtt) + result = WebVTTWriter().write(caption_set) + assert "region:r1" in result