From c35775285be24ac420a3dbcce8e1787df9fdaace Mon Sep 17 00:00:00 2001 From: chartelt Date: Thu, 25 Jun 2026 11:07:17 +0200 Subject: [PATCH 1/3] VTK-3309: parse VTT cue line/position/align into Layout origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebVTT reader was storing cue settings (e.g. `line:20%`) only as the raw `webvtt_positioning` string on Layout, leaving `origin` unpopulated. The SST/TTML writer's source-position mode reads `layout_info.origin.x/y` and silently fell back to bottom when those were None. Add `_parse_cue_settings` to `WebVTTReader`: - `line:XX%` → `origin.y = Size(XX, PERCENT)` - `position:XX%` → `origin.x = Size(XX, PERCENT)` (default 10% when absent) - `align:XX` → `alignment.horizontal` `webvtt_positioning` is still set so VTT→VTT round-trips are unaffected (the WebVTT writer prioritises that field and never falls through to origin). Co-Authored-By: Claude Sonnet 4.6 --- pycaption/webvtt.py | 63 ++++++++++++++++++++++++++++++++++++++++++-- tests/test_webvtt.py | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/pycaption/webvtt.py b/pycaption/webvtt.py index 1b98cd39..dc56c90d 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,66 @@ 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) + + origin = None + if origin_y is not None: + if origin_x is None: + origin_x = Size(10.0, UnitEnum.PERCENT) + 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) + def _parse_timestamp(self, timestamp): """Returns an integer representing a number of microseconds :rtype: int diff --git a/tests/test_webvtt.py b/tests/test_webvtt.py index 87739cb3..894437ed 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_defaults_origin_x_to_10_percent(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.origin.x.value == 10.0 + assert cue.layout_info.origin.x.unit == UnitEnum.PERCENT + + def test_position_percent_sets_origin_x(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 + + 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): From 6a3f0d578ed22389ea417172a9c2636c76363b86 Mon Sep 17 00:00:00 2001 From: chartelt Date: Thu, 25 Jun 2026 11:56:55 +0200 Subject: [PATCH 2/3] VTK-3309: centre VTT source-mode text horizontally when no position: is set When a WebVTT cue has `line:XX%` but no explicit `position:XX%`, browsers default to centring the cue box. The previous fix left-anchored text at 0% because the image renderer used origin.x as a left edge. Add `center_horizontal` flag to Layout (default False). The WebVTT reader sets it True when `line:XX%` is present without an explicit `position:XX%`. The image renderer then centres each line of text instead of left-anchoring, matching browser display behaviour. Co-Authored-By: Claude Sonnet 4.6 --- pycaption/geometry.py | 13 ++++++++++--- pycaption/subtitler_image_based.py | 26 ++++++++++++++++---------- pycaption/webvtt.py | 14 ++++++++++++-- tests/test_webvtt.py | 8 ++++---- 4 files changed, 42 insertions(+), 19 deletions(-) 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 dc56c90d..856aaf8a 100644 --- a/pycaption/webvtt.py +++ b/pycaption/webvtt.py @@ -194,10 +194,15 @@ def _parse_cue_settings(self, cue_settings): 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(10.0, UnitEnum.PERCENT) + origin_x = Size(0.0, UnitEnum.PERCENT) # placeholder; renderer centres instead origin = Point(origin_x, origin_y) alignment = None @@ -209,7 +214,12 @@ def _parse_cue_settings(self, cue_settings): ) alignment = Alignment(h_align, v_align) - return Layout(origin=origin, alignment=alignment, webvtt_positioning=cue_settings) + 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 diff --git a/tests/test_webvtt.py b/tests/test_webvtt.py index 894437ed..ece1537c 100644 --- a/tests/test_webvtt.py +++ b/tests/test_webvtt.py @@ -169,20 +169,20 @@ def test_line_percent_sets_origin_y(self): assert cue.layout_info.origin.y.value == 20.0 assert cue.layout_info.origin.y.unit == UnitEnum.PERCENT - def test_line_percent_defaults_origin_x_to_10_percent(self): + 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.origin.x.value == 10.0 - assert cue.layout_info.origin.x.unit == UnitEnum.PERCENT + assert cue.layout_info.center_horizontal is True - def test_position_percent_sets_origin_x(self): + 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" From 9d1897feac8fb26261171279b57a32220f728300 Mon Sep 17 00:00:00 2001 From: chartelt Date: Thu, 25 Jun 2026 14:44:47 +0200 Subject: [PATCH 3/3] fix tests/test_subtitler_image_based.py --- tests/test_subtitler_image_based.py | 1 + 1 file changed, 1 insertion(+) 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}")