pycaption 2.3.5__tar.gz → 2.3.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {pycaption-2.3.5/pycaption.egg-info → pycaption-2.3.7}/PKG-INFO +1 -1
  2. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/dfxp/writer.py +4 -0
  3. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/geometry.py +127 -58
  4. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/sami/reader.py +4 -6
  5. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/sami/writer.py +2 -4
  6. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/scc/reader.py +23 -7
  7. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/scc/writer.py +15 -11
  8. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/webvtt/constants.py +16 -4
  9. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/webvtt/reader.py +79 -32
  10. {pycaption-2.3.5 → pycaption-2.3.7/pycaption.egg-info}/PKG-INFO +1 -1
  11. {pycaption-2.3.5 → pycaption-2.3.7}/setup.py +1 -1
  12. {pycaption-2.3.5 → pycaption-2.3.7}/tests/conftest.py +3 -1
  13. {pycaption-2.3.5 → pycaption-2.3.7}/tests/fixtures/dfxp.py +2 -3
  14. {pycaption-2.3.5 → pycaption-2.3.7}/tests/fixtures/scc.py +29 -7
  15. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_dfxp.py +3 -0
  16. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_geometry.py +117 -4
  17. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_scc.py +36 -9
  18. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_scc_writer.py +61 -0
  19. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_webvtt.py +175 -0
  20. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_webvtt_conversion.py +22 -0
  21. {pycaption-2.3.5 → pycaption-2.3.7}/LICENSE +0 -0
  22. {pycaption-2.3.5 → pycaption-2.3.7}/MANIFEST.in +0 -0
  23. {pycaption-2.3.5 → pycaption-2.3.7}/README.rst +0 -0
  24. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/__init__.py +0 -0
  25. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/base.py +0 -0
  26. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/dfxp/__init__.py +0 -0
  27. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/dfxp/constants.py +0 -0
  28. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/dfxp/extras.py +0 -0
  29. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/dfxp/reader.py +0 -0
  30. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/exceptions.py +0 -0
  31. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/microdvd.py +0 -0
  32. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/sami/__init__.py +0 -0
  33. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/sami/constants.py +0 -0
  34. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/sami/parser.py +0 -0
  35. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/scc/__init__.py +0 -0
  36. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/scc/constants.py +0 -0
  37. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/scc/specialized_collections.py +0 -0
  38. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/scc/state_machines.py +0 -0
  39. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/scc/translator.py +0 -0
  40. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/srt.py +0 -0
  41. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/transcript.py +0 -0
  42. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/utils.py +0 -0
  43. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/webvtt/__init__.py +0 -0
  44. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption/webvtt/writer.py +0 -0
  45. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption.egg-info/SOURCES.txt +0 -0
  46. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption.egg-info/dependency_links.txt +0 -0
  47. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption.egg-info/requires.txt +0 -0
  48. {pycaption-2.3.5 → pycaption-2.3.7}/pycaption.egg-info/top_level.txt +0 -0
  49. {pycaption-2.3.5 → pycaption-2.3.7}/setup.cfg +0 -0
  50. {pycaption-2.3.5 → pycaption-2.3.7}/tests/__init__.py +0 -0
  51. {pycaption-2.3.5 → pycaption-2.3.7}/tests/fixtures/__init__.py +0 -0
  52. {pycaption-2.3.5 → pycaption-2.3.7}/tests/fixtures/microdvd.py +0 -0
  53. {pycaption-2.3.5 → pycaption-2.3.7}/tests/fixtures/sami.py +0 -0
  54. {pycaption-2.3.5 → pycaption-2.3.7}/tests/fixtures/srt.py +0 -0
  55. {pycaption-2.3.5 → pycaption-2.3.7}/tests/fixtures/translated_scc.py +0 -0
  56. {pycaption-2.3.5 → pycaption-2.3.7}/tests/fixtures/webvtt.py +0 -0
  57. {pycaption-2.3.5 → pycaption-2.3.7}/tests/mixins.py +0 -0
  58. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_base.py +0 -0
  59. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_bytes_input.py +0 -0
  60. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_dfxp_conversion.py +0 -0
  61. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_dfxp_extras.py +0 -0
  62. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_double_encoding.py +0 -0
  63. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_functions.py +0 -0
  64. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_microdvd.py +0 -0
  65. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_microdvd_conversion.py +0 -0
  66. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_sami.py +0 -0
  67. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_sami_conversion.py +0 -0
  68. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_scc_conversion.py +0 -0
  69. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_scc_translator.py +0 -0
  70. {pycaption-2.3.5 → pycaption-2.3.7}/tests/test_srt.py +0 -0
  71. {pycaption-2.3.5 → pycaption-2.3.7}/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.5
3
+ Version: 2.3.7
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -93,6 +93,10 @@ class DFXPWriter(BaseWriter):
93
93
  :type langs: list[str]
94
94
  """
95
95
  for lang in langs:
96
+ caption_set.set_layout_info(
97
+ lang,
98
+ self._relativize_and_fit_to_screen(caption_set.get_layout_info(lang)),
99
+ )
96
100
  for caption in caption_set.get_captions(lang):
97
101
  caption.layout_info = self._relativize_and_fit_to_screen(
98
102
  caption.layout_info
@@ -13,6 +13,8 @@ from functools import total_ordering
13
13
 
14
14
  from .exceptions import CaptionReadSyntaxError, RelativizationError
15
15
 
16
+ _UNIT_MISMATCH_MSG = "The sizes should have the same measure units."
17
+
16
18
 
17
19
  class UnitEnum(Enum):
18
20
  """Enumeration-like object, specifying the units of measure for length
@@ -29,6 +31,8 @@ class UnitEnum(Enum):
29
31
  PERCENT = "%"
30
32
  CELL = "c"
31
33
  PT = "pt"
34
+ VW = "vw"
35
+ VH = "vh"
32
36
 
33
37
 
34
38
  class VerticalAlignmentEnum(Enum):
@@ -55,6 +59,15 @@ class HorizontalAlignmentEnum(Enum):
55
59
  END = "end"
56
60
 
57
61
 
62
+ class PositionAlignmentEnum(Enum):
63
+ """WebVTT position alignment: which edge of the cue box the position
64
+ percentage anchors to."""
65
+
66
+ LINE_LEFT = "line-left"
67
+ CENTER = "center"
68
+ LINE_RIGHT = "line-right"
69
+
70
+
58
71
  class WritingDirectionEnum(Enum):
59
72
  """Specifies WebVTT writing direction (vertical cue setting)."""
60
73
 
@@ -79,12 +92,12 @@ class Alignment:
79
92
  "after": VerticalAlignmentEnum.BOTTOM,
80
93
  }
81
94
 
82
- def __init__(self, horizontal, vertical):
95
+ def __init__(self, horizontal=None, vertical=None):
83
96
  """
84
- :type horizontal: HorizontalAlignmentEnum
85
- :param horizontal: HorizontalAlignmentEnum member
86
- :type vertical: VerticalAlignmentEnum
87
- :param vertical: VerticalAlignmentEnum member
97
+ :type horizontal: HorizontalAlignmentEnum | None
98
+ :param horizontal: HorizontalAlignmentEnum member, or None
99
+ :type vertical: VerticalAlignmentEnum | None
100
+ :param vertical: VerticalAlignmentEnum member, or None
88
101
  """
89
102
  self.horizontal = horizontal
90
103
  self.vertical = vertical
@@ -92,16 +105,15 @@ class Alignment:
92
105
  def __hash__(self):
93
106
  return hash(hash(self.horizontal) * 83 + hash(self.vertical) * 89 + 97)
94
107
 
95
- def __eq__(self, other):
108
+ def __eq__(self, other: object) -> bool:
96
109
  if not isinstance(other, Alignment):
97
110
  return NotImplemented
98
- return (
99
- self.horizontal == other.horizontal
100
- and self.vertical == other.vertical
101
- )
111
+ return self.horizontal == other.horizontal and self.vertical == other.vertical
102
112
 
103
113
  def __repr__(self):
104
- return f"<Alignment ({self.horizontal} {self.vertical})>"
114
+ h = self.horizontal.value if self.horizontal else "None"
115
+ v = self.vertical.value if self.vertical else "None"
116
+ return f"<Alignment ({h} {v})>"
105
117
 
106
118
  def serialized(self):
107
119
  """Returns a tuple of the useful information regarding this object"""
@@ -121,12 +133,15 @@ class Alignment:
121
133
 
122
134
  if not horizontal_obj and not vertical_obj:
123
135
  return None
124
- return cls(horizontal_obj, vertical_obj)
136
+ return Alignment(horizontal_obj, vertical_obj)
125
137
 
126
138
 
127
139
  class TwoDimensionalObject:
128
140
  """Adds a couple useful methods to its subclasses, nothing fancy."""
129
141
 
142
+ def __init__(self, first, second):
143
+ raise NotImplementedError
144
+
130
145
  @classmethod
131
146
  def from_xml_attribute(cls, attribute):
132
147
  """Instantiate the class from a value of the type "4px" or "5%"
@@ -134,11 +149,11 @@ class TwoDimensionalObject:
134
149
 
135
150
  :type attribute: str
136
151
  """
137
- horizontal, vertical = attribute.split(" ")
138
- horizontal = Size.from_string(horizontal)
139
- vertical = Size.from_string(vertical)
152
+ first, second = attribute.split(" ")
153
+ first = Size.from_string(first)
154
+ second = Size.from_string(second)
140
155
 
141
- return cls(horizontal, vertical)
156
+ return cls(first, second)
142
157
 
143
158
 
144
159
  class Stretch(TwoDimensionalObject):
@@ -178,13 +193,10 @@ class Stretch(TwoDimensionalObject):
178
193
  None if not self.vertical else self.vertical.serialized(),
179
194
  )
180
195
 
181
- def __eq__(self, other):
196
+ def __eq__(self, other: object) -> bool:
182
197
  if not isinstance(other, Stretch):
183
198
  return NotImplemented
184
- return (
185
- self.horizontal == other.horizontal
186
- and self.vertical == other.vertical
187
- )
199
+ return self.horizontal == other.horizontal and self.vertical == other.vertical
188
200
 
189
201
  def __hash__(self):
190
202
  return hash(hash(self.horizontal) * 59 + hash(self.vertical) * 61 + 67)
@@ -200,9 +212,8 @@ class Stretch(TwoDimensionalObject):
200
212
 
201
213
  def is_relative(self):
202
214
  """Return True if all dimensions are expressed as percentages."""
203
- return (
204
- (not self.horizontal or self.horizontal.is_relative())
205
- and (not self.vertical or self.vertical.is_relative())
215
+ return (not self.horizontal or self.horizontal.is_relative()) and (
216
+ not self.vertical or self.vertical.is_relative()
206
217
  )
207
218
 
208
219
  def as_percentage_of(self, video_width, video_height):
@@ -240,9 +251,8 @@ class Point(TwoDimensionalObject):
240
251
 
241
252
  def is_relative(self):
242
253
  """Return True if all dimensions are expressed as percentages."""
243
- return (
244
- (not self.x or self.x.is_relative())
245
- and (not self.y or self.y.is_relative())
254
+ return (not self.x or self.x.is_relative()) and (
255
+ not self.y or self.y.is_relative()
246
256
  )
247
257
 
248
258
  def as_percentage_of(self, video_width, video_height):
@@ -282,7 +292,7 @@ class Point(TwoDimensionalObject):
282
292
  None if not self.y else self.y.serialized(),
283
293
  )
284
294
 
285
- def __eq__(self, other):
295
+ def __eq__(self, other: object) -> bool:
286
296
  if not isinstance(other, Point):
287
297
  return NotImplemented
288
298
  return self.x == other.x and self.y == other.y
@@ -322,7 +332,7 @@ class Size:
322
332
  if self.unit == other.unit:
323
333
  return Size(self.value - other.value, self.unit)
324
334
  else:
325
- raise ValueError("The sizes should have the same measure units.")
335
+ raise ValueError(_UNIT_MISMATCH_MSG)
326
336
 
327
337
  def __abs__(self):
328
338
  return Size(abs(self.value), self.unit)
@@ -331,14 +341,14 @@ class Size:
331
341
  if not isinstance(other, Size):
332
342
  return NotImplemented
333
343
  if self.unit != other.unit:
334
- raise ValueError("The sizes should have the same measure units.")
344
+ raise ValueError(_UNIT_MISMATCH_MSG)
335
345
  return self.value < other.value
336
346
 
337
347
  def __add__(self, other):
338
348
  if self.unit == other.unit:
339
349
  return Size(self.value + other.value, self.unit)
340
350
  else:
341
- raise ValueError("The sizes should have the same measure units.")
351
+ raise ValueError(_UNIT_MISMATCH_MSG)
342
352
 
343
353
  def is_relative(self):
344
354
  """
@@ -357,6 +367,9 @@ class Size:
357
367
  if unit == UnitEnum.PERCENT:
358
368
  return self # Nothing to do here
359
369
 
370
+ if unit in (UnitEnum.VW, UnitEnum.VH):
371
+ return Size(value, UnitEnum.PERCENT)
372
+
360
373
  # The input must be valid so that any conversion can be done
361
374
  if not (video_width or video_height):
362
375
  raise RelativizationError(
@@ -401,7 +414,7 @@ class Size:
401
414
  :rtype: Size
402
415
  """
403
416
  size_pattern = re.compile(
404
- r"^(((?P<value>\d+(\.\d+)?)(?P<unit>"
417
+ r"^(((?P<value>-?\d+(\.\d+)?)(?P<unit>"
405
418
  rf"{'|'.join([unit.value for unit in UnitEnum])}))|0)$"
406
419
  )
407
420
  match = size_pattern.search(string)
@@ -416,7 +429,6 @@ class Size:
416
429
  value = match.group("value")
417
430
  return cls(value, UnitEnum(unit))
418
431
  else:
419
- # If the unit is missing, the only accepted alternative is zero
420
432
  return cls(match.group(0), UnitEnum.PIXEL)
421
433
 
422
434
  def __repr__(self):
@@ -438,7 +450,7 @@ class Size:
438
450
  """Returns the "useful" values of this object"""
439
451
  return self.value, self.unit
440
452
 
441
- def __eq__(self, other):
453
+ def __eq__(self, other: object) -> bool:
442
454
  if not isinstance(other, Size):
443
455
  return NotImplemented
444
456
  return self.value == other.value and self.unit == other.unit
@@ -447,7 +459,6 @@ class Size:
447
459
  return hash(hash(self.value) * 41 + hash(self.unit) * 43 + 47)
448
460
 
449
461
 
450
-
451
462
  class Padding:
452
463
  """Represents padding information. Consists of 4 Size objects, representing
453
464
  padding from (in this order): before (up), after (down), start (left) and
@@ -518,11 +529,11 @@ class Padding:
518
529
  None if not self.end else self.end.serialized(),
519
530
  )
520
531
 
521
- def __eq__(self, other):
532
+ def __eq__(self, other: object) -> bool:
533
+ if not isinstance(other, Padding):
534
+ return NotImplemented
522
535
  return (
523
- other
524
- and type(self) == type(other)
525
- and self.before == other.before
536
+ self.before == other.before
526
537
  and self.after == other.after
527
538
  and self.start == other.start
528
539
  and self.end == other.end
@@ -597,6 +608,7 @@ class Layout:
597
608
  alignment=None,
598
609
  webvtt_positioning=None,
599
610
  writing_direction=None,
611
+ position_alignment=None,
600
612
  inherit_from=None,
601
613
  ):
602
614
  """
@@ -622,6 +634,10 @@ class Layout:
622
634
  :type writing_direction: WritingDirectionEnum
623
635
  :param writing_direction: WebVTT vertical writing direction (rl or lr).
624
636
 
637
+ :type position_alignment: PositionAlignmentEnum
638
+ :param position_alignment: Which edge of the cue box the position
639
+ percentage anchors to (line-left, center, or line-right).
640
+
625
641
  :type inherit_from: Layout
626
642
  :param inherit_from: A Layout with the positioning parameters to be
627
643
  used if not specified by the positioning arguments,
@@ -633,6 +649,7 @@ class Layout:
633
649
  self.alignment = alignment
634
650
  self.webvtt_positioning = webvtt_positioning
635
651
  self.writing_direction = writing_direction
652
+ self.position_alignment = position_alignment
636
653
 
637
654
  if inherit_from:
638
655
  for attr_name in [
@@ -641,20 +658,24 @@ class Layout:
641
658
  "padding",
642
659
  "alignment",
643
660
  "writing_direction",
661
+ "position_alignment",
644
662
  ]:
645
663
  attr = getattr(self, attr_name)
646
664
  if not attr:
647
665
  setattr(self, attr_name, getattr(inherit_from, attr_name))
648
666
 
649
667
  def __bool__(self):
650
- return any((
651
- self.origin,
652
- self.extent,
653
- self.padding,
654
- self.alignment,
655
- self.webvtt_positioning,
656
- self.writing_direction,
657
- ))
668
+ return any(
669
+ (
670
+ self.origin,
671
+ self.extent,
672
+ self.padding,
673
+ self.alignment,
674
+ self.webvtt_positioning,
675
+ self.writing_direction,
676
+ self.position_alignment,
677
+ )
678
+ )
658
679
 
659
680
  def __repr__(self):
660
681
  return (
@@ -670,16 +691,19 @@ class Layout:
670
691
  None if not self.padding else self.padding.serialized(),
671
692
  None if not self.alignment else self.alignment.serialized(),
672
693
  self.writing_direction,
694
+ self.position_alignment,
673
695
  )
674
696
 
675
- def __eq__(self, other):
697
+ def __eq__(self, other: object) -> bool:
698
+ if not isinstance(other, Layout):
699
+ return NotImplemented
676
700
  return (
677
- type(self) == type(other)
678
- and self.origin == other.origin
701
+ self.origin == other.origin
679
702
  and self.extent == other.extent
680
703
  and self.padding == other.padding
681
704
  and self.alignment == other.alignment
682
705
  and self.writing_direction == other.writing_direction
706
+ and self.position_alignment == other.position_alignment
683
707
  )
684
708
 
685
709
  def __hash__(self):
@@ -689,6 +713,7 @@ class Layout:
689
713
  + hash(self.padding) * 13
690
714
  + hash(self.alignment) * 5
691
715
  + hash(self.writing_direction) * 19
716
+ + hash(self.position_alignment) * 23
692
717
  + 17
693
718
  )
694
719
 
@@ -709,6 +734,7 @@ class Layout:
709
734
  params = {
710
735
  "alignment": self.alignment,
711
736
  "writing_direction": self.writing_direction,
737
+ "position_alignment": self.position_alignment,
712
738
  }
713
739
  for attr_name in ["origin", "extent", "padding"]:
714
740
  attr = getattr(self, attr_name)
@@ -723,34 +749,77 @@ class Layout:
723
749
  technically valid but contain inconsistent settings that may cause
724
750
  long captions to be cut out of the screen.
725
751
 
752
+ When position_alignment is set, the origin is first adjusted so that
753
+ origin.x represents the true left edge of the cue box (the W3C spec
754
+ allows the position to anchor to center or right edge).
755
+
726
756
  ATTENTION: This must be called on relativized objects (such as the one
727
757
  returned by as_percentage_of). All units are presumed to be percentages.
728
758
  """
729
759
  if not self.origin:
730
760
  return self
731
761
 
732
- diff_horizontal = Size(90 - self.origin.x.value, UnitEnum.PERCENT)
733
- diff_vertical = Size(95 - self.origin.y.value, UnitEnum.PERCENT)
762
+ if (
763
+ self.origin.x.unit != UnitEnum.PERCENT
764
+ or self.origin.y.unit != UnitEnum.PERCENT
765
+ ):
766
+ return self
767
+
768
+ origin = self._resolve_position_alignment()
769
+
770
+ if origin.x.value >= 90 or origin.y.value >= 95:
771
+ return self
772
+
773
+ diff_horizontal = Size(90 - origin.x.value, UnitEnum.PERCENT)
774
+ diff_vertical = Size(95 - origin.y.value, UnitEnum.PERCENT)
734
775
 
735
776
  if not self.extent:
736
777
  new_extent = Stretch(diff_horizontal, diff_vertical)
737
778
  else:
738
- new_extent = self._corrected_extent(diff_horizontal, diff_vertical)
779
+ new_extent = self._corrected_extent_from(
780
+ origin, diff_horizontal, diff_vertical
781
+ )
739
782
 
740
783
  return Layout(
741
- origin=self.origin,
784
+ origin=origin,
742
785
  extent=new_extent,
743
786
  padding=self.padding,
744
787
  alignment=self.alignment,
745
788
  writing_direction=self.writing_direction,
746
789
  )
747
790
 
748
- def _corrected_extent(self, diff_horizontal, diff_vertical):
791
+ def _resolve_position_alignment(self):
792
+ """Adjust origin.x based on position_alignment and extent width.
793
+
794
+ The VTT reader always stores the ``position`` setting in origin.x
795
+ and ``size`` in extent.horizontal, regardless of writing direction.
796
+ This method adjusts origin.x so it represents the true left edge
797
+ of the cue box.
798
+
799
+ Returns a new Point (or self.origin unchanged for LINE_LEFT/None).
800
+ """
801
+ if not self.position_alignment or not self.extent:
802
+ return self.origin
803
+
804
+ width = self.extent.horizontal.value
805
+
806
+ if self.position_alignment == PositionAlignmentEnum.CENTER:
807
+ adjusted_x = max(0, self.origin.x.value - width / 2)
808
+ elif self.position_alignment == PositionAlignmentEnum.LINE_RIGHT:
809
+ adjusted_x = max(0, self.origin.x.value - width)
810
+ else:
811
+ return self.origin
812
+
813
+ return Point(Size(adjusted_x, UnitEnum.PERCENT), self.origin.y)
814
+
815
+ def _corrected_extent_from(self, origin, diff_horizontal, diff_vertical):
749
816
  """Return extent clamped so origin + extent doesn't exceed the screen."""
750
- bottom_right = self.origin.add_stretch(self.extent)
817
+ bottom_right = origin.add_stretch(self.extent)
751
818
 
752
- if (bottom_right.x.unit != UnitEnum.PERCENT
753
- or bottom_right.y.unit != UnitEnum.PERCENT):
819
+ if (
820
+ bottom_right.x.unit != UnitEnum.PERCENT
821
+ or bottom_right.y.unit != UnitEnum.PERCENT
822
+ ):
754
823
  raise ValueError(
755
824
  "Units must be relativized before extent "
756
825
  "can be calculated based on origin."
@@ -7,10 +7,7 @@ styles, margin-based positioning, and multi-language content.
7
7
  from bs4 import BeautifulSoup, NavigableString
8
8
 
9
9
  from ..base import BaseReader, Caption, CaptionList, CaptionNode, CaptionSet
10
- from ..exceptions import (
11
- CaptionReadNoCaptions,
12
- CaptionReadTimingError,
13
- )
10
+ from ..exceptions import CaptionReadNoCaptions, CaptionReadTimingError
14
11
  from ..geometry import Alignment, HorizontalAlignmentEnum, Layout, Padding, Size
15
12
  from .parser import SAMIParser
16
13
 
@@ -65,7 +62,8 @@ class SAMIReader(BaseReader):
65
62
  caption_dict[language] = lang_captions
66
63
 
67
64
  caption_set = CaptionSet(
68
- caption_dict, layout_info=global_layout,
65
+ caption_dict,
66
+ layout_info=global_layout,
69
67
  visual_alignment_default=HorizontalAlignmentEnum.LEFT,
70
68
  )
71
69
 
@@ -244,7 +242,7 @@ class SAMIReader(BaseReader):
244
242
  attrs = {}
245
243
  css_attrs = tag.attrs
246
244
 
247
- if "class" in css_attrs:
245
+ if "class" in css_attrs and css_attrs["class"]:
248
246
  attrs["class"] = css_attrs["class"][0].lower()
249
247
  if "id" in css_attrs:
250
248
  attrs["class"] = css_attrs["id"].lower()
@@ -42,9 +42,7 @@ class SAMIWriter(BaseWriter):
42
42
  """Serialize a CaptionSet into a SAMI document string."""
43
43
  caption_set = deepcopy(caption_set)
44
44
  source_default = self._get_visual_alignment_default(caption_set)
45
- self._promote_center = (
46
- source_default == HorizontalAlignmentEnum.CENTER
47
- )
45
+ self._promote_center = source_default == HorizontalAlignmentEnum.CENTER
48
46
  sami = BeautifulSoup(SAMI_BASE_MARKUP, "lxml-xml")
49
47
 
50
48
  caption_set.layout_info = self._relativize_and_fit_to_screen(
@@ -310,7 +308,7 @@ class SAMIWriter(BaseWriter):
310
308
 
311
309
  style = ""
312
310
  klass = ""
313
- if "classes" in content:
311
+ if "classes" in content and content["classes"]:
314
312
  klass += f' class="{" ".join(content["classes"])}"'
315
313
  elif "class" in content:
316
314
  klass += f' class="{content["class"]}"'
@@ -82,12 +82,12 @@ from collections import deque
82
82
  from copy import deepcopy
83
83
 
84
84
  from pycaption.base import BaseReader, CaptionSet
85
- from pycaption.geometry import HorizontalAlignmentEnum
86
85
  from pycaption.exceptions import (
87
86
  CaptionLineLengthError,
88
87
  CaptionReadNoCaptions,
89
88
  CaptionReadTimingError,
90
89
  )
90
+ from pycaption.geometry import HorizontalAlignmentEnum
91
91
 
92
92
  from .constants import (
93
93
  CHARACTERS,
@@ -107,7 +107,7 @@ from .specialized_collections import (
107
107
  )
108
108
  from .state_machines import DefaultProvidingPositionTracker
109
109
 
110
-
110
+ _TIMECODE_RE = re.compile(r"\d{2}:\d{2}:\d{2}[:;](\d{1,2})")
111
111
 
112
112
 
113
113
  class SCCReader(BaseReader):
@@ -142,6 +142,7 @@ class SCCReader(BaseReader):
142
142
  self.roll_rows_expected = 0
143
143
  self.simulate_roll_up = False
144
144
 
145
+ self._raw_lines = []
145
146
  self.time = 0
146
147
 
147
148
  def detect(self, content):
@@ -181,8 +182,8 @@ class SCCReader(BaseReader):
181
182
 
182
183
  self.simulate_roll_up = simulate_roll_up
183
184
  self.time_translator.offset = offset * 1000000
184
- # split lines
185
185
  lines = content.splitlines()
186
+ self._raw_lines = lines
186
187
 
187
188
  # loop through each line except the first
188
189
  for line in lines[1:]:
@@ -204,11 +205,13 @@ class SCCReader(BaseReader):
204
205
 
205
206
  Raises CaptionReadNoCaptions if no captions were parsed,
206
207
  CaptionLineLengthError if any line exceeds 32 characters,
208
+ CaptionReadTimingError if any timecode has frame >= 30,
207
209
  and CaptionReadTimingError if any cue is shorter than 0.05s.
208
210
  """
209
211
  if captions.is_empty():
210
212
  raise CaptionReadNoCaptions("empty caption file")
211
213
 
214
+ self._validate_frame_numbers()
212
215
  self._validate_line_lengths()
213
216
 
214
217
  for cap in captions.get_captions(lang):
@@ -219,6 +222,22 @@ class SCCReader(BaseReader):
219
222
  f"must be at least 0.05 seconds."
220
223
  )
221
224
 
225
+ def _validate_frame_numbers(self):
226
+ """Raise CaptionReadTimingError if any timecode has frames >= 30."""
227
+ violations = []
228
+ for line in self._raw_lines:
229
+ match = _TIMECODE_RE.match(line.strip())
230
+ if match:
231
+ frames = int(match.group(1))
232
+ if frames >= 30:
233
+ timecode = match.group(0)
234
+ violations.append(f"{timecode} (frame {frames})")
235
+ if violations:
236
+ raise CaptionReadTimingError(
237
+ "Frame number must be 0-29 in SCC timecodes.\n"
238
+ "Invalid timecodes:\n" + "\n".join(violations)
239
+ )
240
+
222
241
  def _validate_line_lengths(self):
223
242
  """Raise CaptionLineLengthError if any line exceeds 32 characters."""
224
243
  violations = [
@@ -473,9 +492,7 @@ class SCCReader(BaseReader):
473
492
  def _new_buffer(self):
474
493
  """Create a fresh InstructionNodeCreator bound to the shared
475
494
  position tracker."""
476
- return InstructionNodeCreator(
477
- position_tracker=self.position_tracker
478
- )
495
+ return InstructionNodeCreator(position_tracker=self.position_tracker)
479
496
 
480
497
  def _reset_buffer(self):
481
498
  """Replace the active buffer with a fresh creator and reset position state."""
@@ -632,4 +649,3 @@ class _SccTimeTranslator:
632
649
  """
633
650
  self._time = timespec
634
651
  self._frames = 0
635
-
@@ -99,6 +99,7 @@ class SCCWriter(BaseWriter):
99
99
  codes = self._encode_all(classified)
100
100
  codes = self._adjust_timing(codes)
101
101
  codes = self._deduplicate_timestamps(codes)
102
+ codes = self._clamp_end_times(codes)
102
103
  output += self._render_mixed(codes)
103
104
 
104
105
  return output
@@ -195,6 +196,17 @@ class SCCWriter(BaseWriter):
195
196
  last_emitted_frame = self._microseconds_to_frame(start)
196
197
  return codes
197
198
 
199
+ @staticmethod
200
+ def _clamp_end_times(codes):
201
+ """Suppress end times that drift behind start after timing adjustments.
202
+ When dense cues push start forward beyond the original end, emit no
203
+ clear command (end=None) rather than producing an invalid negative-
204
+ duration cue."""
205
+ for index, (code, start, end, mode, depth) in enumerate(codes):
206
+ if end is not None and end <= start:
207
+ codes[index] = (code, start, None, mode, depth)
208
+ return codes
209
+
198
210
  def _render_mixed(self, codes):
199
211
  """Serialize all cues in chronological order, emitting mode-appropriate
200
212
  preambles inline. Handles pop-on (ENM+RCL...EDM+EOC), roll-up
@@ -225,12 +237,7 @@ class SCCWriter(BaseWriter):
225
237
  code_tokens = code.split()
226
238
 
227
239
  if len(code_tokens) <= max_payload:
228
- return (
229
- f"{ts}\t"
230
- "94ae 94ae 9420 9420 "
231
- f"{code}"
232
- "942c 942c 942f 942f\n\n"
233
- )
240
+ return f"{ts}\t" "94ae 94ae 9420 9420 " f"{code}" "942c 942c 942f 942f\n\n"
234
241
 
235
242
  output = ""
236
243
  offset = 0
@@ -243,11 +250,7 @@ class SCCWriter(BaseWriter):
243
250
  is_last = offset + max_payload >= len(code_tokens)
244
251
  if is_last:
245
252
  line = line + ["942c", "942c", "942f", "942f"]
246
- output += (
247
- f"{self._format_timestamp(start)}\t"
248
- + " ".join(line)
249
- + "\n\n"
250
- )
253
+ output += f"{self._format_timestamp(start)}\t" + " ".join(line) + "\n\n"
251
254
  offset += max_payload
252
255
  if not is_last:
253
256
  start += MICROSECONDS_PER_CODEWORD
@@ -329,6 +332,7 @@ class SCCWriter(BaseWriter):
329
332
  base_row = max(1, min(15, round((y.value - 5) / 90.0 * 15) + 1))
330
333
  else:
331
334
  base_row = 15
335
+ base_row = max(1, min(base_row, 16 - num_lines))
332
336
  row = base_row + line_index
333
337
  return min(row, 15)
334
338
  return max(1, min(15, 16 - num_lines + line_index))
@@ -6,7 +6,7 @@ docstrings below each pattern explain what they capture and give examples.
6
6
 
7
7
  import re
8
8
 
9
- from ..geometry import HorizontalAlignmentEnum
9
+ from ..geometry import HorizontalAlignmentEnum, PositionAlignmentEnum
10
10
 
11
11
  TIMING_LINE_PATTERN = re.compile(r"^(\S+)\s+-->\s+(\S+)(?:\s+(.*?))?\s*$")
12
12
  """
@@ -52,10 +52,13 @@ Matches two percentage values (integer or decimal) separated by a comma:
52
52
  0%,0%
53
53
  100%,100%
54
54
  """
55
- CUE_SETTING_PATTERN = re.compile(r"(position|line|size|align|vertical):([\w.%-]+)")
55
+ CUE_SETTING_PATTERN = re.compile(
56
+ r"(position|line|size|align|vertical):([\w.%-]+(?:,[\w.%-]+)?)"
57
+ )
56
58
  """
57
- Matches individual cue settings from the timing line:
58
- position:50% line:75% size:80% align:center vertical:rl
59
+ Matches individual cue settings from the timing line, including optional
60
+ comma-separated alignment qualifiers per W3C WebVTT spec:
61
+ position:50% position:50%,line-left line:80%,center align:center
59
62
  """
60
63
  STYLE_SELECTOR_PATTERN = re.compile(r"::cue(?:\((\.?[\w-]+)\))?\s*\{([^}]*)\}")
61
64
  """
@@ -99,6 +102,15 @@ ALIGN_SETTING_MAP = {
99
102
  """Reverse mapping from WebVTT ``align`` setting strings to internal
100
103
  HorizontalAlignmentEnum values (used by the reader)."""
101
104
 
105
+ POSITION_ALIGN_MAP = {
106
+ "line-left": PositionAlignmentEnum.LINE_LEFT,
107
+ "center": PositionAlignmentEnum.CENTER,
108
+ "line-right": PositionAlignmentEnum.LINE_RIGHT,
109
+ "start": PositionAlignmentEnum.LINE_LEFT,
110
+ "end": PositionAlignmentEnum.LINE_RIGHT,
111
+ }
112
+ """Maps WebVTT positionAlign qualifier strings to PositionAlignmentEnum."""
113
+
102
114
 
103
115
  def _is_note_start(line):
104
116
  """Return True if the line begins a WebVTT NOTE block."""