pycaption 2.3.13__tar.gz → 2.3.14__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.13/pycaption.egg-info → pycaption-2.3.14}/PKG-INFO +1 -1
  2. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/writer.py +65 -59
  3. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/webvtt/reader.py +32 -24
  4. {pycaption-2.3.13 → pycaption-2.3.14/pycaption.egg-info}/PKG-INFO +1 -1
  5. {pycaption-2.3.13 → pycaption-2.3.14}/setup.py +1 -1
  6. {pycaption-2.3.13 → pycaption-2.3.14}/tests/conftest.py +12 -0
  7. {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/dfxp.py +14 -0
  8. {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/webvtt.py +144 -0
  9. {pycaption-2.3.13 → pycaption-2.3.14}/tests/mixins.py +19 -0
  10. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_sami.py +122 -1
  11. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_sami_conversion.py +34 -0
  12. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_webvtt.py +177 -0
  13. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_webvtt_conversion.py +54 -0
  14. {pycaption-2.3.13 → pycaption-2.3.14}/LICENSE +0 -0
  15. {pycaption-2.3.13 → pycaption-2.3.14}/MANIFEST.in +0 -0
  16. {pycaption-2.3.13 → pycaption-2.3.14}/README.rst +0 -0
  17. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/__init__.py +0 -0
  18. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/base.py +0 -0
  19. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/__init__.py +0 -0
  20. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/constants.py +0 -0
  21. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/extras.py +0 -0
  22. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/reader.py +0 -0
  23. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/writer.py +0 -0
  24. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/exceptions.py +0 -0
  25. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/geometry.py +0 -0
  26. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/microdvd.py +0 -0
  27. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/__init__.py +0 -0
  28. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/constants.py +0 -0
  29. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/parser.py +0 -0
  30. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/reader.py +0 -0
  31. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/__init__.py +0 -0
  32. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/constants.py +0 -0
  33. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/reader.py +0 -0
  34. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/specialized_collections.py +0 -0
  35. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/state_machines.py +0 -0
  36. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/translator.py +0 -0
  37. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/writer.py +0 -0
  38. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/srt.py +0 -0
  39. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/transcript.py +0 -0
  40. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/utils.py +0 -0
  41. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/webvtt/__init__.py +0 -0
  42. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/webvtt/constants.py +0 -0
  43. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/webvtt/writer.py +0 -0
  44. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption.egg-info/SOURCES.txt +0 -0
  45. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption.egg-info/dependency_links.txt +0 -0
  46. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption.egg-info/requires.txt +0 -0
  47. {pycaption-2.3.13 → pycaption-2.3.14}/pycaption.egg-info/top_level.txt +0 -0
  48. {pycaption-2.3.13 → pycaption-2.3.14}/setup.cfg +0 -0
  49. {pycaption-2.3.13 → pycaption-2.3.14}/tests/__init__.py +0 -0
  50. {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/__init__.py +0 -0
  51. {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/microdvd.py +0 -0
  52. {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/sami.py +0 -0
  53. {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/scc.py +0 -0
  54. {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/srt.py +0 -0
  55. {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/translated_scc.py +0 -0
  56. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_base.py +0 -0
  57. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_bytes_input.py +0 -0
  58. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_dfxp.py +0 -0
  59. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_dfxp_conversion.py +0 -0
  60. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_dfxp_extras.py +0 -0
  61. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_double_encoding.py +0 -0
  62. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_functions.py +0 -0
  63. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_geometry.py +0 -0
  64. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_microdvd.py +0 -0
  65. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_microdvd_conversion.py +0 -0
  66. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_scc.py +0 -0
  67. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_scc_conversion.py +0 -0
  68. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_scc_translator.py +0 -0
  69. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_scc_writer.py +0 -0
  70. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_srt.py +0 -0
  71. {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_srt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.13
3
+ Version: 2.3.14
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -37,7 +37,6 @@ class SAMIWriter(BaseWriter):
37
37
  def __init__(self, *args, **kwargs):
38
38
  super().__init__(*args, **kwargs)
39
39
  self._span_stack = []
40
- self.last_time = None
41
40
  self._promote_center = False
42
41
 
43
42
  def write(self, caption_set, **kwargs):
@@ -51,13 +50,8 @@ class SAMIWriter(BaseWriter):
51
50
  caption_set.layout_info
52
51
  )
53
52
 
54
- primary = None
55
-
53
+ timelines = {}
56
54
  for lang in caption_set.get_languages():
57
- self.last_time = None
58
- if primary is None:
59
- primary = lang
60
-
61
55
  caption_set.set_layout_info(
62
56
  lang,
63
57
  self._relativize_and_fit_to_screen(caption_set.get_layout_info(lang)),
@@ -72,12 +66,11 @@ class SAMIWriter(BaseWriter):
72
66
  node.layout_info = self._relativize_and_fit_to_screen(
73
67
  node.layout_info
74
68
  )
75
- sami = self._recreate_p_tag(caption, sami, lang, primary, caption_set)
69
+ cues = self._build_timeline(captions)
70
+ if cues:
71
+ timelines[lang] = cues
76
72
 
77
- if self.last_time and captions:
78
- sami = self._recreate_blank_tag(
79
- sami, captions[-1], lang, primary, caption_set
80
- )
73
+ self._recreate_syncs(sami, timelines, caption_set)
81
74
 
82
75
  stylesheet = self._recreate_stylesheet(caption_set)
83
76
  sami.find("style").append(stylesheet)
@@ -86,17 +79,64 @@ class SAMIWriter(BaseWriter):
86
79
  caption_content = "\n".join(a[1:])
87
80
  return caption_content
88
81
 
89
- def _recreate_p_tag(self, caption, sami, lang, primary, captions):
90
- """Build a <sync><p> block for a single caption with styling and alignment."""
91
- time = int(caption.start // 1000)
92
-
93
- if self.last_time and time != self.last_time:
94
- sami = self._recreate_blank_tag(sami, caption, lang, primary, captions)
95
-
96
- self.last_time = int(caption.end // 1000)
82
+ @staticmethod
83
+ def _build_timeline(captions):
84
+ """Return (start_ms, end_ms, caption) cues sorted by start.
97
85
 
98
- sami, sync = self._recreate_sync(sami, lang, primary, time)
86
+ Zero-length cues are never on screen, so they are dropped.
87
+ """
88
+ cues = []
89
+ for caption in captions:
90
+ start = int(caption.start // 1000)
91
+ end = int(caption.end // 1000)
92
+ if end > start:
93
+ cues.append((start, end, caption))
94
+ cues.sort(key=lambda cue: cue[0])
95
+ return cues
96
+
97
+ def _recreate_syncs(self, sami, timelines, captions):
98
+ """Append one <sync> per time at which any language's visible cues change.
99
+
100
+ Overlapping cues of a language are merged: a <sync> repeats every cue
101
+ of that language still on screen, so start times never decrease and
102
+ no cue is cleared early.
103
+ """
104
+ times = sorted(
105
+ {time for cues in timelines.values() for cue in cues for time in cue[:2]}
106
+ )
107
+ shown = {lang: [] for lang in timelines}
108
+ pending = {lang: 0 for lang in timelines}
109
+
110
+ for time in times:
111
+ sync = None
112
+ for lang, cues in timelines.items():
113
+ active = [i for i in shown[lang] if cues[i][1] > time]
114
+ while pending[lang] < len(cues) and cues[pending[lang]][0] <= time:
115
+ active.append(pending[lang])
116
+ pending[lang] += 1
117
+ if active == shown[lang]:
118
+ continue
119
+ shown[lang] = active
120
+
121
+ if sync is None:
122
+ sync = sami.new_tag("sync", start=time)
123
+ sami.body.append(sync)
124
+
125
+ if active:
126
+ for i in active:
127
+ sync.append(
128
+ self._recreate_p_tag(cues[i][2], sami, lang, captions)
129
+ )
130
+ else:
131
+ # The blank takes the class of the next cue, or of the
132
+ # last cue once the language has no cues left.
133
+ upcoming = cues[min(pending[lang], len(cues) - 1)][2]
134
+ sync.append(
135
+ self._recreate_blank_tag(upcoming, sami, lang, captions)
136
+ )
99
137
 
138
+ def _recreate_p_tag(self, caption, sami, lang, captions):
139
+ """Build a <p> for a single caption with styling and alignment."""
100
140
  p = sami.new_tag("p")
101
141
 
102
142
  p_style = self._build_p_style(caption)
@@ -107,9 +147,7 @@ class SAMIWriter(BaseWriter):
107
147
  p["class"] = self._recreate_p_lang(caption, lang, captions)
108
148
  p.string = encode_leading_nbsp(self._recreate_text(caption.nodes))
109
149
 
110
- sync.append(p)
111
-
112
- return sami
150
+ return p
113
151
 
114
152
  def _build_p_style(self, caption):
115
153
  """Build the inline CSS style string for a <p> element."""
@@ -158,45 +196,13 @@ class SAMIWriter(BaseWriter):
158
196
 
159
197
  return HORIZONTAL_ALIGNMENT_MAP.get(h)
160
198
 
161
- def _recreate_sync(self, sami, lang, primary, time):
162
- """Find or create a <sync> tag at the given millisecond timestamp."""
163
- if lang == primary:
164
- sync = sami.new_tag("sync", start=time)
165
- sami.body.append(sync)
166
- else:
167
- sync = sami.find("sync", start=time)
168
- if sync is None:
169
- sami, sync = self._find_closest_sync(sami, time)
170
-
171
- return sami, sync
172
-
173
- @staticmethod
174
- def _find_closest_sync(sami, time):
175
- """Insert a new <sync> tag in chronological order among existing syncs."""
176
- sync = sami.new_tag("sync", start=time)
177
-
178
- earlier = sami.find_all("sync", start=lambda x: int(x) < time)
179
- if earlier:
180
- last_sync = earlier[-1]
181
- last_sync.insert_after(sync)
182
- else:
183
- later = sami.find_all("sync", start=lambda x: int(x) > time)
184
- if later:
185
- last_sync = later[0]
186
- last_sync.insert_before(sync)
187
- return sami, sync
188
-
189
- def _recreate_blank_tag(self, sami, caption, lang, primary, captions):
190
- """Insert an &nbsp; paragraph to clear the previous caption at its end time."""
191
- sami, sync = self._recreate_sync(sami, lang, primary, self.last_time)
192
-
199
+ def _recreate_blank_tag(self, caption, sami, lang, captions):
200
+ """Build an &nbsp; <p> that clears the language's previous caption."""
193
201
  p = sami.new_tag("p")
194
202
  p["class"] = self._recreate_p_lang(caption, lang, captions)
195
203
  p.string = "&nbsp;"
196
204
 
197
- sync.append(p)
198
-
199
- return sami
205
+ return p
200
206
 
201
207
  @staticmethod
202
208
  def _recreate_p_lang(caption, lang, captions):
@@ -76,6 +76,7 @@ class _ParseState:
76
76
  "open_tags",
77
77
  "layout_info",
78
78
  "found_timing",
79
+ "drop_cue",
79
80
  "in_note_block",
80
81
  "in_style_block",
81
82
  "in_region_block",
@@ -90,6 +91,7 @@ class _ParseState:
90
91
  self.open_tags = []
91
92
  self.layout_info = None
92
93
  self.found_timing = False
94
+ self.drop_cue = False
93
95
  self.in_note_block = False
94
96
  self.in_style_block = False
95
97
  self.in_region_block = False
@@ -114,7 +116,9 @@ class WebVTTReader(BaseReader):
114
116
  :param ignore_timing_errors: When False, raises on non-monotonic
115
117
  or inverted timestamps. Default True for lenient parsing.
116
118
  :param time_shift_milliseconds: Offset applied to all timestamps
117
- (positive = shift forward, negative = shift backward).
119
+ (positive = shift forward, negative = shift backward). After a
120
+ negative shift, a cue crossing 0 is clamped to start at 0 and a
121
+ cue ending at or before 0 is dropped.
118
122
  """
119
123
  super().__init__(*args, **kwargs)
120
124
  self.ignore_timing_errors = ignore_timing_errors
@@ -219,15 +223,7 @@ class WebVTTReader(BaseReader):
219
223
 
220
224
  # Flush the last cue if file doesn't end with a blank line
221
225
  if state.nodes:
222
- captions.append(
223
- self._build_cue(
224
- state.nodes,
225
- state.open_tags,
226
- state.start,
227
- state.end,
228
- state.layout_info,
229
- )
230
- )
226
+ self._finalize_cue(state, captions)
231
227
 
232
228
  return captions
233
229
 
@@ -251,6 +247,7 @@ class WebVTTReader(BaseReader):
251
247
  state.start, state.end, state.layout_info = self._read_timing(
252
248
  line, line_index, last_start_time
253
249
  )
250
+ state.drop_cue = state.start is None
254
251
  return
255
252
 
256
253
  if not state.found_timing and line != "":
@@ -261,12 +258,10 @@ class WebVTTReader(BaseReader):
261
258
  state.pending_id = None
262
259
  return
263
260
 
264
- if line == "" and state.found_timing and state.nodes:
261
+ if line == "" and state.found_timing:
265
262
  self._finalize_cue(state, captions)
266
263
  return
267
264
 
268
- if not state.found_timing or line == "":
269
- return
270
265
  if state.nodes:
271
266
  state.nodes.append(CaptionNode.create_break())
272
267
  state.nodes.extend(self._parse_cue_text(line, state.open_tags))
@@ -320,18 +315,20 @@ class WebVTTReader(BaseReader):
320
315
  state.seen_ids.add(cue_id)
321
316
 
322
317
  def _finalize_cue(self, state, captions):
323
- """Close the current cue, append it to captions, and reset state
324
- for the next cue."""
318
+ """Close the current cue, append it to captions unless it is dropped
319
+ or empty, and reset state for the next cue."""
325
320
  state.found_timing = False
326
- captions.append(
327
- self._build_cue(
328
- state.nodes,
329
- state.open_tags,
330
- state.start,
331
- state.end,
332
- state.layout_info,
321
+ if not state.drop_cue and state.nodes:
322
+ captions.append(
323
+ self._build_cue(
324
+ state.nodes,
325
+ state.open_tags,
326
+ state.start,
327
+ state.end,
328
+ state.layout_info,
329
+ )
333
330
  )
334
- )
331
+ state.drop_cue = False
335
332
  state.nodes = []
336
333
  state.open_tags = []
337
334
 
@@ -438,7 +435,9 @@ class WebVTTReader(BaseReader):
438
435
  :param line: Raw timing line, e.g.
439
436
  "00:00:01.000 --> 00:00:05.000 line:80%"
440
437
  :param last_start_time: Start time of previous cue (for validation).
441
- :returns: Tuple of (start_us, end_us, Layout or None).
438
+ :returns: Tuple of (start_us, end_us, Layout or None). start_us and
439
+ end_us are None when a negative time shift puts the cue end at
440
+ or before 0, meaning the cue is dropped.
442
441
  :raises CaptionReadSyntaxError: On unparseable timing format.
443
442
  """
444
443
  m = TIMING_LINE_PATTERN.search(line)
@@ -449,6 +448,15 @@ class WebVTTReader(BaseReader):
449
448
  end = self._parse_timestamp(m.group(2)) + self.time_shift_microseconds
450
449
  cue_settings = m.group(3)
451
450
 
451
+ if self.time_shift_microseconds < 0:
452
+ if end <= 0:
453
+ # Shifted entirely before 0: drop. Still reject inverted
454
+ # timings; ordering is moot since the cue is not kept.
455
+ if not self.ignore_timing_errors:
456
+ self._validate_timings(start, end, start)
457
+ return None, None, None
458
+ start = max(start, 0)
459
+
452
460
  if not self.ignore_timing_errors:
453
461
  self._validate_timings(start, end, last_start_time)
454
462
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.13
3
+ Version: 2.3.14
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -21,7 +21,7 @@ transcript_dependencies = ["nltk==3.10.0"]
21
21
 
22
22
  setup(
23
23
  name="pycaption",
24
- version="2.3.13",
24
+ version="2.3.14",
25
25
  description="Closed caption converter",
26
26
  long_description=open(README_PATH).read(),
27
27
  author="Joe Norton",
@@ -29,6 +29,7 @@ from tests.fixtures.dfxp import ( # noqa: F401
29
29
  sample_dfxp_multiple_regions_output,
30
30
  sample_dfxp_only_spaces_paragraph,
31
31
  sample_dfxp_output,
32
+ sample_dfxp_overlapping_cues,
32
33
  sample_dfxp_style_tag_with_no_xml_id_input,
33
34
  sample_dfxp_style_tag_with_no_xml_id_output,
34
35
  sample_dfxp_syntax_error,
@@ -219,7 +220,16 @@ from tests.fixtures.webvtt import ( # noqa: F401
219
220
  sample_webvtt_double_br,
220
221
  sample_webvtt_empty,
221
222
  sample_webvtt_empty_cue,
223
+ sample_webvtt_empty_cue_multiple_blanks,
224
+ sample_webvtt_empty_cue_only_orphan_text,
222
225
  sample_webvtt_empty_cue_output,
226
+ sample_webvtt_empty_cue_then_duplicate_id,
227
+ sample_webvtt_empty_cue_then_id,
228
+ sample_webvtt_empty_cue_then_note,
229
+ sample_webvtt_empty_cue_then_orphan_text,
230
+ sample_webvtt_empty_cue_then_region,
231
+ sample_webvtt_empty_cue_then_style,
232
+ sample_webvtt_empty_last_cue,
223
233
  sample_webvtt_from_dfxp,
224
234
  sample_webvtt_from_dfxp_with_positioning_and_style,
225
235
  sample_webvtt_from_dfxp_with_style,
@@ -233,11 +243,13 @@ from tests.fixtures.webvtt import ( # noqa: F401
233
243
  sample_webvtt_last_cue_zero_start,
234
244
  sample_webvtt_multi_lang_de,
235
245
  sample_webvtt_multi_lang_en,
246
+ sample_webvtt_negative_shift,
236
247
  sample_webvtt_no_blank_after_header,
237
248
  sample_webvtt_no_header,
238
249
  sample_webvtt_note_with_arrow,
239
250
  sample_webvtt_numeric_entities,
240
251
  sample_webvtt_output_long_cue,
252
+ sample_webvtt_overlapping_cues,
241
253
  sample_webvtt_style_with_arrow,
242
254
  sample_webvtt_timestamps,
243
255
  sample_webvtt_with_bom,
@@ -1638,3 +1638,17 @@ def sample_dfxp_with_nbsp_indentation():
1638
1638
  </div>
1639
1639
  </body>
1640
1640
  </tt>"""
1641
+
1642
+
1643
+ @pytest.fixture(scope="session")
1644
+ def sample_dfxp_overlapping_cues():
1645
+ return """\
1646
+ <?xml version="1.0" encoding="utf-8"?>
1647
+ <tt xml:lang="en" xmlns="http://www.w3.org/ns/ttml">
1648
+ <body>
1649
+ <div xml:lang="en-US">
1650
+ <p begin="00:00:00.500" end="00:00:03.000">A</p>
1651
+ <p begin="00:00:01.000" end="00:00:01.800">B</p>
1652
+ </div>
1653
+ </body>
1654
+ </tt>"""
@@ -343,6 +343,124 @@ Transcribed by Celestials
343
343
  """
344
344
 
345
345
 
346
+ @pytest.fixture(scope="session")
347
+ def sample_webvtt_empty_cue_then_id():
348
+ return """WEBVTT
349
+
350
+ cue-1
351
+ 00:00.000 --> 00:02.000
352
+
353
+ cue-2
354
+ 00:04.000 --> 00:05.000
355
+ C
356
+ """
357
+
358
+
359
+ @pytest.fixture(scope="session")
360
+ def sample_webvtt_empty_cue_then_note():
361
+ return """WEBVTT
362
+
363
+ 00:00.000 --> 00:02.000
364
+
365
+ NOTE hello
366
+
367
+ 00:04.000 --> 00:05.000
368
+ C
369
+ """
370
+
371
+
372
+ @pytest.fixture(scope="session")
373
+ def sample_webvtt_empty_cue_then_style():
374
+ return """WEBVTT
375
+
376
+ 00:00.000 --> 00:02.000
377
+
378
+ STYLE
379
+ ::cue { color: red }
380
+
381
+ 00:04.000 --> 00:05.000
382
+ C
383
+ """
384
+
385
+
386
+ @pytest.fixture(scope="session")
387
+ def sample_webvtt_empty_cue_then_region():
388
+ return """WEBVTT
389
+
390
+ 00:00.000 --> 00:02.000
391
+
392
+ REGION
393
+ id:r1
394
+
395
+ 00:04.000 --> 00:05.000
396
+ C
397
+ """
398
+
399
+
400
+ @pytest.fixture(scope="session")
401
+ def sample_webvtt_empty_cue_multiple_blanks():
402
+ return """WEBVTT
403
+
404
+ 00:00.000 --> 00:02.000
405
+
406
+
407
+
408
+ cue-2
409
+ 00:04.000 --> 00:05.000
410
+ C
411
+ """
412
+
413
+
414
+ @pytest.fixture(scope="session")
415
+ def sample_webvtt_empty_last_cue():
416
+ return """WEBVTT
417
+
418
+ cue-1
419
+ 00:00.000 --> 00:02.000
420
+ A
421
+
422
+ cue-2
423
+ 00:03.000 --> 00:04.000
424
+ """
425
+
426
+
427
+ @pytest.fixture(scope="session")
428
+ def sample_webvtt_empty_cue_then_duplicate_id():
429
+ return """WEBVTT
430
+
431
+ cue-1
432
+ 00:00.000 --> 00:02.000
433
+
434
+ cue-1
435
+ 00:04.000 --> 00:05.000
436
+ C
437
+ """
438
+
439
+
440
+ @pytest.fixture(scope="session")
441
+ def sample_webvtt_empty_cue_then_orphan_text():
442
+ return """WEBVTT
443
+
444
+ 00:00.000 --> 00:02.000
445
+
446
+ hello
447
+ world
448
+
449
+ 00:04.000 --> 00:05.000
450
+ C
451
+ """
452
+
453
+
454
+ @pytest.fixture(scope="session")
455
+ def sample_webvtt_empty_cue_only_orphan_text():
456
+ return """WEBVTT
457
+
458
+ 00:00.000 --> 00:02.000
459
+
460
+ hello
461
+ """
462
+
463
+
346
464
  @pytest.fixture(scope="session")
347
465
  def sample_webvtt_multi_lang_en():
348
466
  return """WEBVTT
@@ -594,3 +712,29 @@ def sample_webvtt_style_with_arrow():
594
712
  @pytest.fixture(scope="session")
595
713
  def sample_webvtt_numeric_entities():
596
714
  return "WEBVTT\n\n" "00:00:01.000 --> 00:00:03.000\n" "&#169; and &#x266B;\n"
715
+
716
+
717
+ @pytest.fixture(scope="session")
718
+ def sample_webvtt_negative_shift():
719
+ return (
720
+ "WEBVTT\n\n"
721
+ "00:00:00.500 --> 00:00:01.500\n"
722
+ "Straddles zero\n\n"
723
+ "00:00:01.000 --> 00:00:01.800\n"
724
+ "Fully before zero\n\n"
725
+ "00:00:03.000 --> 00:00:05.000\n"
726
+ "After zero\n"
727
+ )
728
+
729
+
730
+ @pytest.fixture(scope="session")
731
+ def sample_webvtt_overlapping_cues():
732
+ return (
733
+ "WEBVTT\n\n"
734
+ "00:00:00.500 --> 00:00:01.500\n"
735
+ "A\n\n"
736
+ "00:00:01.000 --> 00:00:01.800\n"
737
+ "B\n\n"
738
+ "00:00:03.000 --> 00:00:05.000\n"
739
+ "C\n"
740
+ )
@@ -4,6 +4,7 @@ import pytest
4
4
 
5
5
  from bs4 import BeautifulSoup
6
6
 
7
+ from pycaption import SAMIReader
7
8
  from pycaption.exceptions import InvalidInputError
8
9
 
9
10
 
@@ -176,6 +177,24 @@ class SAMITestingMixIn:
176
177
 
177
178
  assert self._strip_blanks(first_items) == self._strip_blanks(second_items)
178
179
 
180
+ def assert_sami_syncs_equal(self, sami, expected):
181
+ """Assert the SYNCs as (start, [(class, text), ...]); "" is a blank.
182
+
183
+ Start values must strictly increase, and the output must read back.
184
+ """
185
+ syncs = [
186
+ (
187
+ int(sync["start"]),
188
+ [(" ".join(p["class"]), p.text.strip()) for p in sync.find_all("p")],
189
+ )
190
+ for sync in BeautifulSoup(sami, "lxml").select("sync")
191
+ ]
192
+ starts = [start for start, _ in syncs]
193
+
194
+ assert starts == sorted(set(starts))
195
+ assert syncs == expected
196
+ SAMIReader().read(sami)
197
+
179
198
 
180
199
  class MicroDVDTestingMixIn:
181
200
  """
@@ -11,7 +11,7 @@ from pycaption import (
11
11
  from pycaption.base import Caption, CaptionList, CaptionNode, CaptionSet
12
12
  from pycaption.exceptions import CaptionReadTimingError
13
13
  from pycaption.geometry import HorizontalAlignmentEnum, Size, UnitEnum # noqa
14
- from tests.mixins import ReaderTestingMixIn
14
+ from tests.mixins import ReaderTestingMixIn, SAMITestingMixIn
15
15
 
16
16
 
17
17
  class TestSAMIReader(ReaderTestingMixIn):
@@ -269,3 +269,124 @@ class TestSAMIWriterNbspIndentation:
269
269
  '"The Story Knife."',
270
270
  "\xa0\xa0Part one",
271
271
  ]
272
+
273
+
274
+ def _captions(*cues):
275
+ """Build a CaptionList from (start_ms, end_ms, text) tuples."""
276
+ return CaptionList(
277
+ [
278
+ Caption(start * 1000, end * 1000, [CaptionNode.create_text(text)])
279
+ for start, end, text in cues
280
+ ]
281
+ )
282
+
283
+
284
+ PARTIAL_OVERLAP = [(500, 1500, "A"), (1000, 1800, "B"), (3000, 5000, "C")]
285
+ TOUCHING = [(500, 1000, "A"), (1000, 1800, "B")]
286
+
287
+
288
+ class TestSAMIWriterOverlappingCues(SAMITestingMixIn):
289
+ """Overlapping cues are merged so SYNC start values never decrease."""
290
+
291
+ def _write(self, **languages):
292
+ return SAMIWriter().write(
293
+ CaptionSet(
294
+ {
295
+ lang.replace("_", "-"): _captions(*cues)
296
+ for lang, cues in languages.items()
297
+ }
298
+ )
299
+ )
300
+
301
+ def test_partial_overlap(self):
302
+ sami = self._write(en_US=PARTIAL_OVERLAP)
303
+
304
+ self.assert_sami_syncs_equal(
305
+ sami,
306
+ [
307
+ (500, [("en-US", "A")]),
308
+ (1000, [("en-US", "A"), ("en-US", "B")]),
309
+ (1500, [("en-US", "B")]),
310
+ (1800, [("en-US", "")]),
311
+ (3000, [("en-US", "C")]),
312
+ (5000, [("en-US", "")]),
313
+ ],
314
+ )
315
+
316
+ def test_nested_overlap(self):
317
+ sami = self._write(en_US=[(500, 3000, "A"), (1000, 1800, "B")])
318
+
319
+ self.assert_sami_syncs_equal(
320
+ sami,
321
+ [
322
+ (500, [("en-US", "A")]),
323
+ (1000, [("en-US", "A"), ("en-US", "B")]),
324
+ (1800, [("en-US", "A")]),
325
+ (3000, [("en-US", "")]),
326
+ ],
327
+ )
328
+
329
+ def test_same_start(self):
330
+ sami = self._write(en_US=[(0, 500, "A"), (0, 800, "B")])
331
+
332
+ self.assert_sami_syncs_equal(
333
+ sami,
334
+ [
335
+ (0, [("en-US", "A"), ("en-US", "B")]),
336
+ (500, [("en-US", "B")]),
337
+ (800, [("en-US", "")]),
338
+ ],
339
+ )
340
+
341
+ def test_touching_cues_are_unchanged(self):
342
+ sami = self._write(en_US=TOUCHING)
343
+
344
+ self.assert_sami_syncs_equal(
345
+ sami,
346
+ [
347
+ (500, [("en-US", "A")]),
348
+ (1000, [("en-US", "B")]),
349
+ (1800, [("en-US", "")]),
350
+ ],
351
+ )
352
+
353
+ def test_overlap_only_in_second_language(self):
354
+ sami = self._write(en_US=TOUCHING, de_DE=PARTIAL_OVERLAP)
355
+
356
+ self.assert_sami_syncs_equal(
357
+ sami,
358
+ [
359
+ (500, [("en-US", "A"), ("de-DE", "A")]),
360
+ (1000, [("en-US", "B"), ("de-DE", "A"), ("de-DE", "B")]),
361
+ (1500, [("de-DE", "B")]),
362
+ (1800, [("en-US", ""), ("de-DE", "")]),
363
+ (3000, [("de-DE", "C")]),
364
+ (5000, [("de-DE", "")]),
365
+ ],
366
+ )
367
+
368
+ def test_cues_out_of_input_order(self):
369
+ sami = self._write(en_US=[(3000, 5000, "C"), (500, 1500, "A")])
370
+
371
+ self.assert_sami_syncs_equal(
372
+ sami,
373
+ [
374
+ (500, [("en-US", "A")]),
375
+ (1500, [("en-US", "")]),
376
+ (3000, [("en-US", "C")]),
377
+ (5000, [("en-US", "")]),
378
+ ],
379
+ )
380
+
381
+ def test_zero_length_cue_is_dropped(self):
382
+ sami = self._write(en_US=[(0, 1000, "A"), (2000, 2000, "Z"), (3000, 4000, "B")])
383
+
384
+ self.assert_sami_syncs_equal(
385
+ sami,
386
+ [
387
+ (0, [("en-US", "A")]),
388
+ (1000, [("en-US", "")]),
389
+ (3000, [("en-US", "B")]),
390
+ (4000, [("en-US", "")]),
391
+ ],
392
+ )
@@ -118,6 +118,40 @@ class TestWebVTTtoSAMI(SAMITestingMixIn):
118
118
  self.assert_sami_captions_equal(sample_sami, result)
119
119
 
120
120
 
121
+ class TestOverlappingCuesToSAMI(SAMITestingMixIn):
122
+ def test_overlapping_webvtt_gives_in_order_syncs(
123
+ self, sample_webvtt_overlapping_cues
124
+ ):
125
+ caption_set = WebVTTReader().read(sample_webvtt_overlapping_cues)
126
+ result = SAMIWriter().write(caption_set)
127
+
128
+ self.assert_sami_syncs_equal(
129
+ result,
130
+ [
131
+ (500, [("en-US", "A")]),
132
+ (1000, [("en-US", "A"), ("en-US", "B")]),
133
+ (1500, [("en-US", "B")]),
134
+ (1800, [("en-US", "")]),
135
+ (3000, [("en-US", "C")]),
136
+ (5000, [("en-US", "")]),
137
+ ],
138
+ )
139
+
140
+ def test_overlapping_dfxp_gives_in_order_syncs(self, sample_dfxp_overlapping_cues):
141
+ caption_set = DFXPReader().read(sample_dfxp_overlapping_cues)
142
+ result = SAMIWriter().write(caption_set)
143
+
144
+ self.assert_sami_syncs_equal(
145
+ result,
146
+ [
147
+ (500, [("en-US", "A")]),
148
+ (1000, [("en-US", "A"), ("en-US", "B")]),
149
+ (1800, [("en-US", "A")]),
150
+ (3000, [("en-US", "")]),
151
+ ],
152
+ )
153
+
154
+
121
155
  class TestWebVTTtoSAMIStyles(SAMITestingMixIn):
122
156
  """Tests for WebVTT structured style/layout → SAMI conversion."""
123
157
 
@@ -79,6 +79,115 @@ class TestWebVTTReader(ReaderTestingMixIn):
79
79
  assert cue.start == 16985000
80
80
  assert cue.end == 18737000
81
81
 
82
+ def test_negative_shift_clamps_cue_crossing_zero(
83
+ self, sample_webvtt_negative_shift
84
+ ):
85
+ captions = WebVTTReader(time_shift_milliseconds=-1000).read(
86
+ sample_webvtt_negative_shift
87
+ )
88
+ cue = captions.get_captions("en-US")[0]
89
+
90
+ assert cue.get_text() == "Straddles zero"
91
+ assert cue.start == 0
92
+ assert cue.end == 500000
93
+
94
+ def test_negative_shift_keeps_cue_starting_exactly_at_zero(
95
+ self, sample_webvtt_negative_shift
96
+ ):
97
+ captions = WebVTTReader(time_shift_milliseconds=-1000).read(
98
+ sample_webvtt_negative_shift
99
+ )
100
+ cue = captions.get_captions("en-US")[1]
101
+
102
+ assert cue.get_text() == "Fully before zero"
103
+ assert cue.start == 0
104
+ assert cue.end == 800000
105
+
106
+ def test_negative_shift_drops_cues_ending_at_or_before_zero(
107
+ self, sample_webvtt_negative_shift
108
+ ):
109
+ # -1500: "Straddles zero" ends exactly at 0, "Fully before zero"
110
+ # crosses 0. -2000: both end before 0.
111
+ captions = WebVTTReader(time_shift_milliseconds=-1500).read(
112
+ sample_webvtt_negative_shift
113
+ )
114
+ assert [c.get_text() for c in captions.get_captions("en-US")] == [
115
+ "Fully before zero",
116
+ "After zero",
117
+ ]
118
+
119
+ captions = WebVTTReader(time_shift_milliseconds=-2000).read(
120
+ sample_webvtt_negative_shift
121
+ )
122
+ cues = captions.get_captions("en-US")
123
+
124
+ assert [c.get_text() for c in cues] == ["After zero"]
125
+ assert cues[0].start == 1000000
126
+ assert cues[0].end == 3000000
127
+
128
+ def test_negative_shift_leaves_cue_after_zero_unchanged(
129
+ self, sample_webvtt_negative_shift
130
+ ):
131
+ captions = WebVTTReader(time_shift_milliseconds=-1000).read(
132
+ sample_webvtt_negative_shift
133
+ )
134
+ cue = captions.get_captions("en-US")[2]
135
+
136
+ assert cue.start == 2000000
137
+ assert cue.end == 4000000
138
+
139
+ def test_negative_shift_drops_last_cue_without_trailing_blank_line(self):
140
+ captions = WebVTTReader(time_shift_milliseconds=-3500).read(
141
+ "WEBVTT\n\n00:00:03.000 --> 00:00:04.000\nKept\n\n"
142
+ "00:00:03.000 --> 00:00:03.500\nDropped"
143
+ )
144
+
145
+ assert [c.get_text() for c in captions.get_captions("en-US")] == ["Kept"]
146
+
147
+ def test_negative_shift_dropping_every_cue_raises_no_captions(
148
+ self, sample_webvtt_negative_shift
149
+ ):
150
+ with pytest.raises(CaptionReadNoCaptions):
151
+ WebVTTReader(time_shift_milliseconds=-5000).read(
152
+ sample_webvtt_negative_shift
153
+ )
154
+
155
+ def test_negative_shift_clamping_passes_strict_timing_validation(
156
+ self, sample_webvtt_negative_shift
157
+ ):
158
+ captions = WebVTTReader(
159
+ ignore_timing_errors=False, time_shift_milliseconds=-1000
160
+ ).read(sample_webvtt_negative_shift)
161
+
162
+ assert [c.start for c in captions.get_captions("en-US")] == [
163
+ 0,
164
+ 0,
165
+ 2000000,
166
+ ]
167
+
168
+ def test_negative_shift_still_rejects_inverted_dropped_cue(self):
169
+ with pytest.raises(CaptionReadError):
170
+ WebVTTReader(
171
+ ignore_timing_errors=False, time_shift_milliseconds=-5000
172
+ ).read("WEBVTT\n\n00:00:02.000 --> 00:00:01.000\nInverted\n")
173
+
174
+ def test_negative_float_shift_clamps(self, sample_webvtt_negative_shift):
175
+ captions = WebVTTReader(time_shift_milliseconds=-1000.5).read(
176
+ sample_webvtt_negative_shift
177
+ )
178
+ cues = captions.get_captions("en-US")
179
+
180
+ assert cues[0].start == 0
181
+ assert cues[0].end == 499500
182
+ assert cues[1].start == 0
183
+
184
+ def test_zero_shift_keeps_cue_at_zero(self):
185
+ captions = WebVTTReader(time_shift_milliseconds=0).read(
186
+ "WEBVTT\n\n00:00:00.000 --> 00:00:00.000\nZero length\n"
187
+ )
188
+
189
+ assert len(captions.get_captions("en-US")) == 1
190
+
82
191
  def test_webvtt_cue_components_parsed_from_text(self):
83
192
  nodes = self.reader._parse_cue_text(
84
193
  "<c vIntro><b>Wikipedia</b> is a great adventure. <i>It may have "
@@ -189,6 +298,74 @@ class TestWebVTTReader(ReaderTestingMixIn):
189
298
  def test_webvtt_empty_cue(self, sample_webvtt_empty_cue):
190
299
  assert 1 == len(self.reader.read(sample_webvtt_empty_cue).get_captions("en-US"))
191
300
 
301
+ @pytest.mark.parametrize("ignore_timing_errors", [True, False])
302
+ @pytest.mark.parametrize(
303
+ "fixture_name",
304
+ [
305
+ "sample_webvtt_empty_cue_then_id",
306
+ "sample_webvtt_empty_cue_then_note",
307
+ "sample_webvtt_empty_cue_then_style",
308
+ "sample_webvtt_empty_cue_then_region",
309
+ "sample_webvtt_empty_cue_multiple_blanks",
310
+ ],
311
+ )
312
+ def test_blank_line_closes_empty_cue(
313
+ self, request, fixture_name, ignore_timing_errors
314
+ ):
315
+ captions = WebVTTReader(ignore_timing_errors=ignore_timing_errors).read(
316
+ request.getfixturevalue(fixture_name)
317
+ )
318
+
319
+ assert [
320
+ (c.start, c.end, c.get_text()) for c in captions.get_captions("en-US")
321
+ ] == [(4000000, 5000000, "C")]
322
+
323
+ def test_empty_last_cue_is_dropped(self, sample_webvtt_empty_last_cue):
324
+ captions = self.reader.read(sample_webvtt_empty_last_cue)
325
+
326
+ assert [
327
+ (c.start, c.end, c.get_text()) for c in captions.get_captions("en-US")
328
+ ] == [(0, 2000000, "A")]
329
+
330
+ @pytest.mark.parametrize("ignore_timing_errors", [True, False])
331
+ def test_empty_cue_id_counts_for_duplicate_warning(
332
+ self, sample_webvtt_empty_cue_then_duplicate_id, ignore_timing_errors
333
+ ):
334
+ reader = WebVTTReader(ignore_timing_errors=ignore_timing_errors)
335
+ with pytest.warns(CaptionReadWarning, match="Duplicate cue identifier 'cue-1'"):
336
+ captions = reader.read(sample_webvtt_empty_cue_then_duplicate_id)
337
+
338
+ assert [c.get_text() for c in captions.get_captions("en-US")] == ["C"]
339
+
340
+ def test_dropped_empty_cue_before_zero_is_closed(self):
341
+ captions = WebVTTReader(time_shift_milliseconds=-2000).read(
342
+ "WEBVTT\n\n00:00.000 --> 00:01.000\n\ncue-2\n00:03.000 --> 00:04.000\nC\n"
343
+ )
344
+
345
+ assert [
346
+ (c.start, c.end, c.get_text()) for c in captions.get_captions("en-US")
347
+ ] == [(1000000, 2000000, "C")]
348
+
349
+ @pytest.mark.parametrize("ignore_timing_errors", [True, False])
350
+ def test_orphan_text_after_empty_cue_is_ignored(
351
+ self, sample_webvtt_empty_cue_then_orphan_text, ignore_timing_errors
352
+ ):
353
+ captions = WebVTTReader(ignore_timing_errors=ignore_timing_errors).read(
354
+ sample_webvtt_empty_cue_then_orphan_text
355
+ )
356
+
357
+ assert [
358
+ (c.start, c.end, c.get_text()) for c in captions.get_captions("en-US")
359
+ ] == [(4000000, 5000000, "C")]
360
+
361
+ @pytest.mark.parametrize("ignore_timing_errors", [True, False])
362
+ def test_only_orphan_text_after_empty_cue_raises_no_captions(
363
+ self, sample_webvtt_empty_cue_only_orphan_text, ignore_timing_errors
364
+ ):
365
+ reader = WebVTTReader(ignore_timing_errors=ignore_timing_errors)
366
+ with pytest.raises(CaptionReadNoCaptions):
367
+ reader.read(sample_webvtt_empty_cue_only_orphan_text)
368
+
192
369
 
193
370
  class TestWebVTTWriter:
194
371
  def setup_method(self):
@@ -2,6 +2,8 @@ import re
2
2
 
3
3
  from xml.etree import ElementTree
4
4
 
5
+ import pytest
6
+
5
7
  from pycaption import (
6
8
  DFXPReader,
7
9
  DFXPWriter,
@@ -108,6 +110,30 @@ class TestWebVTTtoWebVTT(WebVTTTestingMixIn):
108
110
  assert re.match(expected_timestamp_line_pattern, results[5])
109
111
 
110
112
 
113
+ class TestWebVTTEmptyCueConversion:
114
+ @pytest.mark.parametrize(
115
+ "writer", [WebVTTWriter, SRTWriter, DFXPWriter, SAMIWriter]
116
+ )
117
+ @pytest.mark.parametrize("ignore_timing_errors", [True, False])
118
+ @pytest.mark.parametrize(
119
+ "fixture_name, leaked_text",
120
+ [
121
+ ("sample_webvtt_empty_cue_then_id", "cue-2"),
122
+ ("sample_webvtt_empty_cue_then_note", "hello"),
123
+ ("sample_webvtt_empty_cue_multiple_blanks", "cue-2"),
124
+ ],
125
+ )
126
+ def test_no_leak_after_empty_cue(
127
+ self, request, fixture_name, leaked_text, ignore_timing_errors, writer
128
+ ):
129
+ caption_set = WebVTTReader(ignore_timing_errors=ignore_timing_errors).read(
130
+ request.getfixturevalue(fixture_name)
131
+ )
132
+ results = writer().write(caption_set)
133
+
134
+ assert leaked_text not in results
135
+
136
+
111
137
  # # TODO: Write a test that includes a WebVTT file with style tags
112
138
  # # That will fail because the styles used in the cues are not tracked.
113
139
 
@@ -1383,3 +1409,31 @@ class TestMultipleLayoutsPerCaption:
1383
1409
  assert len(cues) == 2
1384
1410
  assert cues[0][1] == ["plain"]
1385
1411
  assert cues[1][1] == ["styled<i>"]
1412
+
1413
+
1414
+ class TestWebVTTNegativeTimeShiftConversion:
1415
+ @pytest.mark.parametrize("shift", [-1000, -1500, -2000])
1416
+ @pytest.mark.parametrize(
1417
+ "writer", [WebVTTWriter, SRTWriter, DFXPWriter, SAMIWriter]
1418
+ )
1419
+ def test_no_wrapped_timestamp_written(
1420
+ self, sample_webvtt_negative_shift, writer, shift
1421
+ ):
1422
+ caption_set = WebVTTReader(time_shift_milliseconds=shift).read(
1423
+ sample_webvtt_negative_shift
1424
+ )
1425
+ output = writer().write(caption_set)
1426
+
1427
+ assert "23:59:" not in output
1428
+ assert 'start="-' not in output
1429
+
1430
+ @pytest.mark.parametrize("shift", [-1000, -1500, -2000])
1431
+ def test_webvtt_output_end_after_start(self, sample_webvtt_negative_shift, shift):
1432
+ caption_set = WebVTTReader(time_shift_milliseconds=shift).read(
1433
+ sample_webvtt_negative_shift
1434
+ )
1435
+ output = WebVTTWriter().write(caption_set)
1436
+ timings = re.findall(r"(\S+) --> (\S+)", output)
1437
+
1438
+ assert timings
1439
+ assert all(start < end for start, end in timings)
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