diff --git a/CHANGELOG.rst b/CHANGELOG.rst index db17ef752..4a481de97 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -44,6 +44,7 @@ Features: Fixes: - Support slicing ``Container.streams``, which raised ``TypeError`` despite being typed. +- ``AssSubtitle.dialogue`` now strips override tags such as ``{\b1}``, turns ``\n`` and ``\h`` into spaces, and keeps a ``{`` with no closing ``}`` instead of dropping the rest of the line. - ``HWConfig.methods`` no longer raises ``ValueError`` for configs that support several methods; ``HWConfigMethod`` is now an ``IntFlag``. - ``CodecContext.is_encoder``, ``CodecContext.is_decoder``, ``Container.input_was_opened``, and ``VideoFormatComponent.is_chroma`` now return ``bool`` instead of ``int``. - ``Frame.time_base`` returns ``AVRational(0, 1)`` instead of ``None`` when unset, like other ``AVRational`` properties. diff --git a/av/subtitles/subtitle.py b/av/subtitles/subtitle.py index 2e45b4e3a..bd2629fa4 100644 --- a/av/subtitles/subtitle.py +++ b/av/subtitles/subtitle.py @@ -291,15 +291,21 @@ def ass(self): @property def dialogue(self): """ - Extract the dialogue from the ass format. Strip comments. + Extract the plain text from the ASS dialogue line. + + Override tags and comments (``{...}`` blocks) are removed, ``\\N`` + becomes a newline, and ``\\n`` and ``\\h`` become a space. A ``{`` + with no closing ``}`` is kept as literal text. """ + ass_text: bytes = self.ass + text_len: cython.Py_ssize_t = len(ass_text) + # A '{' only opens a block if a '}' appears somewhere after it. + last_close: cython.Py_ssize_t = ass_text.rfind(b"}") comma_count: cython.short = 0 i: cython.Py_ssize_t = 0 - state: cython.bint = False - ass_text: bytes = self.ass + in_block: cython.bint = False char, next_char = cython.declare(cython.uchar) result: bytearray = bytearray() - text_len: cython.Py_ssize_t = len(ass_text) while comma_count < 8 and i < text_len: if ass_text[i] == b","[0]: @@ -310,18 +316,21 @@ def dialogue(self): char = ass_text[i] next_char = 0 if i + 1 >= text_len else ass_text[i + 1] - if char == b"\\"[0] and next_char == b"N"[0]: - result.append(b"\n"[0]) - i += 2 - continue - - if not state: - if char == b"{"[0] and next_char != b"\\"[0]: - state = True + if not in_block: + if char == b"\\"[0] and next_char == b"N"[0]: + result.append(b"\n"[0]) + i += 2 + continue + if char == b"\\"[0] and (next_char == b"n"[0] or next_char == b"h"[0]): + result.append(b" "[0]) + i += 2 + continue + if char == b"{"[0] and i < last_close: + in_block = True else: result.append(char) elif char == b"}"[0]: - state = False + in_block = False i += 1 return bytes(result) diff --git a/tests/test_subtitles.py b/tests/test_subtitles.py index b5b70746a..51cf3f3b2 100644 --- a/tests/test_subtitles.py +++ b/tests/test_subtitles.py @@ -154,6 +154,28 @@ def test_subtitle_dialogue_extended_chars(self) -> None: assert sub.dialogue == "♪ Hey, hey, hey ♪".encode() + def test_subtitle_dialogue(self) -> None: + from av.subtitles.subtitle import SubtitleSet + + def dialogue(text: bytes) -> bytes: + subtitle = SubtitleSet.create(text=text, start=0, end=2000) + return cast(AssSubtitle, subtitle[0]).dialogue + + prefix = b"0,0,Default,,0,0,0,," + # Override tags and comments are stripped. + assert dialogue(prefix + b"{\\b1}bold{\\b0}") == b"bold" + assert dialogue(prefix + b"{\\an8}top") == b"top" + assert dialogue(prefix + b"{\\fnArial}hi") == b"hi" + assert dialogue(prefix + b"a{comment}b") == b"ab" + # Text escapes: \N -> newline, \n and \h -> space. + assert dialogue(prefix + b"a\\Nb") == b"a\nb" + assert dialogue(prefix + b"a\\nb") == b"a b" + assert dialogue(prefix + b"a\\hb") == b"a b" + # A stray '{' with no closing '}' is literal text. + assert dialogue(prefix + b"cost is {5") == b"cost is {5" + # Commas in the Text field are kept. + assert dialogue(prefix + b"x, y, z") == b"x, y, z" + def test_subtitle_encode_mp4(self) -> None: """Test encoding subtitles to MP4 container.""" from av.subtitles.subtitle import SubtitleSet