From 265eba52f904a74d83a5f736e5d995353e09e212 Mon Sep 17 00:00:00 2001 From: Sebastian Annies Date: Fri, 14 Aug 2026 12:28:22 +0200 Subject: [PATCH] Fix reversed line order for top-positioned multiline image subtitles Both 'top' and 'bottom' positions iterated caption lines in reverse, which is only correct for 'bottom' (it anchors the last line and stacks upward). 'top' stacks downward, so the reversed iteration rendered the first line below the second. Only reverse for 'bottom' now. Co-Authored-By: Claude Fable 5 --- pycaption/subtitler_image_based.py | 6 ++-- tests/test_subtitler_image_based.py | 45 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/pycaption/subtitler_image_based.py b/pycaption/subtitler_image_based.py index 68879ac6..e1479f43 100644 --- a/pycaption/subtitler_image_based.py +++ b/pycaption/subtitler_image_based.py @@ -276,9 +276,9 @@ def printLine(self, draw: ImageDraw, caption_list: Caption, fnt: ImageFont, posi 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. - # Bottom/top modes: iterate bottom-to-top (reversed), stacking upward. - caption_iter = flat_captions if position == 'source' else flat_captions[::-1] + # Bottom mode anchors the last line and stacks upward, so it needs the + # lines in reverse order. Top/source modes stack downward in source order. + caption_iter = flat_captions[::-1] if position == 'bottom' else flat_captions for lines_written, caption in enumerate(caption_iter): text = caption.get_text() diff --git a/tests/test_subtitler_image_based.py b/tests/test_subtitler_image_based.py index f0e46fa3..ed074a40 100644 --- a/tests/test_subtitler_image_based.py +++ b/tests/test_subtitler_image_based.py @@ -130,6 +130,51 @@ def test_exception_group(self, tmp_path): assert isinstance(rg.value, CaptionRendererErrorGroup), "Should be wrapped in a CaptionRendererErrorGroup" +class TestMultilineOrder: + """The first line of a multiline caption must be rendered above the + second line, regardless of whether the block is anchored top or bottom.""" + + NARROW = "II" + WIDE = "WWWWWWWWWW" + + @staticmethod + def _text_bands(img): + """Return the width of each vertical band of rendered pixels, + top to bottom. Bands are separated by fully transparent rows.""" + alpha = img.getchannel('A') + width, height = img.size + bands = [] + current = None + for y in range(height): + row = [x for x in range(width) if alpha.getpixel((x, y)) > 0] + if row: + if current is None: + current = [min(row), max(row)] + else: + current[0] = min(current[0], min(row)) + current[1] = max(current[1], max(row)) + elif current is not None: + bands.append(current[1] - current[0]) + current = None + if current is not None: + bands.append(current[1] - current[0]) + return bands + + @pytest.mark.parametrize('position', ['top', 'bottom']) + def test_first_line_is_above_second(self, position): + writer, draw = make_writer_and_draw(720, 480) + fnt = ImageFont.truetype(FONT_PATH, 28) + caption = make_caption(f"{self.NARROW}\n{self.WIDE}") + writer.printLine(draw, [caption], fnt, position=position, align='center') + + bands = self._text_bands(draw._image) + assert len(bands) == 2, "Expected two separate lines of text" + assert bands[0] < bands[1], ( + f"position={position}: narrow first line must be rendered " + f"above the wide second line" + ) + + class TestBaselineAlignment: """Render subtitle images with/without descenders to visually verify that the baseline sits at a consistent 5% from the bottom."""