pycaption 2.2.24__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.24/pycaption.egg-info → pycaption-2.2.26}/PKG-INFO +1 -1
  2. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/geometry.py +10 -4
  3. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/__init__.py +100 -22
  4. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/specialized_collections.py +10 -6
  5. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/webvtt.py +170 -13
  6. {pycaption-2.2.24 → pycaption-2.2.26/pycaption.egg-info}/PKG-INFO +1 -1
  7. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption.egg-info/SOURCES.txt +1 -0
  8. {pycaption-2.2.24 → pycaption-2.2.26}/setup.py +1 -1
  9. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_scc.py +4 -4
  10. pycaption-2.2.26/tests/test_scc_writer.py +255 -0
  11. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_webvtt.py +186 -12
  12. {pycaption-2.2.24 → pycaption-2.2.26}/LICENSE +0 -0
  13. {pycaption-2.2.24 → pycaption-2.2.26}/MANIFEST.in +0 -0
  14. {pycaption-2.2.24 → pycaption-2.2.26}/README.rst +0 -0
  15. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/__init__.py +0 -0
  16. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/base.py +0 -0
  17. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/dfxp/__init__.py +0 -0
  18. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/dfxp/base.py +0 -0
  19. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/dfxp/extras.py +0 -0
  20. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/exceptions.py +0 -0
  21. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/microdvd.py +0 -0
  22. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/sami.py +0 -0
  23. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/constants.py +0 -0
  24. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/state_machines.py +0 -0
  25. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/translator.py +0 -0
  26. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/srt.py +0 -0
  27. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/transcript.py +0 -0
  28. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/utils.py +0 -0
  29. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption.egg-info/dependency_links.txt +0 -0
  30. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption.egg-info/requires.txt +0 -0
  31. {pycaption-2.2.24 → pycaption-2.2.26}/pycaption.egg-info/top_level.txt +0 -0
  32. {pycaption-2.2.24 → pycaption-2.2.26}/setup.cfg +0 -0
  33. {pycaption-2.2.24 → pycaption-2.2.26}/tests/__init__.py +0 -0
  34. {pycaption-2.2.24 → pycaption-2.2.26}/tests/conftest.py +0 -0
  35. {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/__init__.py +0 -0
  36. {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/dfxp.py +0 -0
  37. {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/microdvd.py +0 -0
  38. {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/sami.py +0 -0
  39. {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/scc.py +0 -0
  40. {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/srt.py +0 -0
  41. {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/translated_scc.py +0 -0
  42. {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/webvtt.py +0 -0
  43. {pycaption-2.2.24 → pycaption-2.2.26}/tests/mixins.py +0 -0
  44. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_base.py +0 -0
  45. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_dfxp.py +0 -0
  46. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_dfxp_conversion.py +0 -0
  47. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_dfxp_extras.py +0 -0
  48. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_functions.py +0 -0
  49. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_geometry.py +0 -0
  50. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_microdvd.py +0 -0
  51. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_microdvd_conversion.py +0 -0
  52. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_sami.py +0 -0
  53. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_sami_conversion.py +0 -0
  54. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_scc_conversion.py +0 -0
  55. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_scc_translator.py +0 -0
  56. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_srt.py +0 -0
  57. {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_srt_conversion.py +0 -0
  58. {pycaption-2.2.24 → 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.24
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:
@@ -335,8 +335,7 @@ class SCCReader(BaseReader):
335
335
  # Look ahead for the next command, skipping the duplicate
336
336
  # that SCC uses for error-correction (same word repeated).
337
337
  next_idx = idx + 1
338
- if (next_idx < len(word_list)
339
- and word_list[next_idx].strip() == word):
338
+ if next_idx < len(word_list) and word_list[next_idx].strip() == word:
340
339
  next_idx += 1
341
340
  next_command = (
342
341
  word_list[next_idx] if next_idx < len(word_list) else None
@@ -496,10 +495,11 @@ class SCCReader(BaseReader):
496
495
  edm_time = self.time_translator.get_time()
497
496
  if self.pop_ons_queue:
498
497
  self._pop_on(end=edm_time)
499
- if self.buffer_dict.active_key in ("paint", "roll") \
500
- and not self.buffer.is_empty():
501
- self.caption_stash.create_and_store(
502
- self.buffer, self.time, edm_time)
498
+ if (
499
+ self.buffer_dict.active_key in ("paint", "roll")
500
+ and not self.buffer.is_empty()
501
+ ):
502
+ self.caption_stash.create_and_store(self.buffer, self.time, edm_time)
503
503
  self.buffer = self.node_creator_factory.new_creator()
504
504
  self.node_creator_factory.position_tracker.reset_for_new_caption()
505
505
  self.time = edm_time
@@ -563,9 +563,17 @@ class SCCReader(BaseReader):
563
563
  self.caption_stash.create_and_store(pop_on_cue.buffer, pop_on_cue.start, end)
564
564
 
565
565
 
566
+ SCC_TOKENS_PER_CAPTION_MAX = 80
567
+
568
+ _SCC_PREFIX = ["94ae", "94ae", "9420", "9420"]
569
+ _SCC_SUFFIX = ["942c", "942c", "942f", "942f"]
570
+ _SCC_OVERHEAD = len(_SCC_PREFIX) + len(_SCC_SUFFIX)
571
+
572
+
566
573
  class SCCWriter(BaseWriter):
567
- def __init__(self, *args, **kw):
574
+ def __init__(self, *args, drop_frame=False, **kw):
568
575
  super().__init__(*args, **kw)
576
+ self.drop_frame = drop_frame
569
577
 
570
578
  def write(self, caption_set):
571
579
  output = HEADER + "\n\n"
@@ -589,24 +597,64 @@ class SCCWriter(BaseWriter):
589
597
  # Advance start times so as to have time to write to the pop-on
590
598
  # buffer; possibly remove the previous clear-screen command
591
599
  for index, (code, start, end) in enumerate(codes):
592
- code_words = len(code) / 5 + 8
600
+ code_words = len(code.split()) + _SCC_OVERHEAD
593
601
  code_time_microseconds = code_words * MICROSECONDS_PER_CODEWORD
594
602
  code_start = start - code_time_microseconds
595
- if index > 0:
596
- previous_code, previous_start, previous_end = codes[index - 1]
597
- if code_start < previous_start:
598
- code_start = previous_start
599
- if previous_end + 3 * MICROSECONDS_PER_CODEWORD >= code_start:
603
+ if code_start < 0:
604
+ code_start = 0
605
+ if index == 0:
606
+ codes[index] = (code, code_start, end)
607
+ continue
608
+ previous_code, previous_start, previous_end = codes[index - 1]
609
+ if code_start <= previous_start + MICROSECONDS_PER_CODEWORD:
610
+ prev_words = len(previous_code.split()) + _SCC_OVERHEAD
611
+ code_start = max(
612
+ code_start,
613
+ previous_start + prev_words * MICROSECONDS_PER_CODEWORD,
614
+ )
615
+ codes[index] = (code, code_start, end)
616
+ codes[index - 1] = (previous_code, previous_start, None)
617
+ else:
618
+ if (
619
+ previous_end is not None
620
+ and previous_end + 3 * MICROSECONDS_PER_CODEWORD >= code_start
621
+ ):
600
622
  codes[index - 1] = (previous_code, previous_start, None)
601
- codes[index] = (code, code_start, end)
623
+ codes[index] = (code, code_start, end)
602
624
 
603
625
  # PASS 3:
604
- # Write captions.
626
+ # Deduplicate timestamps — ensure monotonically increasing frame values
627
+ last_emitted_frame = -1
628
+ for index, (code, start, end) in enumerate(codes):
629
+ cur_frame = self._microseconds_to_frame(start)
630
+ if cur_frame <= last_emitted_frame:
631
+ while self._microseconds_to_frame(start) <= last_emitted_frame:
632
+ start += MICROSECONDS_PER_CODEWORD
633
+ codes[index] = (code, start, end)
634
+ last_emitted_frame = self._microseconds_to_frame(start)
635
+
636
+ # PASS 4:
637
+ # Write captions, splitting if > SCC_TOKENS_PER_CAPTION_MAX tokens.
638
+ max_payload = SCC_TOKENS_PER_CAPTION_MAX - _SCC_OVERHEAD
605
639
  for code, start, end in codes:
606
- output += f"{self._format_timestamp(start)}\t"
607
- output += "94ae 94ae 9420 9420 "
608
- output += code
609
- output += "942c 942c 942f 942f\n\n"
640
+ code_tokens = code.split()
641
+ if len(code_tokens) + _SCC_OVERHEAD <= SCC_TOKENS_PER_CAPTION_MAX:
642
+ output += f"{self._format_timestamp(start)}\t"
643
+ output += "94ae 94ae 9420 9420 "
644
+ output += code
645
+ output += "942c 942c 942f 942f\n\n"
646
+ else:
647
+ offset = 0
648
+ while offset < len(code_tokens):
649
+ chunk = code_tokens[offset : offset + max_payload]
650
+ line = _SCC_PREFIX + chunk + _SCC_SUFFIX
651
+ output += (
652
+ f"{self._format_timestamp(start)}\t" + " ".join(line) + "\n\n"
653
+ )
654
+ offset += max_payload
655
+ if offset < len(code_tokens):
656
+ start += MICROSECONDS_PER_CODEWORD
657
+
610
658
  if end is not None:
611
659
  output += f"{self._format_timestamp(end)}\t942c 942c\n\n"
612
660
 
@@ -668,10 +716,20 @@ class SCCWriter(BaseWriter):
668
716
  code = self._maybe_align(code)
669
717
  return code
670
718
 
719
+ def _format_timestamp(self, microseconds):
720
+ if self.drop_frame:
721
+ return self._format_timestamp_df(microseconds)
722
+ return self._format_timestamp_ndf(microseconds)
723
+
724
+ def _microseconds_to_frame(self, microseconds):
725
+ if self.drop_frame:
726
+ return math.floor(microseconds * 30 / 1_000_000 * 1000 / 1001 + 1e-9)
727
+ seconds_float = microseconds / 1_000_000.0 * 1000.0 / 1001.0
728
+ return math.floor(seconds_float * 30)
729
+
671
730
  @staticmethod
672
- def _format_timestamp(microseconds):
673
- seconds_float = microseconds / 1000.0 / 1000.0
674
- # Convert to non-drop-frame timecode
731
+ def _format_timestamp_ndf(microseconds):
732
+ seconds_float = microseconds / 1_000_000.0
675
733
  seconds_float *= 1000.0 / 1001.0
676
734
  hours = math.floor(seconds_float / 3600)
677
735
  seconds_float -= hours * 3600
@@ -682,6 +740,26 @@ class SCCWriter(BaseWriter):
682
740
  frames = math.floor(seconds_float * 30)
683
741
  return f"{hours:02}:{minutes:02}:{seconds:02}:{frames:02}"
684
742
 
743
+ @staticmethod
744
+ def _format_timestamp_df(microseconds):
745
+ total_frames = math.floor(microseconds * 30 / 1_000_000 * 1000 / 1001 + 1e-9)
746
+ fps = 30
747
+ frames_per_10min = 17982 # 10*60*30 - 2*9
748
+ d = total_frames // frames_per_10min
749
+ m = total_frames % frames_per_10min
750
+ if m < 2:
751
+ tc_frames = total_frames + 18 * d
752
+ else:
753
+ tc_frames = total_frames + 18 * d + 2 * ((m - 2) // 1798)
754
+
755
+ hours = tc_frames // (fps * 60 * 60)
756
+ rem = tc_frames % (fps * 60 * 60)
757
+ minutes = rem // (fps * 60)
758
+ rem = rem % (fps * 60)
759
+ seconds = rem // fps
760
+ frames = rem % fps
761
+ return f"{hours:02}:{minutes:02}:{seconds:02};{frames:02}"
762
+
685
763
 
686
764
  class _SccTimeTranslator:
687
765
  """Converts SCC time to microseconds, keeping track of frames passed"""
@@ -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.24
3
+ Version: 2.2.26
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -41,6 +41,7 @@ tests/test_sami_conversion.py
41
41
  tests/test_scc.py
42
42
  tests/test_scc_conversion.py
43
43
  tests/test_scc_translator.py
44
+ tests/test_scc_writer.py
44
45
  tests/test_srt.py
45
46
  tests/test_srt_conversion.py
46
47
  tests/test_webvtt.py
@@ -20,7 +20,7 @@ transcript_dependencies = ["nltk==3.9.4"]
20
20
 
21
21
  setup(
22
22
  name="pycaption",
23
- version="2.2.24",
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()
@@ -0,0 +1,255 @@
1
+ import re
2
+
3
+
4
+ from pycaption import SCCReader, SCCWriter, SRTReader, WebVTTReader
5
+
6
+
7
+ class TestSCCWriterTimestampFormatting:
8
+ def test_ndf_timestamp_uses_colons(self):
9
+ assert SCCWriter._format_timestamp_ndf(0) == "00:00:00:00"
10
+ assert ";" not in SCCWriter._format_timestamp_ndf(1_000_000)
11
+
12
+ def test_ndf_known_values(self):
13
+ assert SCCWriter._format_timestamp_ndf(0) == "00:00:00:00"
14
+ # 1 second of real time at 29.97fps NDF
15
+ # 1_000_000 us * 1000/1001 = 999.000999 coded seconds → 29 frames
16
+ assert SCCWriter._format_timestamp_ndf(1_000_000) == "00:00:00:29"
17
+ # 60 seconds real → 59.94 coded seconds → 59s + 28 frames
18
+ assert SCCWriter._format_timestamp_ndf(60_000_000) == "00:00:59:28"
19
+
20
+ def test_df_timestamp_uses_semicolons(self):
21
+ result = SCCWriter._format_timestamp_df(1_000_000)
22
+ assert ";" in result
23
+
24
+ def test_df_known_values(self):
25
+ assert SCCWriter._format_timestamp_df(0) == "00:00:00;00"
26
+ # 10 minutes of real time should map to exactly 10:00;00 in DF
27
+ ten_minutes_us = 10 * 60 * 1_000_000
28
+ assert SCCWriter._format_timestamp_df(ten_minutes_us) == "00:10:00;00"
29
+
30
+ def test_df_frame_skip_at_minute_boundary(self):
31
+ # Real frame 1800 is the first frame of minute 1 in DF.
32
+ # Minute 0 has 1800 frames (0..1799), no drops.
33
+ # Minute 1 starts at real frame 1800, displayed as 00:01:00;02
34
+ # (frames ;00 and ;01 are dropped at non-10th minutes).
35
+ us_for_frame_1800 = round(1800 * 1001 / 1000 / 30 * 1_000_000)
36
+ result = SCCWriter._format_timestamp_df(us_for_frame_1800)
37
+ assert result == "00:01:00;02"
38
+
39
+ def test_df_no_frame_skip_at_10_minute_boundary(self):
40
+ # At 10-minute boundaries, no frames are dropped
41
+ twenty_minutes_us = 20 * 60 * 1_000_000
42
+ result = SCCWriter._format_timestamp_df(twenty_minutes_us)
43
+ assert result == "00:20:00;00"
44
+
45
+
46
+ class TestSCCWriterDropFrameFlag:
47
+ def _make_simple_captions(self):
48
+ srt = (
49
+ "1\n"
50
+ "00:00:01,000 --> 00:00:03,000\n"
51
+ "Hello world\n\n"
52
+ "2\n"
53
+ "00:00:05,000 --> 00:00:07,000\n"
54
+ "Second line\n"
55
+ )
56
+ return SRTReader().read(srt)
57
+
58
+ def test_drop_frame_false_is_default(self):
59
+ writer = SCCWriter()
60
+ assert writer.drop_frame is False
61
+
62
+ def test_drop_frame_false_uses_colons(self):
63
+ captions = self._make_simple_captions()
64
+ output = SCCWriter(drop_frame=False).write(captions)
65
+ timestamps = re.findall(r"\d{2}:\d{2}:\d{2}[:;]\d{2}", output)
66
+ for ts in timestamps:
67
+ assert ";" not in ts
68
+
69
+ def test_drop_frame_true_uses_semicolons(self):
70
+ captions = self._make_simple_captions()
71
+ output = SCCWriter(drop_frame=True).write(captions)
72
+ timestamps = re.findall(r"\d{2}:\d{2}:\d{2}[:;]\d{2}", output)
73
+ assert len(timestamps) > 0
74
+ for ts in timestamps:
75
+ assert ";" in ts
76
+
77
+ def test_drop_frame_false_preserves_existing_behavior(self):
78
+ captions = self._make_simple_captions()
79
+ output_default = SCCWriter().write(captions)
80
+ output_explicit = SCCWriter(drop_frame=False).write(captions)
81
+ assert output_default == output_explicit
82
+
83
+
84
+ class TestSCCWriterTimestampOrdering:
85
+ def test_timestamps_monotonically_increasing_ndf(self):
86
+ vtt_input = (
87
+ "WEBVTT\n\n"
88
+ "0\n00:00:01.529 --> 00:00:03.640\n"
89
+ "When it comes to finding the one,\n\n"
90
+ "1\n00:00:03.730 --> 00:00:07.239\n"
91
+ "I always say that if they can love you at your messiest,\n\n"
92
+ "2\n00:00:07.570 --> 00:00:09.319\n"
93
+ "calm you at your moodiest,\n\n"
94
+ "3\n00:00:09.529 --> 00:00:11.680\n"
95
+ "and laugh with you at your quirkiest,\n\n"
96
+ "4\n00:00:11.930 --> 00:00:13.760\n"
97
+ "you've probably found your person.\n"
98
+ )
99
+ captions = WebVTTReader().read(vtt_input)
100
+ output = SCCWriter(drop_frame=False).write(captions)
101
+ timestamps = re.findall(r"(\d{2}:\d{2}:\d{2}:\d{2})", output)
102
+ for i in range(1, len(timestamps)):
103
+ assert (
104
+ timestamps[i] >= timestamps[i - 1]
105
+ ), f"NDF timestamps out of order: {timestamps[i - 1]} > {timestamps[i]}"
106
+
107
+ def test_timestamps_monotonically_increasing_df(self):
108
+ vtt_input = (
109
+ "WEBVTT\n\n"
110
+ "0\n00:00:01.529 --> 00:00:03.640\n"
111
+ "When it comes to finding the one,\n\n"
112
+ "1\n00:00:03.730 --> 00:00:07.239\n"
113
+ "I always say that if they can love you at your messiest,\n\n"
114
+ "2\n00:00:07.570 --> 00:00:09.319\n"
115
+ "calm you at your moodiest,\n\n"
116
+ "3\n00:00:09.529 --> 00:00:11.680\n"
117
+ "and laugh with you at your quirkiest,\n\n"
118
+ "4\n00:00:11.930 --> 00:00:13.760\n"
119
+ "you've probably found your person.\n"
120
+ )
121
+ captions = WebVTTReader().read(vtt_input)
122
+ output = SCCWriter(drop_frame=True).write(captions)
123
+ timestamps = re.findall(r"(\d{2}:\d{2}:\d{2};\d{2})", output)
124
+ for i in range(1, len(timestamps)):
125
+ assert (
126
+ timestamps[i] >= timestamps[i - 1]
127
+ ), f"DF timestamps out of order: {timestamps[i - 1]} > {timestamps[i]}"
128
+
129
+ def test_rapid_short_captions_stay_ordered(self):
130
+ """Short text followed by long text should not cause timestamp inversion."""
131
+ vtt_input = (
132
+ "WEBVTT\n\n"
133
+ "0\n00:00:02.200 --> 00:00:02.359\nyou know,\n\n"
134
+ "1\n00:00:02.400 --> 00:00:03.760\n"
135
+ "the way he kind of looked at me.\n\n"
136
+ "2\n00:00:04.700 --> 00:00:05.169\nAnd I said,\n\n"
137
+ "3\n00:00:05.210 --> 00:00:05.520\noh\n"
138
+ )
139
+ captions = WebVTTReader().read(vtt_input)
140
+ for df in (True, False):
141
+ output = SCCWriter(drop_frame=df).write(captions)
142
+ sep = ";" if df else ":"
143
+ pattern = r"\d{2}:\d{2}:\d{2}" + re.escape(sep) + r"\d{2}"
144
+ timestamps = re.findall(pattern, output)
145
+ for i in range(1, len(timestamps)):
146
+ assert (
147
+ timestamps[i] >= timestamps[i - 1]
148
+ ), f"drop_frame={df}: {timestamps[i - 1]} > {timestamps[i]}"
149
+
150
+
151
+ class TestSCCWriterFirstCueBackshift:
152
+ def test_first_cue_start_is_shifted_back(self):
153
+ srt = "1\n" "00:00:10,000 --> 00:00:12,000\n" "Hello world\n"
154
+ captions = SRTReader().read(srt)
155
+ output = SCCWriter(drop_frame=False).write(captions)
156
+ timestamps = re.findall(r"(\d{2}:\d{2}:\d{2}:\d{2})", output)
157
+ # The first timestamp should be earlier than 00:00:09:29
158
+ # (10s real time -> ~9:29 NDF, minus backshift)
159
+ assert timestamps[0] < "00:00:09:29"
160
+
161
+ def test_first_cue_at_zero_does_not_go_negative(self):
162
+ srt = "1\n" "00:00:00,100 --> 00:00:02,000\n" "Hello\n"
163
+ captions = SRTReader().read(srt)
164
+ output = SCCWriter(drop_frame=False).write(captions)
165
+ timestamps = re.findall(r"(\d{2}:\d{2}:\d{2}:\d{2})", output)
166
+ assert timestamps[0] == "00:00:00:00"
167
+
168
+
169
+ class TestSCCWriterOverlappingCues:
170
+ def test_overlapping_cues_suppress_clear_screen(self):
171
+ """When cues are very close together, the clear-screen (942c) for
172
+ the previous cue should be suppressed."""
173
+ srt = (
174
+ "1\n00:00:01,000 --> 00:00:01,900\nFirst\n\n"
175
+ "2\n00:00:02,000 --> 00:00:03,000\nSecond\n"
176
+ )
177
+ captions = SRTReader().read(srt)
178
+ output = SCCWriter(drop_frame=False).write(captions)
179
+ # Count standalone 942c lines (clear-screen commands between captions)
180
+ clear_lines = [
181
+ line
182
+ for line in output.split("\n")
183
+ if line.strip().endswith("942c 942c") and "94ae" not in line
184
+ ]
185
+ # With tight cues, the first cue's clear should be suppressed
186
+ # (only the last cue gets a clear-screen at its end time)
187
+ assert len(clear_lines) <= 1
188
+
189
+
190
+ class TestSCCWriterSplitLongCaption:
191
+ def test_split_caption_exceeding_80_tokens(self):
192
+ """A caption that would exceed 80 SCC tokens should be split."""
193
+ # Create a very long caption that will produce many code tokens
194
+ long_text = "A" * 32 + "\n" + "B" * 32 + "\n" + "C" * 32 + "\n" + "D" * 32
195
+ srt = "1\n" "00:00:05,000 --> 00:00:10,000\n" f"{long_text}\n"
196
+ captions = SRTReader().read(srt)
197
+ output = SCCWriter(drop_frame=False).write(captions)
198
+ # Each output line (non-empty, non-header) should have <= 80 tokens
199
+ for line in output.split("\n"):
200
+ line = line.strip()
201
+ if not line or line == "Scenarist_SCC V1.0":
202
+ continue
203
+ # Line format: "HH:MM:SS:FF\t<tokens>"
204
+ parts = line.split("\t")
205
+ if len(parts) == 2:
206
+ tokens = parts[1].split()
207
+ assert len(tokens) <= SCC_TOKENS_PER_CAPTION_MAX, (
208
+ f"Line has {len(tokens)} tokens, "
209
+ f"exceeds {SCC_TOKENS_PER_CAPTION_MAX}"
210
+ )
211
+
212
+
213
+ class TestSCCWriterRoundTrip:
214
+ def test_srt_to_scc_roundtrip_ndf(self):
215
+ srt = (
216
+ "1\n00:00:01,000 --> 00:00:03,000\nHello world\n\n"
217
+ "2\n00:00:05,000 --> 00:00:07,000\nGoodbye world\n"
218
+ )
219
+ captions = SRTReader().read(srt)
220
+ scc_output = SCCWriter(drop_frame=False).write(captions)
221
+ # Should be readable by SCCReader
222
+ result = SCCReader().read(scc_output)
223
+ assert not result.is_empty()
224
+ assert len(result.get_captions("en-US")) == 2
225
+
226
+ def test_srt_to_scc_roundtrip_df(self):
227
+ srt = (
228
+ "1\n00:00:01,000 --> 00:00:03,000\nHello world\n\n"
229
+ "2\n00:00:05,000 --> 00:00:07,000\nGoodbye world\n"
230
+ )
231
+ captions = SRTReader().read(srt)
232
+ scc_output = SCCWriter(drop_frame=True).write(captions)
233
+ # SCCReader already supports semicolon (DF) timestamps
234
+ result = SCCReader().read(scc_output)
235
+ assert not result.is_empty()
236
+ assert len(result.get_captions("en-US")) == 2
237
+
238
+ def test_webvtt_to_scc_roundtrip_df(self):
239
+ vtt_input = (
240
+ "WEBVTT\n\n"
241
+ "0\n00:00:01.529 --> 00:00:03.640\n"
242
+ "When it comes to finding the one,\n\n"
243
+ "1\n00:00:03.730 --> 00:00:07.239\n"
244
+ "I always say that if they can love you\n\n"
245
+ "2\n00:00:07.570 --> 00:00:09.319\n"
246
+ "calm you at your moodiest,\n"
247
+ )
248
+ captions = WebVTTReader().read(vtt_input)
249
+ scc_output = SCCWriter(drop_frame=True).write(captions)
250
+ result = SCCReader().read(scc_output)
251
+ assert not result.is_empty()
252
+ assert len(result.get_captions("en-US")) == 3
253
+
254
+
255
+ from pycaption.scc import SCC_TOKENS_PER_CAPTION_MAX # noqa: E402
@@ -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