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
13 changes: 10 additions & 3 deletions pycaption/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,8 @@ class Layout:
"""

def __init__(self, origin=None, extent=None, padding=None, alignment=None,
webvtt_positioning=None, inherit_from=None):
webvtt_positioning=None, inherit_from=None,
center_horizontal=False):
"""
:type origin: Point
:param origin: The point on the screen which is the top left vertex
Expand All @@ -730,8 +731,13 @@ def __init__(self, origin=None, extent=None, padding=None, alignment=None,
:type webvtt_positioning: str
:param webvtt_positioning: A string with the raw WebVTT cue settings.
This is used so that WebVTT positioning isn't lost on conversion
from WebVTT to WebVTT. It is needed only because pycaption
currently doesn't support reading positioning from WebVTT.
from WebVTT to WebVTT.

:type center_horizontal: bool
:param center_horizontal: When True, image-based writers should centre
the text horizontally rather than using origin.x as a left anchor.
Set by the WebVTT reader for cues that carry a ``line:XX%`` setting
but no explicit ``position:XX%``, matching default browser behaviour.

:type inherit_from: Layout
:param inherit_from: A Layout with the positioning parameters to be
Expand All @@ -743,6 +749,7 @@ def __init__(self, origin=None, extent=None, padding=None, alignment=None,
self.padding = padding
self.alignment = alignment
self.webvtt_positioning = webvtt_positioning
self.center_horizontal = center_horizontal

if inherit_from:
for attr_name in ['origin', 'extent', 'padding', 'alignment']:
Expand Down
26 changes: 16 additions & 10 deletions pycaption/subtitler_image_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,22 +246,25 @@ def printLine(self, draw: ImageDraw, caption_list: Caption, fnt: ImageFont, posi
# then stack lines downward. Fall back to bottom if position is unusable.
source_x = None
source_y = None
source_center_x = False
if position == 'source':
try:
first = flat_captions[0] if flat_captions else None
if first and first.layout_info:
x_ = first.layout_info.origin.x
y_ = first.layout_info.origin.y
if isinstance(x_, Size) and isinstance(y_, Size) \
and x_.unit == UnitEnum.PERCENT \
and y_.unit == UnitEnum.PERCENT:
source_x = self.video_width * (x_.value / 100)
if isinstance(y_, Size) and y_.unit == UnitEnum.PERCENT:
source_y = self.video_height * (y_.value / 100)
if y_.value > 70:
source_y -= 10
source_center_x = getattr(
first.layout_info, 'center_horizontal', False)
if not source_center_x:
x_ = first.layout_info.origin.x
if isinstance(x_, Size) and x_.unit == UnitEnum.PERCENT:
source_x = self.video_width * (x_.value / 100)
except Exception:
pass
if source_x is None:
if source_y is None or (not source_center_x and source_x is None):
position = 'bottom'

# Source mode: iterate top-to-bottom, stacking lines downward.
Expand All @@ -273,10 +276,13 @@ def printLine(self, draw: ImageDraw, caption_list: Caption, fnt: ImageFont, posi
l, t, r, b = draw.textbbox((0, 0), text, font=fnt, align=align)

if position == 'source':
x = source_x
# make sure the text doesn't go out of the screen
if x + r > self.video_width:
x = float(self.video_width) - float(r) - float(10)
if source_center_x:
x = self.video_width / 2 - r / 2
else:
x = source_x
# make sure the text doesn't go out of the screen
if x + r > self.video_width:
x = float(self.video_width) - float(r) - float(10)
y = source_y + lines_written * line_spacing
else:
x = self.video_width / 2 - r / 2
Expand Down
73 changes: 71 additions & 2 deletions pycaption/webvtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
CaptionReadSyntaxError,
InvalidInputError,
)
from .geometry import HorizontalAlignmentEnum, Layout
from .geometry import (
Alignment, HorizontalAlignmentEnum, Layout, Point, Size, UnitEnum,
VerticalAlignmentEnum,
)

# A WebVTT timing line has both start/end times and layout related settings
# (referred to as 'cue settings' in the documentation)
Expand Down Expand Up @@ -148,10 +151,76 @@ def _parse_timing_line(self, line, last_start_time):

layout_info = None
if cue_settings:
layout_info = Layout(webvtt_positioning=cue_settings)
layout_info = self._parse_cue_settings(cue_settings)

return start, end, layout_info

def _parse_cue_settings(self, cue_settings):
"""Parse WebVTT cue settings into a Layout with structured origin/alignment.

Populates origin.y from ``line:XX%`` and origin.x from ``position:XX%``
(defaulting to a 10% left margin when position is absent). Also maps
``align`` to horizontal alignment. The raw string is kept in
webvtt_positioning so VTT→VTT round-trips are unaffected (the WebVTT
writer uses that field directly and never falls through to origin).
"""
_h_align_map = {
'left': HorizontalAlignmentEnum.LEFT,
'center': HorizontalAlignmentEnum.CENTER,
'right': HorizontalAlignmentEnum.RIGHT,
'start': HorizontalAlignmentEnum.START,
'end': HorizontalAlignmentEnum.END,
}

origin_x = None
origin_y = None
h_align = None

for setting in cue_settings.split():
if ':' not in setting:
continue
key, _, value = setting.partition(':')

if key == 'line' and value.endswith('%'):
try:
origin_y = Size(float(value[:-1]), UnitEnum.PERCENT)
except ValueError:
pass
elif key == 'position' and value.endswith('%'):
try:
origin_x = Size(float(value[:-1]), UnitEnum.PERCENT)
except ValueError:
pass
elif key == 'align':
h_align = _h_align_map.get(value)

# When no explicit position:XX% is given, the browser default is to
# centre the cue box horizontally. We signal this with center_horizontal
# so that image-based writers can replicate that behaviour instead of
# treating origin.x as a fixed left anchor.
center_horizontal = origin_y is not None and origin_x is None
origin = None
if origin_y is not None:
if origin_x is None:
origin_x = Size(0.0, UnitEnum.PERCENT) # placeholder; renderer centres instead
origin = Point(origin_x, origin_y)

alignment = None
if h_align is not None:
v_align = (
VerticalAlignmentEnum.TOP
if (origin_y is not None and origin_y.value <= 50)
else VerticalAlignmentEnum.BOTTOM
)
alignment = Alignment(h_align, v_align)

return Layout(
origin=origin,
alignment=alignment,
webvtt_positioning=cue_settings,
center_horizontal=center_horizontal,
)

def _parse_timestamp(self, timestamp):
"""Returns an integer representing a number of microseconds
:rtype: int
Expand Down
1 change: 1 addition & 0 deletions tests/test_subtitler_image_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def test_baseline_visual(self, combo, tmp_path):

out = tmp_path / f"baseline_{name}.png"
out = f"tests/baseline_samples/baseline_{name}.png"
os.makedirs("tests/baseline_samples", exist_ok=True)
img.save(str(out))
print(f"\nSaved: {out}")

57 changes: 57 additions & 0 deletions tests/test_webvtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
WebVTTReader, WebVTTWriter, SAMIReader, DFXPReader,
CaptionReadNoCaptions, CaptionReadError, CaptionReadSyntaxError,
)
from pycaption.geometry import HorizontalAlignmentEnum, UnitEnum, VerticalAlignmentEnum
from tests.mixins import ReaderTestingMixIn


Expand Down Expand Up @@ -158,6 +159,62 @@ def test_webvtt_empty_cue(self, sample_webvtt_empty_cue):
assert 1 == len(self.reader.read(
sample_webvtt_empty_cue).get_captions('en-US'))

def test_line_percent_sets_origin_y(self):
content = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:20%\nHello\n"
captions = self.reader.read(content)
cue = captions.get_captions('en-US')[0]

assert cue.layout_info is not None
assert cue.layout_info.origin is not None
assert cue.layout_info.origin.y.value == 20.0
assert cue.layout_info.origin.y.unit == UnitEnum.PERCENT

def test_line_percent_without_position_sets_center_horizontal(self):
content = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:20%\nHello\n"
captions = self.reader.read(content)
cue = captions.get_captions('en-US')[0]

assert cue.layout_info.center_horizontal is True

def test_position_percent_sets_origin_x_and_disables_centering(self):
content = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:20% position:30%\nHello\n"
captions = self.reader.read(content)
cue = captions.get_captions('en-US')[0]

assert cue.layout_info.origin.x.value == 30.0
assert cue.layout_info.center_horizontal is False

def test_align_sets_horizontal_alignment(self):
content = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:20% align:center\nHello\n"
captions = self.reader.read(content)
cue = captions.get_captions('en-US')[0]

assert cue.layout_info.alignment is not None
assert cue.layout_info.alignment.horizontal == HorizontalAlignmentEnum.CENTER
assert cue.layout_info.alignment.vertical == VerticalAlignmentEnum.TOP

def test_webvtt_positioning_preserved_for_roundtrip(self):
content = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:20%\nHello\n\n"
captions = self.reader.read(content)
cue = captions.get_captions('en-US')[0]

assert cue.layout_info.webvtt_positioning == "line:20%"

def test_line_integer_does_not_set_origin(self):
# Integer line values (line count, not percent) must not populate origin
content = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:2\nHello\n"
captions = self.reader.read(content)
cue = captions.get_captions('en-US')[0]

assert cue.layout_info.origin is None

def test_webvtt_to_webvtt_roundtrip_preserves_line_setting(self):
content = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:20%\nHello\n\n"
caption_set = self.reader.read(content)
output = WebVTTWriter().write(caption_set)

assert "line:20%" in output


class TestWebVTTWriter:
def setup_method(self):
Expand Down
Loading