diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 71d62a0d..4276b8f2 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -20,17 +20,25 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["py38", "py39", "py310", "py311", "py312"] + python-version: [ "py312"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Run Test id: tests run: | - ./run_tests.sh test_${{ matrix.python-version }} + ./run_tests.sh test_${{ matrix.python-version }} continue-on-error: true + - name: Test Report + uses: dorny/test-reporter@v1 + if: always() + with: + name: Test Results (${{ matrix.python-version }}) + path: junit.xml + reporter: java-junit + - name: Archive production artifacts uses: actions/upload-artifact@v4 with: diff --git a/docker-compose.yml b/docker-compose.yml index b342c7bb..616f6ab7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: test_py38: - image: python:3.8-slim-bullseye + image: python:3.8-slim-bookworm command: sh -c " cd pycaption; pip install --upgrade pip; @@ -14,7 +14,7 @@ services: - .:/pycaption test_py39: - image: python:3.9-slim-bullseye + image: python:3.9-slim-bookworm command: sh -c " cd pycaption; pip install --upgrade pip; @@ -26,7 +26,7 @@ services: - .:/pycaption test_py310: - image: python:3.10-slim-bullseye + image: python:3.10-slim-bookworm command: sh -c " cd pycaption; pip install --upgrade pip; @@ -38,7 +38,7 @@ services: - .:/pycaption test_py311: - image: python:3.11-slim-bullseye + image: python:3.11-slim-bookworm command: sh -c " cd pycaption; pip install --upgrade pip; @@ -50,7 +50,7 @@ services: - .:/pycaption test_py312: - image: python:3.12-slim-bullseye + image: python:3.12-slim-bookworm command: sh -c " cd pycaption; pip install --upgrade pip; diff --git a/pycaption/exceptions.py b/pycaption/exceptions.py index 0474c05d..bc5c8b4e 100644 --- a/pycaption/exceptions.py +++ b/pycaption/exceptions.py @@ -41,3 +41,10 @@ class CaptionLineLengthError(CaptionReadError): """ Error raised when a Caption has a line longer than 32 characters. """ + + +class CaptionRendererError(Exception): + """ + Error raised when caption content cannot be rendered correctly, + e.g. text runs off screen or required glyphs are missing from the font. + """ diff --git a/pycaption/srt.py b/pycaption/srt.py index b1770e79..ae338c04 100644 --- a/pycaption/srt.py +++ b/pycaption/srt.py @@ -1,4 +1,3 @@ -import os from copy import deepcopy from .base import ( @@ -7,7 +6,6 @@ from .exceptions import CaptionReadNoCaptions, InvalidInputError import re -from PIL import Image, ImageFont, ImageDraw import warnings warnings.simplefilter('once', DeprecationWarning) @@ -162,14 +160,6 @@ def _recreate_lang(self, captions, position='bottom'): srt = '' count = 1 - fnt = ImageFont.truetype(os.path.dirname(__file__) + '/NotoSansDisplay-Regular-Note-Math.ttf', 30) - - img = None - draw = None - if position == 'top': - img = Image.new('RGB', (self.video_width, self.video_height), (0, 255, 0)) - draw = ImageDraw.Draw(img) - for caption in captions: # Generate the text new_content = '' @@ -190,13 +180,19 @@ def _recreate_lang(self, captions, position='bottom'): # Use the old behavior, output just the timestamp, no coordinates. timestamp = '%s --> %s' % (start[:12], end[:12]) elif position == 'top': + # Approximate character dimensions for coordinate estimation. + # The player uses its own font so these are just reasonable hints. + char_width = round(self.video_width * 0.018) + line_height = round(self.video_height * 0.05) padding_top = 10 - l, t, r, b = draw.textbbox((0, 0), new_content, font=fnt) - l, t, r, b = draw.textbbox((self.video_width / 2 - r / 2, padding_top), new_content, font=fnt) - x1 = str(round(l)).zfill(3) - x2 = str(round(r)).zfill(3) - y1 = str(round(t)).zfill(3) - y2 = str(round(b)).zfill(3) + lines = new_content.split('\n') + max_line_len = max(len(line) for line in lines) + text_width = max_line_len * char_width + text_height = len(lines) * line_height + x1 = str(round(self.video_width / 2 - text_width / 2)).zfill(3) + x2 = str(round(self.video_width / 2 + text_width / 2)).zfill(3) + y1 = str(padding_top).zfill(3) + y2 = str(padding_top + text_height).zfill(3) timestamp = '%s --> %s X1:%s X2:%s Y1:%s Y2:%s' % (start[:12], end[:12], x1, x2, y1, y2) else: raise ValueError('Unsupported position: %s' % position) diff --git a/pycaption/subtitler_image_based.py b/pycaption/subtitler_image_based.py index 88eba71f..c30b01f2 100644 --- a/pycaption/subtitler_image_based.py +++ b/pycaption/subtitler_image_based.py @@ -10,6 +10,7 @@ from langcodes import Language, tag_distance from pycaption.base import BaseWriter, CaptionSet, Caption, CaptionNode, CaptionList +from pycaption.exceptions import CaptionRendererError from pycaption.geometry import UnitEnum, Size @@ -207,7 +208,7 @@ def write_images( missing_glyphs = self.get_missing_glyphs(fnt, self.get_characters(caps_final)) if missing_glyphs: - raise ValueError(f'Selected font was missing glyphs: {" ".join(missing_glyphs.keys())}') + raise CaptionRendererError(f'Selected font was missing glyphs: {" ".join(missing_glyphs.keys())}') min_font_px = 16 font_size = max(min_font_px, int(self.video_width * 0.05 * 0.6)) # rough estimate but should work @@ -277,6 +278,18 @@ def printLine(self, draw: ImageDraw, caption_list: Caption, fnt: ImageFont, posi else: raise ValueError('Unknown "position": {}'.format(position)) + # Check that text (including border outline) fits within the screen + border_offset = 1 # max adj value from border drawing loop + text_left = x - border_offset + l + text_top = y - border_offset + t + text_right = x + border_offset + r + text_bottom = y + border_offset + b + if text_left < 0 or text_top < 0 or text_right > self.video_width or text_bottom > self.video_height: + raise CaptionRendererError( + f'Text runs off screen: text="{text}"' + ) + + border = (*self.borderColor, 255) # Add alpha for RGBA font = (*self.fontColor, 255) # Add alpha for RGBA for adj in range(2): diff --git a/setup.py b/setup.py index 6c1d1e81..3a773083 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ "beautifulsoup4>=4.12.1", "lxml>=4.9.1", "cssutils>=2.0.0", - "Pillow~=11.1.0", + "Pillow~=12.1.0", "fonttools~=4.56.0", "langcodes~=3.5.0", "striprtf~=0.0.28" diff --git a/tests/conftest.py b/tests/conftest.py index 27ee6fd9..215b2a30 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -102,5 +102,5 @@ sample_webvtt_last_cue_zero_start, sample_webvtt_empty_cue, sample_webvtt_multi_lang_en, sample_webvtt_multi_lang_de, sample_webvtt_empty_cue_output, sample_webvtt_timestamps, - sample_srt_top + sample_srt_top, ) diff --git a/tests/fixtures/webvtt.py b/tests/fixtures/webvtt.py index 999e218d..9838a49e 100644 --- a/tests/fixtures/webvtt.py +++ b/tests/fixtures/webvtt.py @@ -3,37 +3,37 @@ @pytest.fixture(scope="session") def sample_srt_top(): return """1 -00:00:09,209 --> 00:00:12,312 X1:262 X2:458 Y1:020 Y2:051 +00:00:09,209 --> 00:00:12,312 X1:250 X2:470 Y1:010 Y2:034 ( clock ticking ) 2 -00:00:14,848 --> 00:00:17,000 X1:233 X2:487 Y1:021 Y2:125 -MAN: -When we think +00:00:14,848 --> 00:00:17,000 X1:230 X2:490 Y1:010 Y2:082 +MAN: +When we think ♪ ...say bow, wow, ♪ 3 -00:00:17,000 --> 00:00:18,752 X1:162 X2:558 Y1:020 Y2:044 +00:00:17,000 --> 00:00:18,752 X1:158 X2:562 Y1:010 Y2:034 we have this vision of Einstein 4 -00:00:18,752 --> 00:00:20,887 X1:208 X2:512 Y1:020 Y2:081 -as an old, wrinkly man +00:00:18,752 --> 00:00:20,887 X1:210 X2:510 Y1:010 Y2:058 +as an old, wrinkly man with white hair. 5 -00:00:20,887 --> 00:00:26,760 X1:190 X2:530 Y1:021 Y2:118 -MAN 2: -E equals m c-squared is +00:00:20,887 --> 00:00:26,760 X1:191 X2:529 Y1:010 Y2:082 +MAN 2: +E equals m c-squared is not about an old Einstein. 6 -00:00:26,760 --> 00:00:32,200 X1:147 X2:573 Y1:021 Y2:081 -MAN 2: +00:00:26,760 --> 00:00:32,200 X1:132 X2:588 Y1:010 Y2:058 +MAN 2: It's all about an eternal Einstein. 7 -00:00:32,200 --> 00:00:36,200 X1:187 X2:533 Y1:021 Y2:044 +00:00:32,200 --> 00:00:36,200 X1:230 X2:490 Y1:010 Y2:034 """ diff --git a/tests/test_srt_conversion.py b/tests/test_srt_conversion.py index ef495cfe..8128d826 100644 --- a/tests/test_srt_conversion.py +++ b/tests/test_srt_conversion.py @@ -67,7 +67,7 @@ def test_webvtt_to_srt_conversion(self, sample_srt, sample_webvtt): def test_webvtt_to_srt_conversion_pos_top(self, sample_srt_top, sample_webvtt): caption_set = WebVTTReader().read(sample_webvtt) - results = SRTWriter(video_width=720, video_height=480 ).write(caption_set, position='top') + results = SRTWriter(video_width=720, video_height=480).write(caption_set, position='top') assert isinstance(results, str) self.assert_srt_equals(sample_srt_top, results) diff --git a/tests/test_subtitler_image_based.py b/tests/test_subtitler_image_based.py new file mode 100644 index 00000000..fca70f7d --- /dev/null +++ b/tests/test_subtitler_image_based.py @@ -0,0 +1,106 @@ + +import os + +import pytest +from PIL import Image, ImageDraw, ImageFont + +from pycaption.base import Caption, CaptionNode +from pycaption.exceptions import CaptionRendererError +from pycaption.geometry import Layout, Point, Size, UnitEnum +from pycaption.subtitler_image_based import SubtitleImageBasedWriter + + +FONT_PATH = os.path.join( + os.path.dirname(__file__), + '..', 'pycaption', 'NotoSansDisplay-Regular-Note-Math.ttf' +) + + +def make_caption(text, layout_info=None): + nodes = [CaptionNode.create_text(text)] + return Caption(0, 1000000, nodes, layout_info=layout_info) + + +def make_source_layout(x_pct, y_pct): + origin = Point(Size(x_pct, UnitEnum.PERCENT), Size(y_pct, UnitEnum.PERCENT)) + return Layout(origin=origin) + + +def make_writer_and_draw(width, height): + writer = SubtitleImageBasedWriter(video_width=width, video_height=height) + img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + return writer, draw + + +class TestTextOffScreenCentered: + def test_short_text_fits(self): + writer, draw = make_writer_and_draw(720, 480) + fnt = ImageFont.truetype(FONT_PATH, 20) + caption = make_caption("Hello") + writer.printLine(draw, [caption], fnt, position='bottom', align='center') + + def test_long_text_runs_off(self): + writer, draw = make_writer_and_draw(200, 100) + fnt = ImageFont.truetype(FONT_PATH, 20) + caption = make_caption("This text is way too long to fit on a tiny screen") + with pytest.raises(CaptionRendererError, match="Text runs off screen"): + writer.printLine(draw, [caption], fnt, position='bottom', align='center') + + +class TestTextOffScreenLeft: + def test_short_text_fits(self): + writer, draw = make_writer_and_draw(720, 480) + fnt = ImageFont.truetype(FONT_PATH, 20) + caption = make_caption("Hello") + writer.printLine(draw, [caption], fnt, position='bottom', align='left') + + def test_long_text_runs_off(self): + writer, draw = make_writer_and_draw(200, 100) + fnt = ImageFont.truetype(FONT_PATH, 20) + caption = make_caption("This text is way too long to fit on a tiny screen") + with pytest.raises(CaptionRendererError, match="Text runs off screen"): + writer.printLine(draw, [caption], fnt, position='bottom', align='left') + + +class TestTextOffScreenRight: + def test_short_text_fits(self): + writer, draw = make_writer_and_draw(720, 480) + fnt = ImageFont.truetype(FONT_PATH, 20) + caption = make_caption("Hello") + writer.printLine(draw, [caption], fnt, position='bottom', align='right') + + def test_long_text_runs_off(self): + writer, draw = make_writer_and_draw(200, 100) + fnt = ImageFont.truetype(FONT_PATH, 20) + caption = make_caption("This text is way too long to fit on a tiny screen") + with pytest.raises(CaptionRendererError, match="Text runs off screen"): + writer.printLine(draw, [caption], fnt, position='bottom', align='right') + + +class TestTextOffScreenSourcePosition: + def test_centered_fits(self): + writer, draw = make_writer_and_draw(720, 480) + fnt = ImageFont.truetype(FONT_PATH, 20) + layout = make_source_layout(x_pct=30, y_pct=50) + caption = make_caption("Hello", layout_info=layout) + writer.printLine(draw, [caption], fnt, position='source', align='left') + + def test_right_sticks_out(self): + # 200px wide screen, text at x=80% (x=160), text wider than remaining space + writer, draw = make_writer_and_draw(200, 200) + fnt = ImageFont.truetype(FONT_PATH, 20) + layout = make_source_layout(x_pct=80, y_pct=50) + caption = make_caption("This text sticks out on the right", layout_info=layout) + with pytest.raises(CaptionRendererError, match="Text runs off screen"): + writer.printLine(draw, [caption], fnt, position='source', align='left') + + def test_left_sticks_out(self): + # 200px wide screen, text at x=10% (x=20), text wider than screen + # so repositioning pushes x past the left edge + writer, draw = make_writer_and_draw(200, 200) + fnt = ImageFont.truetype(FONT_PATH, 20) + layout = make_source_layout(x_pct=10, y_pct=50) + caption = make_caption("This text sticks out on the left", layout_info=layout) + with pytest.raises(CaptionRendererError, match="Text runs off screen"): + writer.printLine(draw, [caption], fnt, position='source', align='left') \ No newline at end of file