diff --git a/docs/changelog.rst b/docs/changelog.rst index b6025644..fc374bb9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,5 +1,12 @@ Changelog --------- +2.3.4 +^^^^^^ + - Auto-detect and repair double-encoded UTF-8 in all readers. When input + text has been misread as CP-1252 and re-encoded (e.g. ♪ stored as ♪), + ``BaseReader._decode_content()`` now reverses the corruption and logs a + warning. Clean UTF-8 input is never modified. + 2.3.3 ^^^^^^ - All readers (SCC, DFXP, WebVTT, SAMI, SRT, MicroDVD) now accept diff --git a/docs/conf.py b/docs/conf.py index 5bc3c371..7cf9d12d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -53,9 +53,9 @@ # built documents. # # The short X.Y version. -version = "2.3.3" +version = "2.3.4.dev1" # The full version, including alpha/beta/rc tags. -release = "2.3.3" +release = "2.3.4.dev1" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/pycaption/base.py b/pycaption/base.py index 62965b04..5cafdb21 100644 --- a/pycaption/base.py +++ b/pycaption/base.py @@ -5,12 +5,15 @@ CaptionConverter orchestrator and base classes for readers/writers. """ +import logging import os from datetime import timedelta from numbers import Number from .exceptions import CaptionReadError, CaptionReadTimingError, InvalidInputError +logger = logging.getLogger(__name__) + # `und` a special identifier for an undetermined language according to ISO 639-2 DEFAULT_LANGUAGE_CODE = os.getenv("PYCAPTION_DEFAULT_LANG", "und") @@ -79,9 +82,7 @@ def _decode_content(content): try: content = content.decode("utf-8-sig") except UnicodeDecodeError as e: - raise InvalidInputError( - f"Content is not valid UTF-8: {e}" - ) from e + raise InvalidInputError(f"Content is not valid UTF-8: {e}") from e elif isinstance(content, str): if content.startswith(""): content = content[1:] @@ -89,8 +90,23 @@ def _decode_content(content): raise InvalidInputError( "The content must be a unicode string or UTF-8 bytes." ) + content = BaseReader._repair_double_encoding(content) return content + @staticmethod + def _repair_double_encoding(text): + """Fix double-encoded UTF-8 (bytes misread as CP-1252).""" + try: + repaired = text.encode("cp1252").decode("utf-8") + except (UnicodeEncodeError, UnicodeDecodeError): + return text + if repaired != text: + logger.warning( + "Detected and repaired double-encoded UTF-8 in caption content" + ) + return repaired + return text + def detect(self, content): """Return True if content appears to be in this reader's format. @@ -396,7 +412,11 @@ class CaptionSet: """ def __init__( - self, captions, styles=None, layout_info=None, regions=None, + self, + captions, + styles=None, + layout_info=None, + regions=None, visual_alignment_default=None, ): """ diff --git a/setup.py b/setup.py index b409890c..398f31eb 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ setup( name="pycaption", - version="2.3.3", + version="2.3.4.dev1", description="Closed caption converter", long_description=open(README_PATH).read(), author="Joe Norton", diff --git a/tests/test_bytes_input.py b/tests/test_bytes_input.py new file mode 100644 index 00000000..2d292fe4 --- /dev/null +++ b/tests/test_bytes_input.py @@ -0,0 +1,161 @@ +"""Tests for bytes input support across all readers. + +All readers should accept bytes (raw file content) and decode as UTF-8 +internally, preventing the double-encoding gibberish that occurs when +callers decode with the wrong system encoding (e.g., cp1252). +""" + +from pycaption import ( + DFXPReader, + MicroDVDReader, + SAMIReader, + SCCReader, + SRTReader, + WebVTTReader, +) + + +class TestSRTReaderBytes: + def setup_class(self): + self.reader = SRTReader() + + def test_read_bytes_produces_same_result_as_str(self, sample_srt): + from_str = self.reader.read(sample_srt) + from_bytes = self.reader.read(sample_srt.encode("utf-8")) + str_captions = from_str.get_captions("en-US") + bytes_captions = from_bytes.get_captions("en-US") + assert len(str_captions) == len(bytes_captions) + for s, b in zip(str_captions, bytes_captions): + assert s.get_text() == b.get_text() + assert s.start == b.start + assert s.end == b.end + + def test_read_bytes_preserves_music_notes(self, sample_srt): + captions = self.reader.read(sample_srt.encode("utf-8")) + texts = [c.get_text() for c in captions.get_captions("en-US")] + assert any("♪" in t for t in texts) + + def test_read_bytes_with_bom(self, sample_srt): + content_with_bom = b"\xef\xbb\xbf" + sample_srt.encode("utf-8") + captions = self.reader.read(content_with_bom) + assert len(captions.get_captions("en-US")) == 7 + + def test_detect_bytes(self, sample_srt): + assert self.reader.detect(sample_srt.encode("utf-8")) is True + + +class TestWebVTTReaderBytes: + def setup_class(self): + self.reader = WebVTTReader() + + def test_read_bytes_produces_same_result_as_str(self, sample_webvtt): + from_str = self.reader.read(sample_webvtt) + from_bytes = self.reader.read(sample_webvtt.encode("utf-8")) + str_captions = from_str.get_captions("en-US") + bytes_captions = from_bytes.get_captions("en-US") + assert len(str_captions) == len(bytes_captions) + for s, b in zip(str_captions, bytes_captions): + assert s.get_text() == b.get_text() + + def test_read_bytes_with_bom(self, sample_webvtt): + content_with_bom = b"\xef\xbb\xbf" + sample_webvtt.encode("utf-8") + captions = self.reader.read(content_with_bom) + assert len(captions.get_captions("en-US")) > 0 + + def test_detect_bytes(self, sample_webvtt): + assert self.reader.detect(sample_webvtt.encode("utf-8")) is True + + +class TestDFXPReaderBytes: + def setup_class(self): + self.reader = DFXPReader() + + def test_read_bytes_produces_same_result_as_str(self, sample_dfxp): + from_str = self.reader.read(sample_dfxp) + from_bytes = self.reader.read(sample_dfxp.encode("utf-8")) + for lang in from_str.get_languages(): + str_captions = from_str.get_captions(lang) + bytes_captions = from_bytes.get_captions(lang) + assert len(str_captions) == len(bytes_captions) + for s, b in zip(str_captions, bytes_captions): + assert s.get_text() == b.get_text() + + def test_read_bytes_with_bom(self, sample_dfxp): + content_with_bom = b"\xef\xbb\xbf" + sample_dfxp.encode("utf-8") + captions = self.reader.read(content_with_bom) + assert not captions.is_empty() + + def test_detect_bytes(self, sample_dfxp): + assert self.reader.detect(sample_dfxp.encode("utf-8")) is True + + +class TestSAMIReaderBytes: + def setup_class(self): + self.reader = SAMIReader() + + def test_read_bytes_produces_same_result_as_str(self, sample_sami): + from_str = self.reader.read(sample_sami) + from_bytes = self.reader.read(sample_sami.encode("utf-8")) + for lang in from_str.get_languages(): + str_captions = from_str.get_captions(lang) + bytes_captions = from_bytes.get_captions(lang) + assert len(str_captions) == len(bytes_captions) + for s, b in zip(str_captions, bytes_captions): + assert s.get_text() == b.get_text() + + def test_read_bytes_preserves_music_notes(self, sample_sami): + captions = self.reader.read(sample_sami.encode("utf-8")) + langs = list(captions.get_languages()) + texts = [c.get_text() for c in captions.get_captions(langs[0])] + assert any("♪" in t for t in texts) + + def test_read_bytes_with_bom(self, sample_sami): + content_with_bom = b"\xef\xbb\xbf" + sample_sami.encode("utf-8") + captions = self.reader.read(content_with_bom) + assert not captions.is_empty() + + def test_detect_bytes(self, sample_sami): + assert self.reader.detect(sample_sami.encode("utf-8")) is True + + +class TestSCCReaderBytes: + def test_read_bytes_produces_same_result_as_str(self, sample_scc_pop_on): + from_str = SCCReader().read(sample_scc_pop_on) + from_bytes = SCCReader().read(sample_scc_pop_on.encode("utf-8")) + str_captions = from_str.get_captions("en-US") + bytes_captions = from_bytes.get_captions("en-US") + assert len(str_captions) == len(bytes_captions) + for s, b in zip(str_captions, bytes_captions): + assert s.get_text() == b.get_text() + + def test_read_bytes_with_bom(self, sample_scc_pop_on): + content_with_bom = b"\xef\xbb\xbf" + sample_scc_pop_on.encode("utf-8") + captions = SCCReader().read(content_with_bom) + assert len(captions.get_captions("en-US")) > 0 + + def test_detect_bytes(self, sample_scc_pop_on): + assert SCCReader().detect(sample_scc_pop_on.encode("utf-8")) is True + + +class TestMicroDVDReaderBytes: + def setup_class(self): + self.reader = MicroDVDReader() + + def test_read_bytes_produces_same_result_as_str(self, sample_microdvd): + from_str = self.reader.read(sample_microdvd) + from_bytes = self.reader.read(sample_microdvd.encode("utf-8")) + str_captions = from_str.get_captions("und") + bytes_captions = from_bytes.get_captions("und") + assert len(str_captions) == len(bytes_captions) + for s, b in zip(str_captions, bytes_captions): + assert s.get_text() == b.get_text() + + def test_read_bytes_with_bom(self, sample_microdvd): + content_with_bom = b"\xef\xbb\xbf" + sample_microdvd.encode("utf-8") + captions = self.reader.read(content_with_bom) + assert not captions.is_empty() + + def test_detect_bytes(self, sample_microdvd): + assert self.reader.detect(sample_microdvd.encode("utf-8")) is True + + diff --git a/tests/test_double_encoding.py b/tests/test_double_encoding.py new file mode 100644 index 00000000..2cad3d72 --- /dev/null +++ b/tests/test_double_encoding.py @@ -0,0 +1,89 @@ +import logging + +import pytest + +from pycaption import SAMIReader, SRTReader +from pycaption.base import BaseReader +from pycaption.dfxp import DFXPReader + + +def _double_encode(text): + """Simulate double-encoding: UTF-8 bytes misread as CP-1252, re-encoded.""" + return text.encode("utf-8").decode("cp1252") + + +ORIGINAL_CHARS = ["♪", "—", "’", "é"] + + +class TestRepairDoubleEncoding: + @pytest.mark.parametrize("original", ORIGINAL_CHARS) + def test_fixes_double_encoded(self, original): + garbled = _double_encode(original) + assert BaseReader._repair_double_encoding(garbled) == original + + def test_leaves_clean_utf8_alone(self): + clean = "♪ This is — perfectly fine é text" + assert BaseReader._repair_double_encoding(clean) == clean + + def test_logs_warning_on_repair(self, caplog): + garbled = _double_encode("♪") + with caplog.at_level(logging.WARNING, logger="pycaption.base"): + BaseReader._repair_double_encoding(garbled) + assert "double-encoded" in caplog.text.lower() + + def test_no_warning_for_clean_input(self, caplog): + with caplog.at_level(logging.WARNING, logger="pycaption.base"): + BaseReader._repair_double_encoding("♪ Music ♪") + assert caplog.text == "" + + +class TestDoubleEncodingEndToEnd: + def test_srt_reader(self): + garbled_note = _double_encode("♪") + content = ( + "1\n" + "00:00:01,000 --> 00:00:02,000\n" + f"{garbled_note} Music {garbled_note}\n" + ) + captions = SRTReader().read(content) + nodes = captions.get_captions("en-US")[0].nodes + text = "".join(n.content for n in nodes) + assert "♪" in text + assert garbled_note not in text + + def test_dfxp_reader(self): + garbled = _double_encode("élève") + content = ( + '\n' + '\n' + "
\n" + f'

' + f"{garbled}

\n" + "
\n" + "
\n" + ) + captions = DFXPReader().read(content) + nodes = captions.get_captions("en")[0].nodes + text = "".join(n.content for n in nodes) + assert "élève" in text + assert garbled not in text + + def test_sami_reader(self): + garbled_dash = _double_encode("—") + content = ( + "\n" + "\n" + " \n" + f"

{garbled_dash} Hello

\n" + "
\n" + " \n" + "

 

\n" + "
\n" + "\n" + "
\n" + ) + captions = SAMIReader().read(content) + lang = list(captions.get_languages())[0] + nodes = captions.get_captions(lang)[0].nodes + text = "".join(n.content for n in nodes) + assert "—" in text