pycaption 2.3.5__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.
- {pycaption-2.3.5/pycaption.egg-info → pycaption-2.3.6}/PKG-INFO +1 -1
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/dfxp/writer.py +4 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/geometry.py +127 -58
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/scc/reader.py +23 -7
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/webvtt/constants.py +16 -4
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/webvtt/reader.py +79 -32
- {pycaption-2.3.5 → pycaption-2.3.6/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.5 → pycaption-2.3.6}/setup.py +1 -1
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/conftest.py +3 -1
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/fixtures/dfxp.py +2 -3
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/fixtures/scc.py +29 -7
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_dfxp.py +3 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_geometry.py +117 -4
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_scc.py +36 -9
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_webvtt.py +175 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/LICENSE +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/MANIFEST.in +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/README.rst +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/__init__.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/base.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/dfxp/constants.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/dfxp/reader.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/microdvd.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/sami/parser.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/sami/reader.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/sami/writer.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/scc/specialized_collections.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/scc/writer.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/srt.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/transcript.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/utils.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption/webvtt/writer.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/setup.cfg +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/__init__.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/mixins.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_base.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_bytes_input.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_double_encoding.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_functions.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_sami.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_srt.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.3.5 → pycaption-2.3.6}/tests/test_webvtt_conversion.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
152
|
+
first, second = attribute.split(" ")
|
|
153
|
+
first = Size.from_string(first)
|
|
154
|
+
second = Size.from_string(second)
|
|
140
155
|
|
|
141
|
-
return cls(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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.
|
|
779
|
+
new_extent = self._corrected_extent_from(
|
|
780
|
+
origin, diff_horizontal, diff_vertical
|
|
781
|
+
)
|
|
739
782
|
|
|
740
783
|
return Layout(
|
|
741
|
-
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
|
|
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 =
|
|
817
|
+
bottom_right = origin.add_stretch(self.extent)
|
|
751
818
|
|
|
752
|
-
if (
|
|
753
|
-
|
|
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."
|
|
@@ -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
|
-
|
|
@@ -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(
|
|
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
|
-
|
|
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."""
|