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
33 changes: 17 additions & 16 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^
Expand Down
2 changes: 2 additions & 0 deletions pycaption/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
CaptionReadError,
CaptionReadNoCaptions,
CaptionReadSyntaxError,
CaptionReadWarning,
)
from .microdvd import MicroDVDReader, MicroDVDWriter
from .sami import SAMIReader, SAMIWriter
Expand Down Expand Up @@ -32,6 +33,7 @@
"CaptionReadError",
"CaptionReadNoCaptions",
"CaptionReadSyntaxError",
"CaptionReadWarning",
"detect_format",
"CaptionNode",
"Caption",
Expand Down
12 changes: 11 additions & 1 deletion pycaption/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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())])

Expand Down
5 changes: 5 additions & 0 deletions pycaption/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
109 changes: 102 additions & 7 deletions pycaption/webvtt/reader.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = []
Expand All @@ -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.")
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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. <i>...</i>.
"""
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
23 changes: 23 additions & 0 deletions pycaption/webvtt/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading