pycaption 2.3.4__tar.gz → 2.3.6__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.4/pycaption.egg-info → pycaption-2.3.6}/PKG-INFO +1 -1
  2. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/constants.py +1 -1
  3. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/writer.py +7 -18
  4. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/geometry.py +126 -66
  5. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/writer.py +0 -6
  6. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/reader.py +23 -7
  7. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/specialized_collections.py +0 -1
  8. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/webvtt/constants.py +16 -4
  9. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/webvtt/reader.py +79 -32
  10. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/webvtt/writer.py +0 -3
  11. {pycaption-2.3.4 → pycaption-2.3.6/pycaption.egg-info}/PKG-INFO +1 -1
  12. {pycaption-2.3.4 → pycaption-2.3.6}/setup.py +1 -1
  13. {pycaption-2.3.4 → pycaption-2.3.6}/tests/conftest.py +3 -1
  14. {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/dfxp.py +2 -3
  15. {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/scc.py +29 -7
  16. {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/webvtt.py +1 -1
  17. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_dfxp.py +3 -0
  18. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_geometry.py +117 -4
  19. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_sami_conversion.py +4 -4
  20. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_scc.py +36 -9
  21. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_scc_conversion.py +4 -8
  22. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_webvtt.py +175 -0
  23. {pycaption-2.3.4 → pycaption-2.3.6}/LICENSE +0 -0
  24. {pycaption-2.3.4 → pycaption-2.3.6}/MANIFEST.in +0 -0
  25. {pycaption-2.3.4 → pycaption-2.3.6}/README.rst +0 -0
  26. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/__init__.py +0 -0
  27. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/base.py +0 -0
  28. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/__init__.py +0 -0
  29. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/extras.py +0 -0
  30. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/reader.py +0 -0
  31. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/exceptions.py +0 -0
  32. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/microdvd.py +0 -0
  33. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/__init__.py +0 -0
  34. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/constants.py +0 -0
  35. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/parser.py +0 -0
  36. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/reader.py +0 -0
  37. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/__init__.py +0 -0
  38. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/constants.py +0 -0
  39. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/state_machines.py +0 -0
  40. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/translator.py +0 -0
  41. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/writer.py +0 -0
  42. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/srt.py +0 -0
  43. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/transcript.py +0 -0
  44. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/utils.py +0 -0
  45. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/webvtt/__init__.py +0 -0
  46. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption.egg-info/SOURCES.txt +0 -0
  47. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption.egg-info/dependency_links.txt +0 -0
  48. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption.egg-info/requires.txt +0 -0
  49. {pycaption-2.3.4 → pycaption-2.3.6}/pycaption.egg-info/top_level.txt +0 -0
  50. {pycaption-2.3.4 → pycaption-2.3.6}/setup.cfg +0 -0
  51. {pycaption-2.3.4 → pycaption-2.3.6}/tests/__init__.py +0 -0
  52. {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/__init__.py +0 -0
  53. {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/microdvd.py +0 -0
  54. {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/sami.py +0 -0
  55. {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/srt.py +0 -0
  56. {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/translated_scc.py +0 -0
  57. {pycaption-2.3.4 → pycaption-2.3.6}/tests/mixins.py +0 -0
  58. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_base.py +0 -0
  59. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_bytes_input.py +0 -0
  60. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_dfxp_conversion.py +0 -0
  61. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_dfxp_extras.py +0 -0
  62. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_double_encoding.py +0 -0
  63. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_functions.py +0 -0
  64. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_microdvd.py +0 -0
  65. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_microdvd_conversion.py +0 -0
  66. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_sami.py +0 -0
  67. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_scc_translator.py +0 -0
  68. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_scc_writer.py +0 -0
  69. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_srt.py +0 -0
  70. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_srt_conversion.py +0 -0
  71. {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_webvtt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.4
3
+ Version: 2.3.6
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -30,7 +30,7 @@ DFXP_DEFAULT_REGION = Layout(
30
30
  alignment=Alignment(HorizontalAlignmentEnum.START, VerticalAlignmentEnum.BOTTOM)
31
31
  )
32
32
 
33
- # Writer fallback alignment used when layout is None or SCC positional.
33
+ # Writer fallback alignment used when layout is None.
34
34
  DFXP_WRITER_FALLBACK_ALIGNMENT = Alignment(
35
35
  HorizontalAlignmentEnum.CENTER, VerticalAlignmentEnum.BOTTOM
36
36
  )
@@ -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
@@ -294,8 +298,7 @@ class RegionCreator:
294
298
  def _collect_unique_regions(caption_set, ignore_region):
295
299
  """Collect all unique Layout objects from the caption set.
296
300
 
297
- Excludes None, ignore_region (typically the default region), and
298
- SCC positional layouts (which map to the default center region).
301
+ Excludes None and ignore_region (typically the default region).
299
302
 
300
303
  :type caption_set: CaptionSet
301
304
  :param ignore_region: a Layout to exclude from the result
@@ -315,9 +318,6 @@ class RegionCreator:
315
318
 
316
319
  unique_regions.pop(None, None)
317
320
  unique_regions.pop(ignore_region, None)
318
- for layout in list(unique_regions):
319
- if layout and layout.is_positional_anchor:
320
- unique_regions.pop(layout)
321
321
  return unique_regions
322
322
 
323
323
  def _create_unique_regions(self, unique_layouts, dfxp, id_factory):
@@ -432,9 +432,6 @@ class RegionCreator:
432
432
  if not layout_info:
433
433
  layout_info = caption_set.layout_info
434
434
 
435
- if layout_info and layout_info.is_positional_anchor:
436
- layout_info = None
437
-
438
435
  region_id = self._region_map.get(layout_info)
439
436
  if not region_id:
440
437
  region_id = DFXP_DEFAULT_REGION_ID
@@ -506,8 +503,8 @@ def _convert_layout_to_attributes(layout, fallback_alignment=None):
506
503
  Maps origin, extent, padding, alignment, and writing_direction to their
507
504
  tts: namespace equivalents.
508
505
 
509
- When layout is None or carries a positional anchor (SCC row/col coords),
510
- uses fallback_alignment if provided (per RP 2052-10 center promotion).
506
+ When layout is None, uses fallback_alignment if provided
507
+ (per RP 2052-10 center promotion).
511
508
 
512
509
  :type layout: Layout | None
513
510
  :param fallback_alignment: Alignment to use when the layout lacks one.
@@ -521,14 +518,6 @@ def _convert_layout_to_attributes(layout, fallback_alignment=None):
521
518
  return _create_external_alignment(fallback_alignment)
522
519
  return result
523
520
 
524
- if layout.is_positional_anchor:
525
- if fallback_alignment:
526
- result.update(_create_external_alignment(fallback_alignment))
527
- writing_mode = _WRITING_DIRECTION_TO_DFXP.get(layout.writing_direction)
528
- if writing_mode:
529
- result["tts:writingMode"] = writing_mode
530
- return result
531
-
532
521
  if layout.origin:
533
522
  result["tts:origin"] = layout.origin.to_xml_attribute()
534
523
 
@@ -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,7 +608,7 @@ class Layout:
597
608
  alignment=None,
598
609
  webvtt_positioning=None,
599
610
  writing_direction=None,
600
- is_positional_anchor=False,
611
+ position_alignment=None,
601
612
  inherit_from=None,
602
613
  ):
603
614
  """
@@ -623,10 +634,9 @@ class Layout:
623
634
  :type writing_direction: WritingDirectionEnum
624
635
  :param writing_direction: WebVTT vertical writing direction (rl or lr).
625
636
 
626
- :type is_positional_anchor: bool
627
- :param is_positional_anchor: True when the origin/alignment describe a
628
- coordinate-system anchor point (e.g. SCC row/col positioning) rather
629
- than visual text alignment.
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).
630
640
 
631
641
  :type inherit_from: Layout
632
642
  :param inherit_from: A Layout with the positioning parameters to be
@@ -639,7 +649,7 @@ class Layout:
639
649
  self.alignment = alignment
640
650
  self.webvtt_positioning = webvtt_positioning
641
651
  self.writing_direction = writing_direction
642
- self.is_positional_anchor = is_positional_anchor
652
+ self.position_alignment = position_alignment
643
653
 
644
654
  if inherit_from:
645
655
  for attr_name in [
@@ -648,20 +658,24 @@ class Layout:
648
658
  "padding",
649
659
  "alignment",
650
660
  "writing_direction",
661
+ "position_alignment",
651
662
  ]:
652
663
  attr = getattr(self, attr_name)
653
664
  if not attr:
654
665
  setattr(self, attr_name, getattr(inherit_from, attr_name))
655
666
 
656
667
  def __bool__(self):
657
- return any((
658
- self.origin,
659
- self.extent,
660
- self.padding,
661
- self.alignment,
662
- self.webvtt_positioning,
663
- self.writing_direction,
664
- ))
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
+ )
665
679
 
666
680
  def __repr__(self):
667
681
  return (
@@ -677,16 +691,19 @@ class Layout:
677
691
  None if not self.padding else self.padding.serialized(),
678
692
  None if not self.alignment else self.alignment.serialized(),
679
693
  self.writing_direction,
694
+ self.position_alignment,
680
695
  )
681
696
 
682
- def __eq__(self, other):
697
+ def __eq__(self, other: object) -> bool:
698
+ if not isinstance(other, Layout):
699
+ return NotImplemented
683
700
  return (
684
- type(self) == type(other)
685
- and self.origin == other.origin
701
+ self.origin == other.origin
686
702
  and self.extent == other.extent
687
703
  and self.padding == other.padding
688
704
  and self.alignment == other.alignment
689
705
  and self.writing_direction == other.writing_direction
706
+ and self.position_alignment == other.position_alignment
690
707
  )
691
708
 
692
709
  def __hash__(self):
@@ -696,6 +713,7 @@ class Layout:
696
713
  + hash(self.padding) * 13
697
714
  + hash(self.alignment) * 5
698
715
  + hash(self.writing_direction) * 19
716
+ + hash(self.position_alignment) * 23
699
717
  + 17
700
718
  )
701
719
 
@@ -716,7 +734,7 @@ class Layout:
716
734
  params = {
717
735
  "alignment": self.alignment,
718
736
  "writing_direction": self.writing_direction,
719
- "is_positional_anchor": self.is_positional_anchor,
737
+ "position_alignment": self.position_alignment,
720
738
  }
721
739
  for attr_name in ["origin", "extent", "padding"]:
722
740
  attr = getattr(self, attr_name)
@@ -731,35 +749,77 @@ class Layout:
731
749
  technically valid but contain inconsistent settings that may cause
732
750
  long captions to be cut out of the screen.
733
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
+
734
756
  ATTENTION: This must be called on relativized objects (such as the one
735
757
  returned by as_percentage_of). All units are presumed to be percentages.
736
758
  """
737
759
  if not self.origin:
738
760
  return self
739
761
 
740
- diff_horizontal = Size(90 - self.origin.x.value, UnitEnum.PERCENT)
741
- 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)
742
775
 
743
776
  if not self.extent:
744
777
  new_extent = Stretch(diff_horizontal, diff_vertical)
745
778
  else:
746
- new_extent = self._corrected_extent(diff_horizontal, diff_vertical)
779
+ new_extent = self._corrected_extent_from(
780
+ origin, diff_horizontal, diff_vertical
781
+ )
747
782
 
748
783
  return Layout(
749
- origin=self.origin,
784
+ origin=origin,
750
785
  extent=new_extent,
751
786
  padding=self.padding,
752
787
  alignment=self.alignment,
753
788
  writing_direction=self.writing_direction,
754
- is_positional_anchor=self.is_positional_anchor,
755
789
  )
756
790
 
757
- 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):
758
816
  """Return extent clamped so origin + extent doesn't exceed the screen."""
759
- bottom_right = self.origin.add_stretch(self.extent)
817
+ bottom_right = origin.add_stretch(self.extent)
760
818
 
761
- if (bottom_right.x.unit != UnitEnum.PERCENT
762
- 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
+ ):
763
823
  raise ValueError(
764
824
  "Units must be relativized before extent "
765
825
  "can be calculated based on origin."
@@ -131,9 +131,6 @@ class SAMIWriter(BaseWriter):
131
131
  if not layout_info:
132
132
  return
133
133
 
134
- if layout_info.is_positional_anchor:
135
- return
136
-
137
134
  if layout_info.origin:
138
135
  if layout_info.origin.x:
139
136
  parts.append(f"margin-left:{layout_info.origin.x};")
@@ -152,9 +149,6 @@ class SAMIWriter(BaseWriter):
152
149
  if not layout_info:
153
150
  return "center" if self._promote_center else None
154
151
 
155
- if layout_info.is_positional_anchor:
156
- return "center" if self._promote_center else None
157
-
158
152
  if not layout_info.alignment:
159
153
  return "center" if self._promote_center else None
160
154
 
@@ -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
-
@@ -632,7 +632,6 @@ def _get_layout_from_tuple(position_tuple):
632
632
  return Layout(
633
633
  origin=Point(horizontal, vertical),
634
634
  alignment=Alignment(HorizontalAlignmentEnum.LEFT, VerticalAlignmentEnum.TOP),
635
- is_positional_anchor=True,
636
635
  )
637
636
 
638
637
 
@@ -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."""