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.
- {pycaption-2.3.4/pycaption.egg-info → pycaption-2.3.6}/PKG-INFO +1 -1
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/constants.py +1 -1
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/writer.py +7 -18
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/geometry.py +126 -66
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/writer.py +0 -6
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/reader.py +23 -7
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/specialized_collections.py +0 -1
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/webvtt/constants.py +16 -4
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/webvtt/reader.py +79 -32
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/webvtt/writer.py +0 -3
- {pycaption-2.3.4 → pycaption-2.3.6/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.4 → pycaption-2.3.6}/setup.py +1 -1
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/conftest.py +3 -1
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/dfxp.py +2 -3
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/scc.py +29 -7
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/webvtt.py +1 -1
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_dfxp.py +3 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_geometry.py +117 -4
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_sami_conversion.py +4 -4
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_scc.py +36 -9
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_scc_conversion.py +4 -8
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_webvtt.py +175 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/LICENSE +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/MANIFEST.in +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/README.rst +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/__init__.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/base.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/dfxp/reader.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/microdvd.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/parser.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/sami/reader.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/scc/writer.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/srt.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/transcript.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/utils.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/setup.cfg +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/__init__.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/mixins.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_base.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_bytes_input.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_double_encoding.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_functions.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_sami.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_srt.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.3.4 → pycaption-2.3.6}/tests/test_webvtt_conversion.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
|
510
|
-
|
|
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
|
-
|
|
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,7 +608,7 @@ class Layout:
|
|
|
597
608
|
alignment=None,
|
|
598
609
|
webvtt_positioning=None,
|
|
599
610
|
writing_direction=None,
|
|
600
|
-
|
|
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
|
|
627
|
-
:param
|
|
628
|
-
|
|
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.
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
741
|
-
|
|
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.
|
|
779
|
+
new_extent = self._corrected_extent_from(
|
|
780
|
+
origin, diff_horizontal, diff_vertical
|
|
781
|
+
)
|
|
747
782
|
|
|
748
783
|
return Layout(
|
|
749
|
-
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
|
|
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 =
|
|
817
|
+
bottom_right = origin.add_stretch(self.extent)
|
|
760
818
|
|
|
761
|
-
if (
|
|
762
|
-
|
|
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
|
-
|
|
@@ -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."""
|