pycaption 2.3.2__tar.gz → 2.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {pycaption-2.3.2/pycaption.egg-info → pycaption-2.3.4}/PKG-INFO +1 -1
  2. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/base.py +48 -1
  3. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/dfxp/reader.py +3 -4
  4. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/microdvd.py +2 -3
  5. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/sami/reader.py +2 -3
  6. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/scc/reader.py +3 -4
  7. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/srt.py +3 -3
  8. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/webvtt/reader.py +2 -17
  9. {pycaption-2.3.2 → pycaption-2.3.4/pycaption.egg-info}/PKG-INFO +1 -1
  10. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption.egg-info/SOURCES.txt +2 -0
  11. {pycaption-2.3.2 → pycaption-2.3.4}/setup.py +1 -1
  12. {pycaption-2.3.2 → pycaption-2.3.4}/tests/mixins.py +12 -2
  13. pycaption-2.3.4/tests/test_bytes_input.py +161 -0
  14. pycaption-2.3.4/tests/test_double_encoding.py +89 -0
  15. {pycaption-2.3.2 → pycaption-2.3.4}/LICENSE +0 -0
  16. {pycaption-2.3.2 → pycaption-2.3.4}/MANIFEST.in +0 -0
  17. {pycaption-2.3.2 → pycaption-2.3.4}/README.rst +0 -0
  18. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/__init__.py +0 -0
  19. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/dfxp/__init__.py +0 -0
  20. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/dfxp/constants.py +0 -0
  21. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/dfxp/extras.py +0 -0
  22. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/dfxp/writer.py +0 -0
  23. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/exceptions.py +0 -0
  24. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/geometry.py +0 -0
  25. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/sami/__init__.py +0 -0
  26. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/sami/constants.py +0 -0
  27. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/sami/parser.py +0 -0
  28. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/sami/writer.py +0 -0
  29. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/scc/__init__.py +0 -0
  30. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/scc/constants.py +0 -0
  31. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/scc/specialized_collections.py +0 -0
  32. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/scc/state_machines.py +0 -0
  33. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/scc/translator.py +0 -0
  34. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/scc/writer.py +0 -0
  35. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/transcript.py +0 -0
  36. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/utils.py +0 -0
  37. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/webvtt/__init__.py +0 -0
  38. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/webvtt/constants.py +0 -0
  39. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption/webvtt/writer.py +0 -0
  40. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption.egg-info/dependency_links.txt +0 -0
  41. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption.egg-info/requires.txt +0 -0
  42. {pycaption-2.3.2 → pycaption-2.3.4}/pycaption.egg-info/top_level.txt +0 -0
  43. {pycaption-2.3.2 → pycaption-2.3.4}/setup.cfg +0 -0
  44. {pycaption-2.3.2 → pycaption-2.3.4}/tests/__init__.py +0 -0
  45. {pycaption-2.3.2 → pycaption-2.3.4}/tests/conftest.py +0 -0
  46. {pycaption-2.3.2 → pycaption-2.3.4}/tests/fixtures/__init__.py +0 -0
  47. {pycaption-2.3.2 → pycaption-2.3.4}/tests/fixtures/dfxp.py +0 -0
  48. {pycaption-2.3.2 → pycaption-2.3.4}/tests/fixtures/microdvd.py +0 -0
  49. {pycaption-2.3.2 → pycaption-2.3.4}/tests/fixtures/sami.py +0 -0
  50. {pycaption-2.3.2 → pycaption-2.3.4}/tests/fixtures/scc.py +0 -0
  51. {pycaption-2.3.2 → pycaption-2.3.4}/tests/fixtures/srt.py +0 -0
  52. {pycaption-2.3.2 → pycaption-2.3.4}/tests/fixtures/translated_scc.py +0 -0
  53. {pycaption-2.3.2 → pycaption-2.3.4}/tests/fixtures/webvtt.py +0 -0
  54. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_base.py +0 -0
  55. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_dfxp.py +0 -0
  56. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_dfxp_conversion.py +0 -0
  57. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_dfxp_extras.py +0 -0
  58. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_functions.py +0 -0
  59. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_geometry.py +0 -0
  60. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_microdvd.py +0 -0
  61. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_microdvd_conversion.py +0 -0
  62. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_sami.py +0 -0
  63. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_sami_conversion.py +0 -0
  64. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_scc.py +0 -0
  65. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_scc_conversion.py +0 -0
  66. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_scc_translator.py +0 -0
  67. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_scc_writer.py +0 -0
  68. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_srt.py +0 -0
  69. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_srt_conversion.py +0 -0
  70. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_webvtt.py +0 -0
  71. {pycaption-2.3.2 → pycaption-2.3.4}/tests/test_webvtt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.2
3
+ Version: 2.3.4
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -5,12 +5,15 @@ CaptionSet -> CaptionList -> Caption -> CaptionNode. Also provides the
5
5
  CaptionConverter orchestrator and base classes for readers/writers.
6
6
  """
7
7
 
8
+ import logging
8
9
  import os
9
10
  from datetime import timedelta
10
11
  from numbers import Number
11
12
 
12
13
  from .exceptions import CaptionReadError, CaptionReadTimingError, InvalidInputError
13
14
 
15
+ logger = logging.getLogger(__name__)
16
+
14
17
  # `und` a special identifier for an undetermined language according to ISO 639-2
15
18
  DEFAULT_LANGUAGE_CODE = os.getenv("PYCAPTION_DEFAULT_LANG", "und")
16
19
 
@@ -64,6 +67,46 @@ class BaseReader:
64
67
  def __init__(self, *args, **kwargs):
65
68
  pass
66
69
 
70
+ @staticmethod
71
+ def _decode_content(content):
72
+ """Decode bytes to str (UTF-8 with BOM handling).
73
+
74
+ :param content: str or bytes input.
75
+ :returns: decoded str with BOM stripped.
76
+ :raises InvalidInputError: if content is not str/bytes, is empty
77
+ bytes, or is not valid UTF-8.
78
+ """
79
+ if isinstance(content, bytes):
80
+ if not content:
81
+ raise InvalidInputError("The content is empty.")
82
+ try:
83
+ content = content.decode("utf-8-sig")
84
+ except UnicodeDecodeError as e:
85
+ raise InvalidInputError(f"Content is not valid UTF-8: {e}") from e
86
+ elif isinstance(content, str):
87
+ if content.startswith(""):
88
+ content = content[1:]
89
+ else:
90
+ raise InvalidInputError(
91
+ "The content must be a unicode string or UTF-8 bytes."
92
+ )
93
+ content = BaseReader._repair_double_encoding(content)
94
+ return content
95
+
96
+ @staticmethod
97
+ def _repair_double_encoding(text):
98
+ """Fix double-encoded UTF-8 (bytes misread as CP-1252)."""
99
+ try:
100
+ repaired = text.encode("cp1252").decode("utf-8")
101
+ except (UnicodeEncodeError, UnicodeDecodeError):
102
+ return text
103
+ if repaired != text:
104
+ logger.warning(
105
+ "Detected and repaired double-encoded UTF-8 in caption content"
106
+ )
107
+ return repaired
108
+ return text
109
+
67
110
  def detect(self, content):
68
111
  """Return True if content appears to be in this reader's format.
69
112
 
@@ -369,7 +412,11 @@ class CaptionSet:
369
412
  """
370
413
 
371
414
  def __init__(
372
- self, captions, styles=None, layout_info=None, regions=None,
415
+ self,
416
+ captions,
417
+ styles=None,
418
+ layout_info=None,
419
+ regions=None,
373
420
  visual_alignment_default=None,
374
421
  ):
375
422
  """
@@ -21,7 +21,6 @@ from ..exceptions import (
21
21
  CaptionReadNoCaptions,
22
22
  CaptionReadSyntaxError,
23
23
  CaptionReadTimingError,
24
- InvalidInputError,
25
24
  )
26
25
  from ..geometry import (
27
26
  Alignment,
@@ -108,9 +107,10 @@ class DFXPReader(BaseReader):
108
107
  def detect(self, content):
109
108
  """Return True if content looks like a DFXP/TTML document.
110
109
 
111
- :type content: str
110
+ :type content: str or bytes
112
111
  :rtype: bool
113
112
  """
113
+ content = self._decode_content(content)
114
114
  lowered = content.lower()
115
115
  return bool(re.search(r"<tt[\s>]", lowered)) and "</tt>" in lowered
116
116
 
@@ -122,8 +122,7 @@ class DFXPReader(BaseReader):
122
122
  :raises InvalidInputError: if content is not a string
123
123
  :raises CaptionReadNoCaptions: if no captions are found
124
124
  """
125
- if not isinstance(content, str):
126
- raise InvalidInputError("The content is not a unicode string.")
125
+ content = self._decode_content(content)
127
126
 
128
127
  dfxp_document = LayoutAwareDFXPParser(
129
128
  content, read_invalid_positioning=self.read_invalid_positioning
@@ -20,7 +20,6 @@ from .exceptions import (
20
20
  CaptionReadNoCaptions,
21
21
  CaptionReadSyntaxError,
22
22
  CaptionReadTimingError,
23
- InvalidInputError,
24
23
  )
25
24
  from .geometry import HorizontalAlignmentEnum
26
25
 
@@ -30,6 +29,7 @@ class MicroDVDReader(BaseReader):
30
29
 
31
30
  def detect(self, content):
32
31
  """Return True if content starts with MicroDVD frame markers."""
32
+ content = self._decode_content(content)
33
33
  return re.match(r"{\d+}{\d+}", content) is not None
34
34
 
35
35
  def read(self, content, lang=DEFAULT_LANGUAGE_CODE):
@@ -39,8 +39,7 @@ class MicroDVDReader(BaseReader):
39
39
  :param lang: Language code to assign.
40
40
  :rtype: CaptionSet
41
41
  """
42
- if not isinstance(content, str):
43
- raise InvalidInputError("The content is not a unicode string.")
42
+ content = self._decode_content(content)
44
43
 
45
44
  lines = content.splitlines()
46
45
  captions = CaptionList()
@@ -10,7 +10,6 @@ from ..base import BaseReader, Caption, CaptionList, CaptionNode, CaptionSet
10
10
  from ..exceptions import (
11
11
  CaptionReadNoCaptions,
12
12
  CaptionReadTimingError,
13
- InvalidInputError,
14
13
  )
15
14
  from ..geometry import Alignment, HorizontalAlignmentEnum, Layout, Padding, Size
16
15
  from .parser import SAMIParser
@@ -32,6 +31,7 @@ class SAMIReader(BaseReader):
32
31
 
33
32
  def detect(self, content):
34
33
  """Return True if content looks like a SAMI document."""
34
+ content = self._decode_content(content)
35
35
  return "<sami" in content.lower()
36
36
 
37
37
  def read(self, content):
@@ -42,8 +42,7 @@ class SAMIReader(BaseReader):
42
42
  :raises InvalidInputError: if content is not a string
43
43
  :raises CaptionReadNoCaptions: if no captions are found
44
44
  """
45
- if not isinstance(content, str):
46
- raise InvalidInputError("The content is not a unicode string.")
45
+ content = self._decode_content(content)
47
46
 
48
47
  content, doc_styles, doc_langs = SAMIParser().feed(content)
49
48
  sami_soup = BeautifulSoup(content, features="lxml")
@@ -87,7 +87,6 @@ from pycaption.exceptions import (
87
87
  CaptionLineLengthError,
88
88
  CaptionReadNoCaptions,
89
89
  CaptionReadTimingError,
90
- InvalidInputError,
91
90
  )
92
91
 
93
92
  from .constants import (
@@ -148,10 +147,11 @@ class SCCReader(BaseReader):
148
147
  def detect(self, content):
149
148
  """Checks whether the given content is a proper SCC file
150
149
 
151
- :type content: str
150
+ :type content: str or bytes
152
151
 
153
152
  :rtype: bool
154
153
  """
154
+ content = self._decode_content(content)
155
155
  lines = content.splitlines()
156
156
  if lines[0] == HEADER:
157
157
  return True
@@ -177,8 +177,7 @@ class SCCReader(BaseReader):
177
177
 
178
178
  :rtype: CaptionSet
179
179
  """
180
- if not isinstance(content, str):
181
- raise InvalidInputError("The content is not a unicode string.")
180
+ content = self._decode_content(content)
182
181
 
183
182
  self.simulate_roll_up = simulate_roll_up
184
183
  self.time_translator.offset = offset * 1000000
@@ -6,7 +6,7 @@ from .base import (
6
6
  BaseReader, BaseWriter, Caption, CaptionList, CaptionNode, CaptionSet,
7
7
  merge_caption_list,
8
8
  )
9
- from .exceptions import CaptionReadNoCaptions, InvalidInputError
9
+ from .exceptions import CaptionReadNoCaptions
10
10
  from .geometry import HorizontalAlignmentEnum
11
11
 
12
12
 
@@ -19,6 +19,7 @@ class SRTReader(BaseReader):
19
19
  Checks that the first line is a sequence number and the second
20
20
  contains an arrow ('-->').
21
21
  """
22
+ content = self._decode_content(content)
22
23
  lines = content.splitlines()
23
24
  if lines[0].isdigit() and "-->" in lines[1]:
24
25
  return True
@@ -34,8 +35,7 @@ class SRTReader(BaseReader):
34
35
  :raises InvalidInputError: if content is not a string.
35
36
  :raises CaptionReadNoCaptions: if no captions are found.
36
37
  """
37
- if not isinstance(content, str):
38
- raise InvalidInputError("The content is not a unicode string.")
38
+ content = self._decode_content(content)
39
39
 
40
40
  lines = content.splitlines()
41
41
  start_line = 0
@@ -15,7 +15,6 @@ from ..exceptions import (
15
15
  CaptionReadNoCaptions,
16
16
  CaptionReadSyntaxError,
17
17
  CaptionReadWarning,
18
- InvalidInputError,
19
18
  )
20
19
  from ..geometry import (
21
20
  Alignment,
@@ -126,8 +125,7 @@ class WebVTTReader(BaseReader):
126
125
  optionally followed by a space or tab and header metadata.
127
126
  Handles BOM-prefixed content.
128
127
  """
129
- if content.startswith(""):
130
- content = content[1:]
128
+ content = self._decode_content(content)
131
129
  first_line = content.splitlines()[0] if content.strip() else ""
132
130
  return (
133
131
  first_line == "WEBVTT"
@@ -147,20 +145,7 @@ class WebVTTReader(BaseReader):
147
145
  :raises InvalidInputError: If content is not a string.
148
146
  :raises CaptionReadNoCaptions: If no cues are found.
149
147
  """
150
- if isinstance(content, bytes):
151
- if not content:
152
- raise InvalidInputError("The content is not a unicode string.")
153
- try:
154
- content = content.decode("utf-8")
155
- except UnicodeDecodeError as e:
156
- raise InvalidInputError(
157
- f"WebVTT content is not valid UTF-8: {e}"
158
- ) from e
159
- elif not isinstance(content, str):
160
- raise InvalidInputError("The content is not a unicode string.")
161
-
162
- if content.startswith(""):
163
- content = content[1:]
148
+ content = self._decode_content(content)
164
149
 
165
150
  # str.splitlines() handles CR, LF, CRLF (W3C WebVTT §3 RULE-FMT-005)
166
151
  lines = content.splitlines()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.2
3
+ Version: 2.3.4
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -41,9 +41,11 @@ tests/__init__.py
41
41
  tests/conftest.py
42
42
  tests/mixins.py
43
43
  tests/test_base.py
44
+ tests/test_bytes_input.py
44
45
  tests/test_dfxp.py
45
46
  tests/test_dfxp_conversion.py
46
47
  tests/test_dfxp_extras.py
48
+ tests/test_double_encoding.py
47
49
  tests/test_functions.py
48
50
  tests/test_geometry.py
49
51
  tests/test_microdvd.py
@@ -20,7 +20,7 @@ transcript_dependencies = ["nltk==3.10.0"]
20
20
 
21
21
  setup(
22
22
  name="pycaption",
23
- version="2.3.2",
23
+ version="2.3.4",
24
24
  description="Closed caption converter",
25
25
  long_description=open(README_PATH).read(),
26
26
  author="Joe Norton",
@@ -17,10 +17,20 @@ class ReaderTestingMixIn:
17
17
  def assert_negative_answer_for_detection(self, different_sample):
18
18
  assert self.reader.detect(different_sample) is False
19
19
 
20
- def test_reader_only_supports_unicode_input(self):
20
+ def test_reader_rejects_non_string_non_bytes_input(self):
21
+ with pytest.raises(InvalidInputError) as exc_info:
22
+ self.reader.read(123)
23
+ assert "must be a unicode string or UTF-8 bytes" in exc_info.value.args[0]
24
+
25
+ def test_reader_rejects_empty_bytes(self):
21
26
  with pytest.raises(InvalidInputError) as exc_info:
22
27
  self.reader.read(b"")
23
- assert exc_info.value.args[0] == "The content is not a unicode string."
28
+ assert exc_info.value.args[0] == "The content is empty."
29
+
30
+ def test_reader_rejects_invalid_utf8_bytes(self):
31
+ with pytest.raises(InvalidInputError) as exc_info:
32
+ self.reader.read(b"\xff\xfe invalid utf8")
33
+ assert "not valid UTF-8" in exc_info.value.args[0]
24
34
 
25
35
 
26
36
  class WebVTTTestingMixIn:
@@ -0,0 +1,161 @@
1
+ """Tests for bytes input support across all readers.
2
+
3
+ All readers should accept bytes (raw file content) and decode as UTF-8
4
+ internally, preventing the double-encoding gibberish that occurs when
5
+ callers decode with the wrong system encoding (e.g., cp1252).
6
+ """
7
+
8
+ from pycaption import (
9
+ DFXPReader,
10
+ MicroDVDReader,
11
+ SAMIReader,
12
+ SCCReader,
13
+ SRTReader,
14
+ WebVTTReader,
15
+ )
16
+
17
+
18
+ class TestSRTReaderBytes:
19
+ def setup_class(self):
20
+ self.reader = SRTReader()
21
+
22
+ def test_read_bytes_produces_same_result_as_str(self, sample_srt):
23
+ from_str = self.reader.read(sample_srt)
24
+ from_bytes = self.reader.read(sample_srt.encode("utf-8"))
25
+ str_captions = from_str.get_captions("en-US")
26
+ bytes_captions = from_bytes.get_captions("en-US")
27
+ assert len(str_captions) == len(bytes_captions)
28
+ for s, b in zip(str_captions, bytes_captions):
29
+ assert s.get_text() == b.get_text()
30
+ assert s.start == b.start
31
+ assert s.end == b.end
32
+
33
+ def test_read_bytes_preserves_music_notes(self, sample_srt):
34
+ captions = self.reader.read(sample_srt.encode("utf-8"))
35
+ texts = [c.get_text() for c in captions.get_captions("en-US")]
36
+ assert any("♪" in t for t in texts)
37
+
38
+ def test_read_bytes_with_bom(self, sample_srt):
39
+ content_with_bom = b"\xef\xbb\xbf" + sample_srt.encode("utf-8")
40
+ captions = self.reader.read(content_with_bom)
41
+ assert len(captions.get_captions("en-US")) == 7
42
+
43
+ def test_detect_bytes(self, sample_srt):
44
+ assert self.reader.detect(sample_srt.encode("utf-8")) is True
45
+
46
+
47
+ class TestWebVTTReaderBytes:
48
+ def setup_class(self):
49
+ self.reader = WebVTTReader()
50
+
51
+ def test_read_bytes_produces_same_result_as_str(self, sample_webvtt):
52
+ from_str = self.reader.read(sample_webvtt)
53
+ from_bytes = self.reader.read(sample_webvtt.encode("utf-8"))
54
+ str_captions = from_str.get_captions("en-US")
55
+ bytes_captions = from_bytes.get_captions("en-US")
56
+ assert len(str_captions) == len(bytes_captions)
57
+ for s, b in zip(str_captions, bytes_captions):
58
+ assert s.get_text() == b.get_text()
59
+
60
+ def test_read_bytes_with_bom(self, sample_webvtt):
61
+ content_with_bom = b"\xef\xbb\xbf" + sample_webvtt.encode("utf-8")
62
+ captions = self.reader.read(content_with_bom)
63
+ assert len(captions.get_captions("en-US")) > 0
64
+
65
+ def test_detect_bytes(self, sample_webvtt):
66
+ assert self.reader.detect(sample_webvtt.encode("utf-8")) is True
67
+
68
+
69
+ class TestDFXPReaderBytes:
70
+ def setup_class(self):
71
+ self.reader = DFXPReader()
72
+
73
+ def test_read_bytes_produces_same_result_as_str(self, sample_dfxp):
74
+ from_str = self.reader.read(sample_dfxp)
75
+ from_bytes = self.reader.read(sample_dfxp.encode("utf-8"))
76
+ for lang in from_str.get_languages():
77
+ str_captions = from_str.get_captions(lang)
78
+ bytes_captions = from_bytes.get_captions(lang)
79
+ assert len(str_captions) == len(bytes_captions)
80
+ for s, b in zip(str_captions, bytes_captions):
81
+ assert s.get_text() == b.get_text()
82
+
83
+ def test_read_bytes_with_bom(self, sample_dfxp):
84
+ content_with_bom = b"\xef\xbb\xbf" + sample_dfxp.encode("utf-8")
85
+ captions = self.reader.read(content_with_bom)
86
+ assert not captions.is_empty()
87
+
88
+ def test_detect_bytes(self, sample_dfxp):
89
+ assert self.reader.detect(sample_dfxp.encode("utf-8")) is True
90
+
91
+
92
+ class TestSAMIReaderBytes:
93
+ def setup_class(self):
94
+ self.reader = SAMIReader()
95
+
96
+ def test_read_bytes_produces_same_result_as_str(self, sample_sami):
97
+ from_str = self.reader.read(sample_sami)
98
+ from_bytes = self.reader.read(sample_sami.encode("utf-8"))
99
+ for lang in from_str.get_languages():
100
+ str_captions = from_str.get_captions(lang)
101
+ bytes_captions = from_bytes.get_captions(lang)
102
+ assert len(str_captions) == len(bytes_captions)
103
+ for s, b in zip(str_captions, bytes_captions):
104
+ assert s.get_text() == b.get_text()
105
+
106
+ def test_read_bytes_preserves_music_notes(self, sample_sami):
107
+ captions = self.reader.read(sample_sami.encode("utf-8"))
108
+ langs = list(captions.get_languages())
109
+ texts = [c.get_text() for c in captions.get_captions(langs[0])]
110
+ assert any("♪" in t for t in texts)
111
+
112
+ def test_read_bytes_with_bom(self, sample_sami):
113
+ content_with_bom = b"\xef\xbb\xbf" + sample_sami.encode("utf-8")
114
+ captions = self.reader.read(content_with_bom)
115
+ assert not captions.is_empty()
116
+
117
+ def test_detect_bytes(self, sample_sami):
118
+ assert self.reader.detect(sample_sami.encode("utf-8")) is True
119
+
120
+
121
+ class TestSCCReaderBytes:
122
+ def test_read_bytes_produces_same_result_as_str(self, sample_scc_pop_on):
123
+ from_str = SCCReader().read(sample_scc_pop_on)
124
+ from_bytes = SCCReader().read(sample_scc_pop_on.encode("utf-8"))
125
+ str_captions = from_str.get_captions("en-US")
126
+ bytes_captions = from_bytes.get_captions("en-US")
127
+ assert len(str_captions) == len(bytes_captions)
128
+ for s, b in zip(str_captions, bytes_captions):
129
+ assert s.get_text() == b.get_text()
130
+
131
+ def test_read_bytes_with_bom(self, sample_scc_pop_on):
132
+ content_with_bom = b"\xef\xbb\xbf" + sample_scc_pop_on.encode("utf-8")
133
+ captions = SCCReader().read(content_with_bom)
134
+ assert len(captions.get_captions("en-US")) > 0
135
+
136
+ def test_detect_bytes(self, sample_scc_pop_on):
137
+ assert SCCReader().detect(sample_scc_pop_on.encode("utf-8")) is True
138
+
139
+
140
+ class TestMicroDVDReaderBytes:
141
+ def setup_class(self):
142
+ self.reader = MicroDVDReader()
143
+
144
+ def test_read_bytes_produces_same_result_as_str(self, sample_microdvd):
145
+ from_str = self.reader.read(sample_microdvd)
146
+ from_bytes = self.reader.read(sample_microdvd.encode("utf-8"))
147
+ str_captions = from_str.get_captions("und")
148
+ bytes_captions = from_bytes.get_captions("und")
149
+ assert len(str_captions) == len(bytes_captions)
150
+ for s, b in zip(str_captions, bytes_captions):
151
+ assert s.get_text() == b.get_text()
152
+
153
+ def test_read_bytes_with_bom(self, sample_microdvd):
154
+ content_with_bom = b"\xef\xbb\xbf" + sample_microdvd.encode("utf-8")
155
+ captions = self.reader.read(content_with_bom)
156
+ assert not captions.is_empty()
157
+
158
+ def test_detect_bytes(self, sample_microdvd):
159
+ assert self.reader.detect(sample_microdvd.encode("utf-8")) is True
160
+
161
+
@@ -0,0 +1,89 @@
1
+ import logging
2
+
3
+ import pytest
4
+
5
+ from pycaption import SAMIReader, SRTReader
6
+ from pycaption.base import BaseReader
7
+ from pycaption.dfxp import DFXPReader
8
+
9
+
10
+ def _double_encode(text):
11
+ """Simulate double-encoding: UTF-8 bytes misread as CP-1252, re-encoded."""
12
+ return text.encode("utf-8").decode("cp1252")
13
+
14
+
15
+ ORIGINAL_CHARS = ["♪", "—", "’", "é"]
16
+
17
+
18
+ class TestRepairDoubleEncoding:
19
+ @pytest.mark.parametrize("original", ORIGINAL_CHARS)
20
+ def test_fixes_double_encoded(self, original):
21
+ garbled = _double_encode(original)
22
+ assert BaseReader._repair_double_encoding(garbled) == original
23
+
24
+ def test_leaves_clean_utf8_alone(self):
25
+ clean = "♪ This is — perfectly fine é text"
26
+ assert BaseReader._repair_double_encoding(clean) == clean
27
+
28
+ def test_logs_warning_on_repair(self, caplog):
29
+ garbled = _double_encode("♪")
30
+ with caplog.at_level(logging.WARNING, logger="pycaption.base"):
31
+ BaseReader._repair_double_encoding(garbled)
32
+ assert "double-encoded" in caplog.text.lower()
33
+
34
+ def test_no_warning_for_clean_input(self, caplog):
35
+ with caplog.at_level(logging.WARNING, logger="pycaption.base"):
36
+ BaseReader._repair_double_encoding("♪ Music ♪")
37
+ assert caplog.text == ""
38
+
39
+
40
+ class TestDoubleEncodingEndToEnd:
41
+ def test_srt_reader(self):
42
+ garbled_note = _double_encode("♪")
43
+ content = (
44
+ "1\n"
45
+ "00:00:01,000 --> 00:00:02,000\n"
46
+ f"{garbled_note} Music {garbled_note}\n"
47
+ )
48
+ captions = SRTReader().read(content)
49
+ nodes = captions.get_captions("en-US")[0].nodes
50
+ text = "".join(n.content for n in nodes)
51
+ assert "♪" in text
52
+ assert garbled_note not in text
53
+
54
+ def test_dfxp_reader(self):
55
+ garbled = _double_encode("élève")
56
+ content = (
57
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
58
+ '<tt xml:lang="en" xmlns="http://www.w3.org/ns/ttml">\n'
59
+ " <body><div>\n"
60
+ f' <p begin="00:00:01.000" end="00:00:02.000">'
61
+ f"{garbled}</p>\n"
62
+ " </div></body>\n"
63
+ "</tt>\n"
64
+ )
65
+ captions = DFXPReader().read(content)
66
+ nodes = captions.get_captions("en")[0].nodes
67
+ text = "".join(n.content for n in nodes)
68
+ assert "élève" in text
69
+ assert garbled not in text
70
+
71
+ def test_sami_reader(self):
72
+ garbled_dash = _double_encode("—")
73
+ content = (
74
+ "<SAMI>\n"
75
+ "<Body>\n"
76
+ " <Sync Start=1000>\n"
77
+ f" <P Class=enCC>{garbled_dash} Hello</P>\n"
78
+ " </Sync>\n"
79
+ " <Sync Start=2000>\n"
80
+ " <P Class=enCC>&nbsp;</P>\n"
81
+ " </Sync>\n"
82
+ "</Body>\n"
83
+ "</SAMI>\n"
84
+ )
85
+ captions = SAMIReader().read(content)
86
+ lang = list(captions.get_languages())[0]
87
+ nodes = captions.get_captions(lang)[0].nodes
88
+ text = "".join(n.content for n in nodes)
89
+ assert "—" in text
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes