diff --git a/pycaption/geometry.py b/pycaption/geometry.py index f5cc8b07..1ac0cfd1 100644 --- a/pycaption/geometry.py +++ b/pycaption/geometry.py @@ -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 @@ -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 @@ -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']: diff --git a/pycaption/subtitler_image_based.py b/pycaption/subtitler_image_based.py index 28c66ba6..91306667 100644 --- a/pycaption/subtitler_image_based.py +++ b/pycaption/subtitler_image_based.py @@ -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. @@ -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 diff --git a/pycaption/webvtt.py b/pycaption/webvtt.py index 1b98cd39..856aaf8a 100644 --- a/pycaption/webvtt.py +++ b/pycaption/webvtt.py @@ -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) @@ -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 diff --git a/tests/test_subtitler_image_based.py b/tests/test_subtitler_image_based.py index da81acc9..2727c75f 100644 --- a/tests/test_subtitler_image_based.py +++ b/tests/test_subtitler_image_based.py @@ -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}") diff --git a/tests/test_webvtt.py b/tests/test_webvtt.py index 87739cb3..ece1537c 100644 --- a/tests/test_webvtt.py +++ b/tests/test_webvtt.py @@ -4,6 +4,7 @@ WebVTTReader, WebVTTWriter, SAMIReader, DFXPReader, CaptionReadNoCaptions, CaptionReadError, CaptionReadSyntaxError, ) +from pycaption.geometry import HorizontalAlignmentEnum, UnitEnum, VerticalAlignmentEnum from tests.mixins import ReaderTestingMixIn @@ -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):