pycaption 2.3.6__tar.gz → 2.3.7__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.6/pycaption.egg-info → pycaption-2.3.7}/PKG-INFO +1 -1
  2. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/sami/reader.py +4 -6
  3. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/sami/writer.py +2 -4
  4. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/scc/writer.py +15 -11
  5. {pycaption-2.3.6 → pycaption-2.3.7/pycaption.egg-info}/PKG-INFO +1 -1
  6. {pycaption-2.3.6 → pycaption-2.3.7}/setup.py +1 -1
  7. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_scc_writer.py +61 -0
  8. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_webvtt_conversion.py +22 -0
  9. {pycaption-2.3.6 → pycaption-2.3.7}/LICENSE +0 -0
  10. {pycaption-2.3.6 → pycaption-2.3.7}/MANIFEST.in +0 -0
  11. {pycaption-2.3.6 → pycaption-2.3.7}/README.rst +0 -0
  12. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/__init__.py +0 -0
  13. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/base.py +0 -0
  14. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/dfxp/__init__.py +0 -0
  15. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/dfxp/constants.py +0 -0
  16. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/dfxp/extras.py +0 -0
  17. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/dfxp/reader.py +0 -0
  18. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/dfxp/writer.py +0 -0
  19. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/exceptions.py +0 -0
  20. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/geometry.py +0 -0
  21. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/microdvd.py +0 -0
  22. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/sami/__init__.py +0 -0
  23. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/sami/constants.py +0 -0
  24. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/sami/parser.py +0 -0
  25. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/scc/__init__.py +0 -0
  26. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/scc/constants.py +0 -0
  27. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/scc/reader.py +0 -0
  28. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/scc/specialized_collections.py +0 -0
  29. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/scc/state_machines.py +0 -0
  30. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/scc/translator.py +0 -0
  31. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/srt.py +0 -0
  32. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/transcript.py +0 -0
  33. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/utils.py +0 -0
  34. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/webvtt/__init__.py +0 -0
  35. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/webvtt/constants.py +0 -0
  36. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/webvtt/reader.py +0 -0
  37. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption/webvtt/writer.py +0 -0
  38. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption.egg-info/SOURCES.txt +0 -0
  39. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption.egg-info/dependency_links.txt +0 -0
  40. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption.egg-info/requires.txt +0 -0
  41. {pycaption-2.3.6 → pycaption-2.3.7}/pycaption.egg-info/top_level.txt +0 -0
  42. {pycaption-2.3.6 → pycaption-2.3.7}/setup.cfg +0 -0
  43. {pycaption-2.3.6 → pycaption-2.3.7}/tests/__init__.py +0 -0
  44. {pycaption-2.3.6 → pycaption-2.3.7}/tests/conftest.py +0 -0
  45. {pycaption-2.3.6 → pycaption-2.3.7}/tests/fixtures/__init__.py +0 -0
  46. {pycaption-2.3.6 → pycaption-2.3.7}/tests/fixtures/dfxp.py +0 -0
  47. {pycaption-2.3.6 → pycaption-2.3.7}/tests/fixtures/microdvd.py +0 -0
  48. {pycaption-2.3.6 → pycaption-2.3.7}/tests/fixtures/sami.py +0 -0
  49. {pycaption-2.3.6 → pycaption-2.3.7}/tests/fixtures/scc.py +0 -0
  50. {pycaption-2.3.6 → pycaption-2.3.7}/tests/fixtures/srt.py +0 -0
  51. {pycaption-2.3.6 → pycaption-2.3.7}/tests/fixtures/translated_scc.py +0 -0
  52. {pycaption-2.3.6 → pycaption-2.3.7}/tests/fixtures/webvtt.py +0 -0
  53. {pycaption-2.3.6 → pycaption-2.3.7}/tests/mixins.py +0 -0
  54. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_base.py +0 -0
  55. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_bytes_input.py +0 -0
  56. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_dfxp.py +0 -0
  57. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_dfxp_conversion.py +0 -0
  58. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_dfxp_extras.py +0 -0
  59. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_double_encoding.py +0 -0
  60. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_functions.py +0 -0
  61. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_geometry.py +0 -0
  62. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_microdvd.py +0 -0
  63. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_microdvd_conversion.py +0 -0
  64. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_sami.py +0 -0
  65. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_sami_conversion.py +0 -0
  66. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_scc.py +0 -0
  67. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_scc_conversion.py +0 -0
  68. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_scc_translator.py +0 -0
  69. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_srt.py +0 -0
  70. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_srt_conversion.py +0 -0
  71. {pycaption-2.3.6 → pycaption-2.3.7}/tests/test_webvtt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.6
3
+ Version: 2.3.7
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -7,10 +7,7 @@ styles, margin-based positioning, and multi-language content.
7
7
  from bs4 import BeautifulSoup, NavigableString
8
8
 
9
9
  from ..base import BaseReader, Caption, CaptionList, CaptionNode, CaptionSet
10
- from ..exceptions import (
11
- CaptionReadNoCaptions,
12
- CaptionReadTimingError,
13
- )
10
+ from ..exceptions import CaptionReadNoCaptions, CaptionReadTimingError
14
11
  from ..geometry import Alignment, HorizontalAlignmentEnum, Layout, Padding, Size
15
12
  from .parser import SAMIParser
16
13
 
@@ -65,7 +62,8 @@ class SAMIReader(BaseReader):
65
62
  caption_dict[language] = lang_captions
66
63
 
67
64
  caption_set = CaptionSet(
68
- caption_dict, layout_info=global_layout,
65
+ caption_dict,
66
+ layout_info=global_layout,
69
67
  visual_alignment_default=HorizontalAlignmentEnum.LEFT,
70
68
  )
71
69
 
@@ -244,7 +242,7 @@ class SAMIReader(BaseReader):
244
242
  attrs = {}
245
243
  css_attrs = tag.attrs
246
244
 
247
- if "class" in css_attrs:
245
+ if "class" in css_attrs and css_attrs["class"]:
248
246
  attrs["class"] = css_attrs["class"][0].lower()
249
247
  if "id" in css_attrs:
250
248
  attrs["class"] = css_attrs["id"].lower()
@@ -42,9 +42,7 @@ class SAMIWriter(BaseWriter):
42
42
  """Serialize a CaptionSet into a SAMI document string."""
43
43
  caption_set = deepcopy(caption_set)
44
44
  source_default = self._get_visual_alignment_default(caption_set)
45
- self._promote_center = (
46
- source_default == HorizontalAlignmentEnum.CENTER
47
- )
45
+ self._promote_center = source_default == HorizontalAlignmentEnum.CENTER
48
46
  sami = BeautifulSoup(SAMI_BASE_MARKUP, "lxml-xml")
49
47
 
50
48
  caption_set.layout_info = self._relativize_and_fit_to_screen(
@@ -310,7 +308,7 @@ class SAMIWriter(BaseWriter):
310
308
 
311
309
  style = ""
312
310
  klass = ""
313
- if "classes" in content:
311
+ if "classes" in content and content["classes"]:
314
312
  klass += f' class="{" ".join(content["classes"])}"'
315
313
  elif "class" in content:
316
314
  klass += f' class="{content["class"]}"'
@@ -99,6 +99,7 @@ class SCCWriter(BaseWriter):
99
99
  codes = self._encode_all(classified)
100
100
  codes = self._adjust_timing(codes)
101
101
  codes = self._deduplicate_timestamps(codes)
102
+ codes = self._clamp_end_times(codes)
102
103
  output += self._render_mixed(codes)
103
104
 
104
105
  return output
@@ -195,6 +196,17 @@ class SCCWriter(BaseWriter):
195
196
  last_emitted_frame = self._microseconds_to_frame(start)
196
197
  return codes
197
198
 
199
+ @staticmethod
200
+ def _clamp_end_times(codes):
201
+ """Suppress end times that drift behind start after timing adjustments.
202
+ When dense cues push start forward beyond the original end, emit no
203
+ clear command (end=None) rather than producing an invalid negative-
204
+ duration cue."""
205
+ for index, (code, start, end, mode, depth) in enumerate(codes):
206
+ if end is not None and end <= start:
207
+ codes[index] = (code, start, None, mode, depth)
208
+ return codes
209
+
198
210
  def _render_mixed(self, codes):
199
211
  """Serialize all cues in chronological order, emitting mode-appropriate
200
212
  preambles inline. Handles pop-on (ENM+RCL...EDM+EOC), roll-up
@@ -225,12 +237,7 @@ class SCCWriter(BaseWriter):
225
237
  code_tokens = code.split()
226
238
 
227
239
  if len(code_tokens) <= max_payload:
228
- return (
229
- f"{ts}\t"
230
- "94ae 94ae 9420 9420 "
231
- f"{code}"
232
- "942c 942c 942f 942f\n\n"
233
- )
240
+ return f"{ts}\t" "94ae 94ae 9420 9420 " f"{code}" "942c 942c 942f 942f\n\n"
234
241
 
235
242
  output = ""
236
243
  offset = 0
@@ -243,11 +250,7 @@ class SCCWriter(BaseWriter):
243
250
  is_last = offset + max_payload >= len(code_tokens)
244
251
  if is_last:
245
252
  line = line + ["942c", "942c", "942f", "942f"]
246
- output += (
247
- f"{self._format_timestamp(start)}\t"
248
- + " ".join(line)
249
- + "\n\n"
250
- )
253
+ output += f"{self._format_timestamp(start)}\t" + " ".join(line) + "\n\n"
251
254
  offset += max_payload
252
255
  if not is_last:
253
256
  start += MICROSECONDS_PER_CODEWORD
@@ -329,6 +332,7 @@ class SCCWriter(BaseWriter):
329
332
  base_row = max(1, min(15, round((y.value - 5) / 90.0 * 15) + 1))
330
333
  else:
331
334
  base_row = 15
335
+ base_row = max(1, min(base_row, 16 - num_lines))
332
336
  row = base_row + line_index
333
337
  return min(row, 15)
334
338
  return max(1, min(15, 16 - num_lines + line_index))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.6
3
+ Version: 2.3.7
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -20,7 +20,7 @@ transcript_dependencies = ["nltk==3.10.0"]
20
20
 
21
21
  setup(
22
22
  name="pycaption",
23
- version="2.3.6",
23
+ version="2.3.7",
24
24
  description="Closed caption converter",
25
25
  long_description=open(README_PATH).read(),
26
26
  author="Joe Norton",
@@ -185,6 +185,30 @@ class TestSCCWriterOverlappingCues:
185
185
  # (only the last cue gets a clear-screen at its end time)
186
186
  assert len(clear_lines) <= 1
187
187
 
188
+ def test_dense_cues_never_produce_negative_duration(self):
189
+ """When many dense cues push start times forward via timing
190
+ adjustments, the end time must never be less than the start."""
191
+ lines = ["WEBVTT", ""]
192
+ for i in range(20):
193
+ start_ms = 1000 + i * 200
194
+ end_ms = start_ms + 150
195
+ s, e = start_ms / 1000, end_ms / 1000
196
+ lines.append(
197
+ f"00:00:{int(s):02d}.{int((s % 1) * 1000):03d} --> "
198
+ f"00:00:{int(e):02d}.{int((e % 1) * 1000):03d}"
199
+ )
200
+ lines.append(
201
+ f"Caption number {i + 1:02d} with a lot of extra "
202
+ f"text padding here for testing!!"
203
+ )
204
+ lines.append("")
205
+ captions = WebVTTReader().read("\n".join(lines))
206
+ scc_output = SCCWriter().write(captions)
207
+ reread = SCCReader().read(scc_output)
208
+ caps = reread.get_captions(reread.get_languages()[0])
209
+ for cap in caps:
210
+ assert cap.end >= cap.start
211
+
188
212
 
189
213
  class TestSCCWriterSplitLongCaption:
190
214
  def test_split_caption_exceeding_80_tokens(self):
@@ -319,6 +343,43 @@ class TestSCCWriterPositioning:
319
343
  pac_col_28 = WRITER_PAC_CODES[(15, 28, "plain")]
320
344
  assert pac_col_28 in output
321
345
 
346
+ def test_multiline_bottom_stacks_upward(self):
347
+ vtt = (
348
+ "WEBVTT\n\n"
349
+ "00:00:01.000 --> 00:00:04.000 line:100%\n"
350
+ "This is line one\n"
351
+ "This is line two\n"
352
+ )
353
+ captions = WebVTTReader().read(vtt)
354
+ scc_output = SCCWriter().write(captions)
355
+ reread = SCCReader().read(scc_output)
356
+ caps = reread.get_captions(reread.get_languages()[0])
357
+ assert caps[0].get_text() == "This is line one\nThis is line two"
358
+ pac_row_14 = WRITER_PAC_CODES[(14, 0, "plain")]
359
+ pac_row_15 = WRITER_PAC_CODES[(15, 0, "plain")]
360
+ assert pac_row_14 in scc_output
361
+ assert pac_row_15 in scc_output
362
+
363
+ def test_three_line_at_86_percent_stacks_upward(self):
364
+ vtt = (
365
+ "WEBVTT\n\n"
366
+ "00:00:01.000 --> 00:00:04.000 line:86%\n"
367
+ "First line\n"
368
+ "Second line\n"
369
+ "Third line\n"
370
+ )
371
+ captions = WebVTTReader().read(vtt)
372
+ scc_output = SCCWriter().write(captions)
373
+ reread = SCCReader().read(scc_output)
374
+ caps = reread.get_captions(reread.get_languages()[0])
375
+ assert caps[0].get_text() == "First line\nSecond line\nThird line"
376
+ pac_row_13 = WRITER_PAC_CODES[(13, 0, "plain")]
377
+ pac_row_14 = WRITER_PAC_CODES[(14, 0, "plain")]
378
+ pac_row_15 = WRITER_PAC_CODES[(15, 0, "plain")]
379
+ assert pac_row_13 in scc_output
380
+ assert pac_row_14 in scc_output
381
+ assert pac_row_15 in scc_output
382
+
322
383
 
323
384
  class TestSCCWriterStyles:
324
385
  def test_vtt_italic_emits_mid_row_code(self):
@@ -672,3 +672,25 @@ class TestGlobalCueColorPropagation:
672
672
  style_opens = [n for n in nodes if n.type_ == CaptionNode.STYLE and n.start]
673
673
  assert len(style_opens) == 1
674
674
  assert style_opens[0].content.get("color") == "red"
675
+
676
+
677
+ class TestYouTubeKaraokeEmptyClassRoundtrip:
678
+ def test_empty_class_vtt_to_sami_roundtrip(self):
679
+ vtt = (
680
+ "WEBVTT\n\n"
681
+ "00:00:01.000 --> 00:00:04.000\n"
682
+ "<c> Hello</c><c> world</c>\n"
683
+ )
684
+ caption_set = WebVTTReader().read(vtt)
685
+ sami_output = SAMIWriter().write(caption_set)
686
+
687
+ assert 'class=""' not in sami_output
688
+ assert "Hello" in sami_output
689
+ assert "world" in sami_output
690
+
691
+ reread_set = SAMIReader().read(sami_output)
692
+ captions = reread_set.get_captions(reread_set.get_languages()[0])
693
+ assert len(captions) == 1
694
+ text = captions[0].get_text()
695
+ assert "Hello" in text
696
+ assert "world" 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
File without changes
File without changes
File without changes