pycaption 2.2.25__tar.gz → 2.2.26__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 (58) hide show
  1. {pycaption-2.2.25/pycaption.egg-info → pycaption-2.2.26}/PKG-INFO +1 -1
  2. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/geometry.py +10 -4
  3. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/specialized_collections.py +10 -6
  4. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/webvtt.py +170 -13
  5. {pycaption-2.2.25 → pycaption-2.2.26/pycaption.egg-info}/PKG-INFO +1 -1
  6. {pycaption-2.2.25 → pycaption-2.2.26}/setup.py +1 -1
  7. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_scc.py +4 -4
  8. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_webvtt.py +186 -12
  9. {pycaption-2.2.25 → pycaption-2.2.26}/LICENSE +0 -0
  10. {pycaption-2.2.25 → pycaption-2.2.26}/MANIFEST.in +0 -0
  11. {pycaption-2.2.25 → pycaption-2.2.26}/README.rst +0 -0
  12. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/__init__.py +0 -0
  13. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/base.py +0 -0
  14. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/dfxp/__init__.py +0 -0
  15. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/dfxp/base.py +0 -0
  16. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/dfxp/extras.py +0 -0
  17. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/exceptions.py +0 -0
  18. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/microdvd.py +0 -0
  19. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/sami.py +0 -0
  20. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/__init__.py +0 -0
  21. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/constants.py +0 -0
  22. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/state_machines.py +0 -0
  23. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/translator.py +0 -0
  24. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/srt.py +0 -0
  25. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/transcript.py +0 -0
  26. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/utils.py +0 -0
  27. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption.egg-info/SOURCES.txt +0 -0
  28. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption.egg-info/dependency_links.txt +0 -0
  29. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption.egg-info/requires.txt +0 -0
  30. {pycaption-2.2.25 → pycaption-2.2.26}/pycaption.egg-info/top_level.txt +0 -0
  31. {pycaption-2.2.25 → pycaption-2.2.26}/setup.cfg +0 -0
  32. {pycaption-2.2.25 → pycaption-2.2.26}/tests/__init__.py +0 -0
  33. {pycaption-2.2.25 → pycaption-2.2.26}/tests/conftest.py +0 -0
  34. {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/__init__.py +0 -0
  35. {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/dfxp.py +0 -0
  36. {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/microdvd.py +0 -0
  37. {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/sami.py +0 -0
  38. {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/scc.py +0 -0
  39. {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/srt.py +0 -0
  40. {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/translated_scc.py +0 -0
  41. {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/webvtt.py +0 -0
  42. {pycaption-2.2.25 → pycaption-2.2.26}/tests/mixins.py +0 -0
  43. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_base.py +0 -0
  44. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_dfxp.py +0 -0
  45. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_dfxp_conversion.py +0 -0
  46. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_dfxp_extras.py +0 -0
  47. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_functions.py +0 -0
  48. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_geometry.py +0 -0
  49. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_microdvd.py +0 -0
  50. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_microdvd_conversion.py +0 -0
  51. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_sami.py +0 -0
  52. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_sami_conversion.py +0 -0
  53. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_scc_conversion.py +0 -0
  54. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_scc_translator.py +0 -0
  55. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_scc_writer.py +0 -0
  56. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_srt.py +0 -0
  57. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_srt_conversion.py +0 -0
  58. {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_webvtt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.2.25
3
+ Version: 2.2.26
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -213,9 +213,15 @@ class Stretch(TwoDimensionalObject):
213
213
 
214
214
 
215
215
  class Region:
216
- """Represents the spatial coordinates of a rectangle
217
-
218
- Don't instantiate by hand. use Region.from_points or Region.from_extent
216
+ """Represents the spatial coordinates of a rectangle.
217
+
218
+ NOTE: This class is currently dead code — not imported or used anywhere in
219
+ the codebase. It also contains known bugs (align_from_origin returns
220
+ inconsistent types, add_extent method does not exist on Point). We left it
221
+ here because it may serve as a starting point if we ever need geometric
222
+ rectangle operations beyond what Layout class provides (e.g. region
223
+ intersection or point-in-region checks).
224
+ If we'll ever need it, fix the bugs first.
219
225
  """
220
226
 
221
227
  @classmethod
@@ -846,7 +852,7 @@ class Layout:
846
852
  found_absolute_unit = False
847
853
  if bottom_right.x.unit != UnitEnum.PERCENT:
848
854
  found_absolute_unit = True
849
- elif bottom_right.x.unit != UnitEnum.PERCENT:
855
+ elif bottom_right.y.unit != UnitEnum.PERCENT:
850
856
  found_absolute_unit = True
851
857
 
852
858
  if found_absolute_unit:
@@ -26,6 +26,9 @@ from .constants import (
26
26
 
27
27
  PopOnCue = collections.namedtuple("PopOnCue", "buffer, start, end")
28
28
 
29
+ # First two hex chars of SCC codes that produce punctuation ['.', '!', '?', ',']
30
+ _PUNCTUATION_PREFIXES = frozenset(["ae", "a1", "bf", "2c"])
31
+
29
32
 
30
33
  class PreCaption:
31
34
  """
@@ -444,8 +447,7 @@ class InstructionNodeCreator:
444
447
  # handle mid-row codes that follows a text node
445
448
  # don't add space if the next command adds one of
446
449
  # ['.', '!', '?', ',']
447
- punctuation = ["ae", "a1", "bf", "2c"]
448
- next_is_punctuation = next_command and next_command[:2] in punctuation
450
+ next_is_punctuation = next_command and next_command[:2] in _PUNCTUATION_PREFIXES
449
451
  prev_text_node = self.get_previous_text_node()
450
452
  prev_node_is_break = prev_text_node is not None and any(
451
453
  x.is_explicit_break()
@@ -501,9 +503,9 @@ class InstructionNodeCreator:
501
503
  if len(collection) == 0:
502
504
  return False
503
505
  for element in collection[::-1]:
504
- if element._type == 0:
506
+ if element._type == _InstructionNode.TEXT:
505
507
  return False
506
- elif element._type == 1:
508
+ elif element._type == _InstructionNode.BREAK:
507
509
  return True
508
510
  return False
509
511
 
@@ -782,6 +784,8 @@ def _format_italics(collection):
782
784
 
783
785
 
784
786
  def _remove_spaces_at_end_of_the_line(collection):
787
+ if not collection:
788
+ return collection
785
789
  for idx, node in enumerate(collection):
786
790
  if (
787
791
  idx > 0
@@ -826,7 +830,7 @@ def _remove_noop_on_off_italics(collection):
826
830
  return new_collection
827
831
 
828
832
 
829
- def _remove_noon_off_on_italics(collection):
833
+ def _remove_noop_off_on_italics(collection):
830
834
  """Removes pairs of off-on italics nodes, that don't surround any other
831
835
  node
832
836
 
@@ -867,7 +871,7 @@ def _remove_noop_italics(collection):
867
871
  """
868
872
  new_collection = _remove_noop_on_off_italics(collection)
869
873
 
870
- new_collection = _remove_noon_off_on_italics(new_collection)
874
+ new_collection = _remove_noop_off_on_italics(new_collection)
871
875
 
872
876
  return new_collection
873
877
 
@@ -10,7 +10,7 @@ from .exceptions import (
10
10
  CaptionReadSyntaxError,
11
11
  InvalidInputError,
12
12
  )
13
- from .geometry import HorizontalAlignmentEnum, Layout
13
+ from .geometry import HorizontalAlignmentEnum, Layout, Point, Size, Stretch, UnitEnum
14
14
 
15
15
  # A WebVTT timing line has both start/end times and layout related settings
16
16
  # (referred to as 'cue settings' in the documentation)
@@ -21,6 +21,19 @@ VOICE_SPAN_PATTERN = re.compile("<v(\\.\\w+)* ([^>]*)>")
21
21
  OTHER_SPAN_PATTERN = re.compile(
22
22
  r"</?([cibuv]|ruby|rt|lang|(\d+):(\d{2})(:\d{2})?\.(\d{3})).*?>"
23
23
  ) # These WebVTT tags are stripped off the cues on conversion
24
+ REGION_SETTING_PATTERN = re.compile(r"^([\w]+):(.+)$")
25
+ """
26
+ Matches a setting name (word chars) followed by colon and a value:
27
+ id:region1
28
+ width:50%
29
+ """
30
+ REGION_ANCHOR_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)%,(\d+(?:\.\d+)?)%$")
31
+ """
32
+ Matches two percentage values (integer or decimal) separated by a comma:
33
+ 0%,0%
34
+ 100%,100%
35
+ """
36
+ LINE_HEIGHT_VH = 5.33
24
37
 
25
38
  WEBVTT_VERSION_OF = {
26
39
  HorizontalAlignmentEnum.LEFT: "left",
@@ -69,6 +82,8 @@ class WebVTTReader(BaseReader):
69
82
  return caption_set
70
83
 
71
84
  def _parse(self, lines):
85
+ # State machine: cycles through waiting-for-timing → collecting-text
86
+ # → emit-caption-on-blank-line, repeat.
72
87
  captions = CaptionList()
73
88
  start = None
74
89
  end = None
@@ -76,8 +91,13 @@ class WebVTTReader(BaseReader):
76
91
  layout_info = None
77
92
  found_timing = False
78
93
 
94
+ # Parse REGION blocks from the header area before processing cues
95
+ self._regions = self._parse_regions(lines)
96
+
79
97
  for i, line in enumerate(lines):
80
98
  if "-->" in line:
99
+ # Timing line found (e.g. "00:00:01.000 --> 00:00:03.000")
100
+ # marks the start of a new cue
81
101
  found_timing = True
82
102
  timing_line = i
83
103
  last_start_time = captions[-1].start if captions else 0
@@ -91,6 +111,8 @@ class WebVTTReader(BaseReader):
91
111
  raise type(e)(new_msg).with_traceback(tb) from None
92
112
 
93
113
  elif "" == line:
114
+ # Blank line = block separator in WebVTT.
115
+ # If we were collecting a cue, finalize and store it.
94
116
  if found_timing and nodes:
95
117
  found_timing = False
96
118
  caption = Caption(start, end, nodes, layout_info=layout_info)
@@ -98,24 +120,23 @@ class WebVTTReader(BaseReader):
98
120
  nodes = []
99
121
  else:
100
122
  if found_timing:
123
+ # We're inside a cue — this line is cue text.
124
+ # Add a line break between multi-line cue text.
101
125
  if nodes:
102
126
  nodes.append(CaptionNode.create_break())
103
127
  nodes.append(CaptionNode.create_text(self._decode(line)))
104
128
  else:
105
- # it's a comment or some metadata; ignore it
129
+ # Outside a cue: cue identifiers, NOTE blocks,
130
+ # or other metadata — skip silently.
106
131
  pass
107
132
 
108
- # Add a last caption if there are remaining nodes
133
+ # File may not end with a blank line emit any remaining cue
109
134
  if nodes:
110
135
  caption = Caption(start, end, nodes, layout_info=layout_info)
111
136
  captions.append(caption)
112
137
 
113
138
  return captions
114
139
 
115
- def _remove_styles(self, line):
116
- partial_result = VOICE_SPAN_PATTERN.sub("\\2: ", line)
117
- return OTHER_SPAN_PATTERN.sub("", partial_result)
118
-
119
140
  def _validate_timings(self, start, end, last_start_time):
120
141
  if start is None:
121
142
  raise CaptionReadSyntaxError("Invalid cue start timestamp.")
@@ -147,7 +168,18 @@ class WebVTTReader(BaseReader):
147
168
 
148
169
  layout_info = None
149
170
  if cue_settings:
150
- layout_info = Layout(webvtt_positioning=cue_settings)
171
+ region_id = self._extract_region_id(cue_settings)
172
+ if region_id and region_id in self._regions:
173
+ layout_info = self._regions[region_id]
174
+ layout_info = Layout(
175
+ origin=layout_info.origin,
176
+ extent=layout_info.extent,
177
+ padding=layout_info.padding,
178
+ alignment=layout_info.alignment,
179
+ webvtt_positioning=cue_settings,
180
+ )
181
+ else:
182
+ layout_info = Layout(webvtt_positioning=cue_settings)
151
183
 
152
184
  return start, end, layout_info
153
185
 
@@ -189,12 +221,138 @@ class WebVTTReader(BaseReader):
189
221
  s = s.replace("&amp;", "&")
190
222
  return s
191
223
 
224
+ def _parse_regions(self, lines):
225
+ """Parse REGION blocks from the file header area.
226
+
227
+ A WebVTT region defines a named rectangular area on screen where cues
228
+ can be rendered. Regions appear before any cues with the syntax:
229
+
230
+ REGION
231
+ id:subtitle_area
232
+ width:50%
233
+ lines:3
234
+ regionanchor:0%,100%
235
+ viewportanchor:10%,90%
236
+ scroll:up
237
+
238
+ Supported settings:
239
+ id - unique identifier (required)
240
+ width - region width as percentage (default: 100%)
241
+ lines - visible line count (default: 3)
242
+ regionanchor - anchor point within region as x%,y% (default: 0%,100%)
243
+ viewportanchor - anchor point on viewport as x%,y% (default: 0%,100%)
244
+ scroll - scroll behavior, only "up" is valid (default: none)
245
+
246
+ :returns: dict mapping region id -> Layout
247
+ """
248
+ regions = {}
249
+ i = 0
250
+ while i < len(lines):
251
+ line = lines[i].strip()
252
+ if line == "REGION" or line.startswith(("REGION\t", "REGION ")):
253
+ i += 1
254
+ settings = {}
255
+ seen_keys = set()
256
+ # Read settings until a blank line (block separator in WebVTT)
257
+ while i < len(lines) and lines[i].strip() != "":
258
+ # Match key:value pair (e.g. "width:50%")
259
+ m = REGION_SETTING_PATTERN.match(lines[i].strip())
260
+ if m:
261
+ key, value = m.group(1), m.group(2)
262
+ if key in seen_keys:
263
+ i += 1
264
+ continue
265
+ seen_keys.add(key)
266
+ settings[key] = value
267
+ i += 1
268
+ # Skip regions without id (spec requires it; cues can't reference them)
269
+ if "id" in settings:
270
+ region_id = settings["id"]
271
+ # First definition wins; duplicates are ignored (RULE-REG-009)
272
+ if region_id not in regions:
273
+ regions[region_id] = self._region_to_layout(settings)
274
+ elif "-->" in line:
275
+ # REGIONs only appear before cues; stop scanning once cues begin
276
+ break
277
+ else:
278
+ i += 1
279
+ return regions
280
+
281
+ def _region_to_layout(self, settings):
282
+ """Convert parsed region settings dict into a Layout with origin/extent.
283
+
284
+ Uses W3C TTML-WebVTT mapping formulas:
285
+ origin_x = viewportanchor_x - (regionanchor_x / 100 * width)
286
+ origin_y = viewportanchor_y - (regionanchor_y / 100 * height)
287
+ height = lines * 5.33
288
+ """
289
+ # Spec defaults per W3C WebVTT §6
290
+ width = 100.0
291
+ lines = 3
292
+ regionanchor_x, regionanchor_y = 0.0, 100.0
293
+ viewportanchor_x, viewportanchor_y = 0.0, 100.0
294
+ # Parse each setting, falling back to defaults on invalid values
295
+ if "width" in settings:
296
+ try:
297
+ width = float(settings["width"].rstrip("%"))
298
+ except ValueError:
299
+ pass
300
+
301
+ if "lines" in settings:
302
+ try:
303
+ lines = int(settings["lines"])
304
+ except ValueError:
305
+ pass
306
+
307
+ # regionanchor: which point inside the region is "pinned"
308
+ # e.g. 0%,100% means the bottom-left corner of the region
309
+ if "regionanchor" in settings:
310
+ m = REGION_ANCHOR_PATTERN.match(settings["regionanchor"])
311
+ if m:
312
+ regionanchor_x = float(m.group(1))
313
+ regionanchor_y = float(m.group(2))
314
+
315
+ # viewportanchor: where on the screen that pin is placed
316
+ # e.g. 10%,90% means 10% from left, 90% from top
317
+ if "viewportanchor" in settings:
318
+ m = REGION_ANCHOR_PATTERN.match(settings["viewportanchor"])
319
+ if m:
320
+ viewportanchor_x = float(m.group(1))
321
+ viewportanchor_y = float(m.group(2))
322
+
323
+ # Calculate the top-left corner (origin) of the region box.
324
+ # Each line is ~5.33% of viewport height (LINE_HEIGHT_VH).
325
+ height = lines * LINE_HEIGHT_VH
326
+ # The origin is where the viewport anchor is, offset back by how far
327
+ # the region anchor is into the box (as a fraction of box dimensions).
328
+ origin_x = viewportanchor_x - (regionanchor_x / 100.0 * width)
329
+ origin_y = viewportanchor_y - (regionanchor_y / 100.0 * height)
330
+
331
+ origin = Point(
332
+ Size(origin_x, UnitEnum.PERCENT),
333
+ Size(origin_y, UnitEnum.PERCENT),
334
+ )
335
+ extent = Stretch(
336
+ Size(width, UnitEnum.PERCENT),
337
+ Size(height, UnitEnum.PERCENT),
338
+ )
339
+
340
+ return Layout(
341
+ origin=origin,
342
+ extent=extent
343
+ )
344
+
345
+ @staticmethod
346
+ def _extract_region_id(cue_settings):
347
+ """Extract region id from cue settings string, if present."""
348
+ for setting in cue_settings.split():
349
+ if setting.startswith("region:"):
350
+ return setting[7:]
351
+ return None
352
+
192
353
 
193
354
  class WebVTTWriter(BaseWriter):
194
355
  HEADER = "WEBVTT\n\n"
195
- global_layout = None
196
- video_width = None
197
- video_height = None
198
356
 
199
357
  def write(self, caption_set, lang=None):
200
358
  """
@@ -347,8 +505,7 @@ class WebVTTWriter(BaseWriter):
347
505
  # Since there is no padding in WebVTT, the left padding is
348
506
  # added to the total left offset (if it is defined and not
349
507
  # relative),
350
- if left_offset:
351
- left_offset += layout.padding.start
508
+ left_offset += layout.padding.start
352
509
  # and removed from the total cue width
353
510
  if cue_width:
354
511
  cue_width -= layout.padding.start
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.2.25
3
+ Version: 2.2.26
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.9.4"]
20
20
 
21
21
  setup(
22
22
  name="pycaption",
23
- version="2.2.25",
23
+ version="2.2.26",
24
24
  description="Closed caption converter",
25
25
  long_description=open(README_PATH).read(),
26
26
  author="Joe Norton",
@@ -696,10 +696,10 @@ class TestInterpretableNodeCreator:
696
696
  node_creator.interpret_command("9429")
697
697
  self.check_closing_italics_closing_on_style_change(node_creator)
698
698
 
699
- def test_remove_noon_off_on_italics(self):
699
+ def test_remove_noop_off_on_italics(self):
700
700
  from pycaption.scc.specialized_collections import (
701
701
  _InstructionNode,
702
- _remove_noon_off_on_italics,
702
+ _remove_noop_off_on_italics,
703
703
  )
704
704
 
705
705
  position_tracker = DefaultProvidingPositionTracker().default
@@ -719,7 +719,7 @@ class TestInterpretableNodeCreator:
719
719
  assert node_creator._collection[-2].sets_italics_off()
720
720
  assert node_creator._collection[-1].sets_italics_on()
721
721
 
722
- new_collection = _remove_noon_off_on_italics(node_creator._collection)
722
+ new_collection = _remove_noop_off_on_italics(node_creator._collection)
723
723
 
724
724
  # should eliminate italic tags, keep only the text node
725
725
  assert len(new_collection) == 1
@@ -739,7 +739,7 @@ class TestInterpretableNodeCreator:
739
739
  assert node_creator._collection[-2].is_text_node()
740
740
  assert node_creator._collection[-1].sets_italics_on()
741
741
 
742
- new_collection = _remove_noon_off_on_italics(node_creator._collection)
742
+ new_collection = _remove_noop_off_on_italics(node_creator._collection)
743
743
  # should not eliminate any node
744
744
  assert new_collection[-3].sets_italics_off()
745
745
  assert new_collection[-2].is_text_node()
@@ -66,7 +66,7 @@ class TestWebVTTReader(ReaderTestingMixIn):
66
66
  assert cue.end == 18737000
67
67
 
68
68
  def test_webvtt_cue_components_removed_from_text(self):
69
- result = self.reader._remove_styles(
69
+ result = self.reader._decode(
70
70
  "<c vIntro><b>Wikipedia</b> is a great adventure. <i>It may have "
71
71
  "its shortcomings</i>, but it is<u> the largest</u> collective "
72
72
  "knowledge construction endevour</c> <ruby>base text <rt>"
@@ -88,8 +88,7 @@ class TestWebVTTReader(ReaderTestingMixIn):
88
88
  # todo: same assert w/ different arguments -> this can be parametrized;
89
89
  with pytest.raises(CaptionReadError):
90
90
  WebVTTReader(ignore_timing_errors=False).read(
91
- "\n" "00:00:20.000 --> 00:00:10.000\n"
92
- "foo bar baz"
91
+ "\n" "00:00:20.000 --> 00:00:10.000\n" "foo bar baz"
93
92
  )
94
93
 
95
94
  with pytest.raises(CaptionReadError):
@@ -111,15 +110,13 @@ class TestWebVTTReader(ReaderTestingMixIn):
111
110
  # Even if timing errors are ignored, this has to raise an exception
112
111
  with pytest.raises(CaptionReadSyntaxError):
113
112
  WebVTTReader().read(
114
- "\nNOTE invalid cue stamp\n"
115
- "00:00:20.000 --> \nfoo bar baz\n"
113
+ "\nNOTE invalid cue stamp\n" "00:00:20.000 --> \nfoo bar baz\n"
116
114
  )
117
115
 
118
116
  # And this too
119
117
  with pytest.raises(CaptionReadSyntaxError):
120
118
  WebVTTReader().read(
121
- "\n00:00:20,000 --> 00:00:22,000\n"
122
- "Note the comma instead of point.\n"
119
+ "\n00:00:20,000 --> 00:00:22,000\n" "Note the comma instead of point.\n"
123
120
  )
124
121
 
125
122
  # todo: at this point it can be split into 2 separate tests
@@ -147,8 +144,7 @@ class TestWebVTTReader(ReaderTestingMixIn):
147
144
  def test_invalid_files(self):
148
145
  with pytest.raises(CaptionReadError):
149
146
  WebVTTReader(ignore_timing_errors=False).read(
150
- "00:00:20.000 --> 00:00:10.000\n"
151
- "Start time is greater than end time."
147
+ "00:00:20.000 --> 00:00:10.000\n" "Start time is greater than end time."
152
148
  )
153
149
 
154
150
  with pytest.raises(CaptionReadError):
@@ -181,9 +177,7 @@ class TestWebVTTWriter:
181
177
  assert sample_webvtt_double_br == results
182
178
 
183
179
  def test_break_node_positioning_is_ignored(
184
- self,
185
- webvtt_from_dfxp_with_conflicting_align,
186
- dfxp_style_region_align_conflict
180
+ self, webvtt_from_dfxp_with_conflicting_align, dfxp_style_region_align_conflict
187
181
  ):
188
182
  caption_set = DFXPReader().read(dfxp_style_region_align_conflict)
189
183
  results = WebVTTWriter().write(caption_set)
@@ -202,3 +196,183 @@ class TestWebVTTWriter:
202
196
  assert sample_webvtt_multi_lang_de == results
203
197
  results = WebVTTWriter().write(caption_set, "en-US")
204
198
  assert sample_webvtt_multi_lang_en == results
199
+
200
+
201
+ class TestWebVTTRegionParsing:
202
+ def setup_method(self):
203
+ self.reader = WebVTTReader()
204
+
205
+ def test_region_block_parsed_into_layout(self):
206
+ vtt = (
207
+ "WEBVTT\n\n"
208
+ "REGION\n"
209
+ "id:subtitle_area\n"
210
+ "width:50%\n"
211
+ "lines:3\n"
212
+ "regionanchor:0%,100%\n"
213
+ "viewportanchor:10%,90%\n"
214
+ "scroll:up\n\n"
215
+ "00:00:01.000 --> 00:00:03.000 region:subtitle_area\n"
216
+ "Hello world\n"
217
+ )
218
+ captions = self.reader.read(vtt)
219
+ cue = captions.get_captions("en-US")[0]
220
+ assert cue.layout_info is not None
221
+ assert cue.layout_info.origin is not None
222
+ assert cue.layout_info.extent is not None
223
+
224
+ def test_region_origin_calculation(self):
225
+ """origin_x = viewportanchor_x - (regionanchor_x / 100 * width)
226
+ origin_y = viewportanchor_y - (regionanchor_y / 100 * height)
227
+ height = lines * 5.33
228
+ """
229
+ vtt = (
230
+ "WEBVTT\n\n"
231
+ "REGION\n"
232
+ "id:box\n"
233
+ "width:50%\n"
234
+ "lines:3\n"
235
+ "regionanchor:0%,100%\n"
236
+ "viewportanchor:10%,90%\n\n"
237
+ "00:00:01.000 --> 00:00:03.000 region:box\n"
238
+ "Test\n"
239
+ )
240
+ captions = self.reader.read(vtt)
241
+ cue = captions.get_captions("en-US")[0]
242
+ layout = cue.layout_info
243
+ # origin_x = 10 - (0/100 * 50) = 10.0
244
+ assert layout.origin.x.value == pytest.approx(10.0)
245
+ # height = 3 * 5.33 = 15.99
246
+ # origin_y = 90 - (100/100 * 15.99) = 74.01
247
+ assert layout.origin.y.value == pytest.approx(74.01)
248
+ # extent = width=50%, height=15.99%
249
+ assert layout.extent.horizontal.value == pytest.approx(50.0)
250
+ assert layout.extent.vertical.value == pytest.approx(15.99)
251
+
252
+ def test_region_defaults(self):
253
+ """width=100%, lines=3, regionanchor=0%,100%, viewportanchor=0%,100%"""
254
+ vtt = (
255
+ "WEBVTT\n\n"
256
+ "REGION\n"
257
+ "id:minimal\n\n"
258
+ "00:00:01.000 --> 00:00:03.000 region:minimal\n"
259
+ "Test\n"
260
+ )
261
+ captions = self.reader.read(vtt)
262
+ cue = captions.get_captions("en-US")[0]
263
+ layout = cue.layout_info
264
+ # width=100, lines=3, height=15.99
265
+ # regionanchor=0,100 viewportanchor=0,100
266
+ # origin_x = 0 - (0/100 * 100) = 0
267
+ # origin_y = 100 - (100/100 * 15.99) = 84.01
268
+ assert layout.origin.x.value == pytest.approx(0.0)
269
+ assert layout.origin.y.value == pytest.approx(84.01)
270
+ assert layout.extent.horizontal.value == pytest.approx(100.0)
271
+ assert layout.extent.vertical.value == pytest.approx(15.99)
272
+
273
+ def test_region_webvtt_positioning_passthrough(self):
274
+ """VTT->VTT round-trip: cue settings string preserved."""
275
+ vtt = (
276
+ "WEBVTT\n\n"
277
+ "REGION\n"
278
+ "id:r1\n"
279
+ "width:50%\n\n"
280
+ "00:00:01.000 --> 00:00:03.000 region:r1\n"
281
+ "Hello\n"
282
+ )
283
+ captions = self.reader.read(vtt)
284
+ cue = captions.get_captions("en-US")[0]
285
+ assert cue.layout_info.webvtt_positioning == "region:r1"
286
+
287
+ def test_invalid_region_reference_ignored(self):
288
+ vtt = (
289
+ "WEBVTT\n\n" "00:00:01.000 --> 00:00:03.000 region:nonexistent\n" "Hello\n"
290
+ )
291
+ captions = self.reader.read(vtt)
292
+ cue = captions.get_captions("en-US")[0]
293
+ # Falls back to raw positioning passthrough
294
+ assert cue.layout_info.webvtt_positioning == "region:nonexistent"
295
+ assert cue.layout_info.origin is None
296
+
297
+ def test_duplicate_region_id_first_wins(self):
298
+ vtt = (
299
+ "WEBVTT\n\n"
300
+ "REGION\n"
301
+ "id:dup\n"
302
+ "width:40%\n\n"
303
+ "REGION\n"
304
+ "id:dup\n"
305
+ "width:80%\n\n"
306
+ "00:00:01.000 --> 00:00:03.000 region:dup\n"
307
+ "Test\n"
308
+ )
309
+ captions = self.reader.read(vtt)
310
+ cue = captions.get_captions("en-US")[0]
311
+ assert cue.layout_info.extent.horizontal.value == pytest.approx(40.0)
312
+
313
+ def test_multiple_regions(self):
314
+ vtt = (
315
+ "WEBVTT\n\n"
316
+ "REGION\n"
317
+ "id:top\n"
318
+ "width:100%\n"
319
+ "lines:2\n"
320
+ "viewportanchor:0%,10%\n"
321
+ "regionanchor:0%,0%\n\n"
322
+ "REGION\n"
323
+ "id:bottom\n"
324
+ "width:100%\n"
325
+ "lines:2\n"
326
+ "viewportanchor:0%,90%\n"
327
+ "regionanchor:0%,0%\n\n"
328
+ "00:00:01.000 --> 00:00:03.000 region:top\n"
329
+ "Top caption\n\n"
330
+ "00:00:01.000 --> 00:00:03.000 region:bottom\n"
331
+ "Bottom caption\n"
332
+ )
333
+ captions = self.reader.read(vtt)
334
+ cues = captions.get_captions("en-US")
335
+ # top: origin_y = 10 - (0/100 * 10.66) = 10.0
336
+ assert cues[0].layout_info.origin.y.value == pytest.approx(10.0)
337
+ # bottom: origin_y = 90 - (0/100 * 10.66) = 90.0
338
+ assert cues[1].layout_info.origin.y.value == pytest.approx(90.0)
339
+
340
+ def test_region_without_id_ignored(self):
341
+ vtt = (
342
+ "WEBVTT\n\n"
343
+ "REGION\n"
344
+ "width:50%\n"
345
+ "lines:3\n\n"
346
+ "00:00:01.000 --> 00:00:03.000\n"
347
+ "No region reference\n"
348
+ )
349
+ captions = self.reader.read(vtt)
350
+ cue = captions.get_captions("en-US")[0]
351
+ assert cue.layout_info is None
352
+
353
+ def test_duplicate_setting_in_region_first_wins(self):
354
+ vtt = (
355
+ "WEBVTT\n\n"
356
+ "REGION\n"
357
+ "id:r1\n"
358
+ "width:40%\n"
359
+ "width:80%\n\n"
360
+ "00:00:01.000 --> 00:00:03.000 region:r1\n"
361
+ "Test\n"
362
+ )
363
+ captions = self.reader.read(vtt)
364
+ cue = captions.get_captions("en-US")[0]
365
+ assert cue.layout_info.extent.horizontal.value == pytest.approx(40.0)
366
+
367
+ def test_cue_without_region_unaffected(self):
368
+ vtt = (
369
+ "WEBVTT\n\n"
370
+ "REGION\n"
371
+ "id:r1\n"
372
+ "width:50%\n\n"
373
+ "00:00:01.000 --> 00:00:03.000\n"
374
+ "No region\n"
375
+ )
376
+ captions = self.reader.read(vtt)
377
+ cue = captions.get_captions("en-US")[0]
378
+ assert cue.layout_info is None
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