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