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
14 changes: 11 additions & 3 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions pycaption/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
28 changes: 12 additions & 16 deletions pycaption/srt.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os
from copy import deepcopy

from .base import (
Expand All @@ -7,7 +6,6 @@
from .exceptions import CaptionReadNoCaptions, InvalidInputError

import re
from PIL import Image, ImageFont, ImageDraw
import warnings

warnings.simplefilter('once', DeprecationWarning)
Expand Down Expand Up @@ -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 = ''
Expand All @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion pycaption/subtitler_image_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
26 changes: 13 additions & 13 deletions tests/fixtures/webvtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
<LAUGHING & WHOOPS!>
"""

Expand Down
2 changes: 1 addition & 1 deletion tests/test_srt_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
106 changes: 106 additions & 0 deletions tests/test_subtitler_image_based.py
Original file line number Diff line number Diff line change
@@ -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')