pycaption 2.2.25__tar.gz → 2.2.26__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycaption-2.2.25/pycaption.egg-info → pycaption-2.2.26}/PKG-INFO +1 -1
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/geometry.py +10 -4
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/specialized_collections.py +10 -6
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/webvtt.py +170 -13
- {pycaption-2.2.25 → pycaption-2.2.26/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.2.25 → pycaption-2.2.26}/setup.py +1 -1
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_scc.py +4 -4
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_webvtt.py +186 -12
- {pycaption-2.2.25 → pycaption-2.2.26}/LICENSE +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/MANIFEST.in +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/README.rst +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/__init__.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/base.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/dfxp/base.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/exceptions.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/microdvd.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/sami.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/constants.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/scc/translator.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/srt.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/transcript.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption/utils.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/setup.cfg +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/__init__.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/conftest.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/sami.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/scc.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/srt.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/mixins.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_base.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_dfxp.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_functions.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_geometry.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_microdvd.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_sami.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_scc_translator.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_scc_writer.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_srt.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.2.25 → pycaption-2.2.26}/tests/test_webvtt_conversion.py +0 -0
|
@@ -213,9 +213,15 @@ class Stretch(TwoDimensionalObject):
|
|
|
213
213
|
|
|
214
214
|
|
|
215
215
|
class Region:
|
|
216
|
-
"""Represents the spatial coordinates of a rectangle
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
"""Represents the spatial coordinates of a rectangle.
|
|
217
|
+
|
|
218
|
+
NOTE: This class is currently dead code — not imported or used anywhere in
|
|
219
|
+
the codebase. It also contains known bugs (align_from_origin returns
|
|
220
|
+
inconsistent types, add_extent method does not exist on Point). We left it
|
|
221
|
+
here because it may serve as a starting point if we ever need geometric
|
|
222
|
+
rectangle operations beyond what Layout class provides (e.g. region
|
|
223
|
+
intersection or point-in-region checks).
|
|
224
|
+
If we'll ever need it, fix the bugs first.
|
|
219
225
|
"""
|
|
220
226
|
|
|
221
227
|
@classmethod
|
|
@@ -846,7 +852,7 @@ class Layout:
|
|
|
846
852
|
found_absolute_unit = False
|
|
847
853
|
if bottom_right.x.unit != UnitEnum.PERCENT:
|
|
848
854
|
found_absolute_unit = True
|
|
849
|
-
elif bottom_right.
|
|
855
|
+
elif bottom_right.y.unit != UnitEnum.PERCENT:
|
|
850
856
|
found_absolute_unit = True
|
|
851
857
|
|
|
852
858
|
if found_absolute_unit:
|
|
@@ -26,6 +26,9 @@ from .constants import (
|
|
|
26
26
|
|
|
27
27
|
PopOnCue = collections.namedtuple("PopOnCue", "buffer, start, end")
|
|
28
28
|
|
|
29
|
+
# First two hex chars of SCC codes that produce punctuation ['.', '!', '?', ',']
|
|
30
|
+
_PUNCTUATION_PREFIXES = frozenset(["ae", "a1", "bf", "2c"])
|
|
31
|
+
|
|
29
32
|
|
|
30
33
|
class PreCaption:
|
|
31
34
|
"""
|
|
@@ -444,8 +447,7 @@ class InstructionNodeCreator:
|
|
|
444
447
|
# handle mid-row codes that follows a text node
|
|
445
448
|
# don't add space if the next command adds one of
|
|
446
449
|
# ['.', '!', '?', ',']
|
|
447
|
-
|
|
448
|
-
next_is_punctuation = next_command and next_command[:2] in punctuation
|
|
450
|
+
next_is_punctuation = next_command and next_command[:2] in _PUNCTUATION_PREFIXES
|
|
449
451
|
prev_text_node = self.get_previous_text_node()
|
|
450
452
|
prev_node_is_break = prev_text_node is not None and any(
|
|
451
453
|
x.is_explicit_break()
|
|
@@ -501,9 +503,9 @@ class InstructionNodeCreator:
|
|
|
501
503
|
if len(collection) == 0:
|
|
502
504
|
return False
|
|
503
505
|
for element in collection[::-1]:
|
|
504
|
-
if element._type ==
|
|
506
|
+
if element._type == _InstructionNode.TEXT:
|
|
505
507
|
return False
|
|
506
|
-
elif element._type ==
|
|
508
|
+
elif element._type == _InstructionNode.BREAK:
|
|
507
509
|
return True
|
|
508
510
|
return False
|
|
509
511
|
|
|
@@ -782,6 +784,8 @@ def _format_italics(collection):
|
|
|
782
784
|
|
|
783
785
|
|
|
784
786
|
def _remove_spaces_at_end_of_the_line(collection):
|
|
787
|
+
if not collection:
|
|
788
|
+
return collection
|
|
785
789
|
for idx, node in enumerate(collection):
|
|
786
790
|
if (
|
|
787
791
|
idx > 0
|
|
@@ -826,7 +830,7 @@ def _remove_noop_on_off_italics(collection):
|
|
|
826
830
|
return new_collection
|
|
827
831
|
|
|
828
832
|
|
|
829
|
-
def
|
|
833
|
+
def _remove_noop_off_on_italics(collection):
|
|
830
834
|
"""Removes pairs of off-on italics nodes, that don't surround any other
|
|
831
835
|
node
|
|
832
836
|
|
|
@@ -867,7 +871,7 @@ def _remove_noop_italics(collection):
|
|
|
867
871
|
"""
|
|
868
872
|
new_collection = _remove_noop_on_off_italics(collection)
|
|
869
873
|
|
|
870
|
-
new_collection =
|
|
874
|
+
new_collection = _remove_noop_off_on_italics(new_collection)
|
|
871
875
|
|
|
872
876
|
return new_collection
|
|
873
877
|
|
|
@@ -10,7 +10,7 @@ from .exceptions import (
|
|
|
10
10
|
CaptionReadSyntaxError,
|
|
11
11
|
InvalidInputError,
|
|
12
12
|
)
|
|
13
|
-
from .geometry import HorizontalAlignmentEnum, Layout
|
|
13
|
+
from .geometry import HorizontalAlignmentEnum, Layout, Point, Size, Stretch, UnitEnum
|
|
14
14
|
|
|
15
15
|
# A WebVTT timing line has both start/end times and layout related settings
|
|
16
16
|
# (referred to as 'cue settings' in the documentation)
|
|
@@ -21,6 +21,19 @@ VOICE_SPAN_PATTERN = re.compile("<v(\\.\\w+)* ([^>]*)>")
|
|
|
21
21
|
OTHER_SPAN_PATTERN = re.compile(
|
|
22
22
|
r"</?([cibuv]|ruby|rt|lang|(\d+):(\d{2})(:\d{2})?\.(\d{3})).*?>"
|
|
23
23
|
) # These WebVTT tags are stripped off the cues on conversion
|
|
24
|
+
REGION_SETTING_PATTERN = re.compile(r"^([\w]+):(.+)$")
|
|
25
|
+
"""
|
|
26
|
+
Matches a setting name (word chars) followed by colon and a value:
|
|
27
|
+
id:region1
|
|
28
|
+
width:50%
|
|
29
|
+
"""
|
|
30
|
+
REGION_ANCHOR_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)%,(\d+(?:\.\d+)?)%$")
|
|
31
|
+
"""
|
|
32
|
+
Matches two percentage values (integer or decimal) separated by a comma:
|
|
33
|
+
0%,0%
|
|
34
|
+
100%,100%
|
|
35
|
+
"""
|
|
36
|
+
LINE_HEIGHT_VH = 5.33
|
|
24
37
|
|
|
25
38
|
WEBVTT_VERSION_OF = {
|
|
26
39
|
HorizontalAlignmentEnum.LEFT: "left",
|
|
@@ -69,6 +82,8 @@ class WebVTTReader(BaseReader):
|
|
|
69
82
|
return caption_set
|
|
70
83
|
|
|
71
84
|
def _parse(self, lines):
|
|
85
|
+
# State machine: cycles through waiting-for-timing → collecting-text
|
|
86
|
+
# → emit-caption-on-blank-line, repeat.
|
|
72
87
|
captions = CaptionList()
|
|
73
88
|
start = None
|
|
74
89
|
end = None
|
|
@@ -76,8 +91,13 @@ class WebVTTReader(BaseReader):
|
|
|
76
91
|
layout_info = None
|
|
77
92
|
found_timing = False
|
|
78
93
|
|
|
94
|
+
# Parse REGION blocks from the header area before processing cues
|
|
95
|
+
self._regions = self._parse_regions(lines)
|
|
96
|
+
|
|
79
97
|
for i, line in enumerate(lines):
|
|
80
98
|
if "-->" in line:
|
|
99
|
+
# Timing line found (e.g. "00:00:01.000 --> 00:00:03.000")
|
|
100
|
+
# marks the start of a new cue
|
|
81
101
|
found_timing = True
|
|
82
102
|
timing_line = i
|
|
83
103
|
last_start_time = captions[-1].start if captions else 0
|
|
@@ -91,6 +111,8 @@ class WebVTTReader(BaseReader):
|
|
|
91
111
|
raise type(e)(new_msg).with_traceback(tb) from None
|
|
92
112
|
|
|
93
113
|
elif "" == line:
|
|
114
|
+
# Blank line = block separator in WebVTT.
|
|
115
|
+
# If we were collecting a cue, finalize and store it.
|
|
94
116
|
if found_timing and nodes:
|
|
95
117
|
found_timing = False
|
|
96
118
|
caption = Caption(start, end, nodes, layout_info=layout_info)
|
|
@@ -98,24 +120,23 @@ class WebVTTReader(BaseReader):
|
|
|
98
120
|
nodes = []
|
|
99
121
|
else:
|
|
100
122
|
if found_timing:
|
|
123
|
+
# We're inside a cue — this line is cue text.
|
|
124
|
+
# Add a line break between multi-line cue text.
|
|
101
125
|
if nodes:
|
|
102
126
|
nodes.append(CaptionNode.create_break())
|
|
103
127
|
nodes.append(CaptionNode.create_text(self._decode(line)))
|
|
104
128
|
else:
|
|
105
|
-
#
|
|
129
|
+
# Outside a cue: cue identifiers, NOTE blocks,
|
|
130
|
+
# or other metadata — skip silently.
|
|
106
131
|
pass
|
|
107
132
|
|
|
108
|
-
#
|
|
133
|
+
# File may not end with a blank line — emit any remaining cue
|
|
109
134
|
if nodes:
|
|
110
135
|
caption = Caption(start, end, nodes, layout_info=layout_info)
|
|
111
136
|
captions.append(caption)
|
|
112
137
|
|
|
113
138
|
return captions
|
|
114
139
|
|
|
115
|
-
def _remove_styles(self, line):
|
|
116
|
-
partial_result = VOICE_SPAN_PATTERN.sub("\\2: ", line)
|
|
117
|
-
return OTHER_SPAN_PATTERN.sub("", partial_result)
|
|
118
|
-
|
|
119
140
|
def _validate_timings(self, start, end, last_start_time):
|
|
120
141
|
if start is None:
|
|
121
142
|
raise CaptionReadSyntaxError("Invalid cue start timestamp.")
|
|
@@ -147,7 +168,18 @@ class WebVTTReader(BaseReader):
|
|
|
147
168
|
|
|
148
169
|
layout_info = None
|
|
149
170
|
if cue_settings:
|
|
150
|
-
|
|
171
|
+
region_id = self._extract_region_id(cue_settings)
|
|
172
|
+
if region_id and region_id in self._regions:
|
|
173
|
+
layout_info = self._regions[region_id]
|
|
174
|
+
layout_info = Layout(
|
|
175
|
+
origin=layout_info.origin,
|
|
176
|
+
extent=layout_info.extent,
|
|
177
|
+
padding=layout_info.padding,
|
|
178
|
+
alignment=layout_info.alignment,
|
|
179
|
+
webvtt_positioning=cue_settings,
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
layout_info = Layout(webvtt_positioning=cue_settings)
|
|
151
183
|
|
|
152
184
|
return start, end, layout_info
|
|
153
185
|
|
|
@@ -189,12 +221,138 @@ class WebVTTReader(BaseReader):
|
|
|
189
221
|
s = s.replace("&", "&")
|
|
190
222
|
return s
|
|
191
223
|
|
|
224
|
+
def _parse_regions(self, lines):
|
|
225
|
+
"""Parse REGION blocks from the file header area.
|
|
226
|
+
|
|
227
|
+
A WebVTT region defines a named rectangular area on screen where cues
|
|
228
|
+
can be rendered. Regions appear before any cues with the syntax:
|
|
229
|
+
|
|
230
|
+
REGION
|
|
231
|
+
id:subtitle_area
|
|
232
|
+
width:50%
|
|
233
|
+
lines:3
|
|
234
|
+
regionanchor:0%,100%
|
|
235
|
+
viewportanchor:10%,90%
|
|
236
|
+
scroll:up
|
|
237
|
+
|
|
238
|
+
Supported settings:
|
|
239
|
+
id - unique identifier (required)
|
|
240
|
+
width - region width as percentage (default: 100%)
|
|
241
|
+
lines - visible line count (default: 3)
|
|
242
|
+
regionanchor - anchor point within region as x%,y% (default: 0%,100%)
|
|
243
|
+
viewportanchor - anchor point on viewport as x%,y% (default: 0%,100%)
|
|
244
|
+
scroll - scroll behavior, only "up" is valid (default: none)
|
|
245
|
+
|
|
246
|
+
:returns: dict mapping region id -> Layout
|
|
247
|
+
"""
|
|
248
|
+
regions = {}
|
|
249
|
+
i = 0
|
|
250
|
+
while i < len(lines):
|
|
251
|
+
line = lines[i].strip()
|
|
252
|
+
if line == "REGION" or line.startswith(("REGION\t", "REGION ")):
|
|
253
|
+
i += 1
|
|
254
|
+
settings = {}
|
|
255
|
+
seen_keys = set()
|
|
256
|
+
# Read settings until a blank line (block separator in WebVTT)
|
|
257
|
+
while i < len(lines) and lines[i].strip() != "":
|
|
258
|
+
# Match key:value pair (e.g. "width:50%")
|
|
259
|
+
m = REGION_SETTING_PATTERN.match(lines[i].strip())
|
|
260
|
+
if m:
|
|
261
|
+
key, value = m.group(1), m.group(2)
|
|
262
|
+
if key in seen_keys:
|
|
263
|
+
i += 1
|
|
264
|
+
continue
|
|
265
|
+
seen_keys.add(key)
|
|
266
|
+
settings[key] = value
|
|
267
|
+
i += 1
|
|
268
|
+
# Skip regions without id (spec requires it; cues can't reference them)
|
|
269
|
+
if "id" in settings:
|
|
270
|
+
region_id = settings["id"]
|
|
271
|
+
# First definition wins; duplicates are ignored (RULE-REG-009)
|
|
272
|
+
if region_id not in regions:
|
|
273
|
+
regions[region_id] = self._region_to_layout(settings)
|
|
274
|
+
elif "-->" in line:
|
|
275
|
+
# REGIONs only appear before cues; stop scanning once cues begin
|
|
276
|
+
break
|
|
277
|
+
else:
|
|
278
|
+
i += 1
|
|
279
|
+
return regions
|
|
280
|
+
|
|
281
|
+
def _region_to_layout(self, settings):
|
|
282
|
+
"""Convert parsed region settings dict into a Layout with origin/extent.
|
|
283
|
+
|
|
284
|
+
Uses W3C TTML-WebVTT mapping formulas:
|
|
285
|
+
origin_x = viewportanchor_x - (regionanchor_x / 100 * width)
|
|
286
|
+
origin_y = viewportanchor_y - (regionanchor_y / 100 * height)
|
|
287
|
+
height = lines * 5.33
|
|
288
|
+
"""
|
|
289
|
+
# Spec defaults per W3C WebVTT §6
|
|
290
|
+
width = 100.0
|
|
291
|
+
lines = 3
|
|
292
|
+
regionanchor_x, regionanchor_y = 0.0, 100.0
|
|
293
|
+
viewportanchor_x, viewportanchor_y = 0.0, 100.0
|
|
294
|
+
# Parse each setting, falling back to defaults on invalid values
|
|
295
|
+
if "width" in settings:
|
|
296
|
+
try:
|
|
297
|
+
width = float(settings["width"].rstrip("%"))
|
|
298
|
+
except ValueError:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
if "lines" in settings:
|
|
302
|
+
try:
|
|
303
|
+
lines = int(settings["lines"])
|
|
304
|
+
except ValueError:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
# regionanchor: which point inside the region is "pinned"
|
|
308
|
+
# e.g. 0%,100% means the bottom-left corner of the region
|
|
309
|
+
if "regionanchor" in settings:
|
|
310
|
+
m = REGION_ANCHOR_PATTERN.match(settings["regionanchor"])
|
|
311
|
+
if m:
|
|
312
|
+
regionanchor_x = float(m.group(1))
|
|
313
|
+
regionanchor_y = float(m.group(2))
|
|
314
|
+
|
|
315
|
+
# viewportanchor: where on the screen that pin is placed
|
|
316
|
+
# e.g. 10%,90% means 10% from left, 90% from top
|
|
317
|
+
if "viewportanchor" in settings:
|
|
318
|
+
m = REGION_ANCHOR_PATTERN.match(settings["viewportanchor"])
|
|
319
|
+
if m:
|
|
320
|
+
viewportanchor_x = float(m.group(1))
|
|
321
|
+
viewportanchor_y = float(m.group(2))
|
|
322
|
+
|
|
323
|
+
# Calculate the top-left corner (origin) of the region box.
|
|
324
|
+
# Each line is ~5.33% of viewport height (LINE_HEIGHT_VH).
|
|
325
|
+
height = lines * LINE_HEIGHT_VH
|
|
326
|
+
# The origin is where the viewport anchor is, offset back by how far
|
|
327
|
+
# the region anchor is into the box (as a fraction of box dimensions).
|
|
328
|
+
origin_x = viewportanchor_x - (regionanchor_x / 100.0 * width)
|
|
329
|
+
origin_y = viewportanchor_y - (regionanchor_y / 100.0 * height)
|
|
330
|
+
|
|
331
|
+
origin = Point(
|
|
332
|
+
Size(origin_x, UnitEnum.PERCENT),
|
|
333
|
+
Size(origin_y, UnitEnum.PERCENT),
|
|
334
|
+
)
|
|
335
|
+
extent = Stretch(
|
|
336
|
+
Size(width, UnitEnum.PERCENT),
|
|
337
|
+
Size(height, UnitEnum.PERCENT),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return Layout(
|
|
341
|
+
origin=origin,
|
|
342
|
+
extent=extent
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def _extract_region_id(cue_settings):
|
|
347
|
+
"""Extract region id from cue settings string, if present."""
|
|
348
|
+
for setting in cue_settings.split():
|
|
349
|
+
if setting.startswith("region:"):
|
|
350
|
+
return setting[7:]
|
|
351
|
+
return None
|
|
352
|
+
|
|
192
353
|
|
|
193
354
|
class WebVTTWriter(BaseWriter):
|
|
194
355
|
HEADER = "WEBVTT\n\n"
|
|
195
|
-
global_layout = None
|
|
196
|
-
video_width = None
|
|
197
|
-
video_height = None
|
|
198
356
|
|
|
199
357
|
def write(self, caption_set, lang=None):
|
|
200
358
|
"""
|
|
@@ -347,8 +505,7 @@ class WebVTTWriter(BaseWriter):
|
|
|
347
505
|
# Since there is no padding in WebVTT, the left padding is
|
|
348
506
|
# added to the total left offset (if it is defined and not
|
|
349
507
|
# relative),
|
|
350
|
-
|
|
351
|
-
left_offset += layout.padding.start
|
|
508
|
+
left_offset += layout.padding.start
|
|
352
509
|
# and removed from the total cue width
|
|
353
510
|
if cue_width:
|
|
354
511
|
cue_width -= layout.padding.start
|
|
@@ -696,10 +696,10 @@ class TestInterpretableNodeCreator:
|
|
|
696
696
|
node_creator.interpret_command("9429")
|
|
697
697
|
self.check_closing_italics_closing_on_style_change(node_creator)
|
|
698
698
|
|
|
699
|
-
def
|
|
699
|
+
def test_remove_noop_off_on_italics(self):
|
|
700
700
|
from pycaption.scc.specialized_collections import (
|
|
701
701
|
_InstructionNode,
|
|
702
|
-
|
|
702
|
+
_remove_noop_off_on_italics,
|
|
703
703
|
)
|
|
704
704
|
|
|
705
705
|
position_tracker = DefaultProvidingPositionTracker().default
|
|
@@ -719,7 +719,7 @@ class TestInterpretableNodeCreator:
|
|
|
719
719
|
assert node_creator._collection[-2].sets_italics_off()
|
|
720
720
|
assert node_creator._collection[-1].sets_italics_on()
|
|
721
721
|
|
|
722
|
-
new_collection =
|
|
722
|
+
new_collection = _remove_noop_off_on_italics(node_creator._collection)
|
|
723
723
|
|
|
724
724
|
# should eliminate italic tags, keep only the text node
|
|
725
725
|
assert len(new_collection) == 1
|
|
@@ -739,7 +739,7 @@ class TestInterpretableNodeCreator:
|
|
|
739
739
|
assert node_creator._collection[-2].is_text_node()
|
|
740
740
|
assert node_creator._collection[-1].sets_italics_on()
|
|
741
741
|
|
|
742
|
-
new_collection =
|
|
742
|
+
new_collection = _remove_noop_off_on_italics(node_creator._collection)
|
|
743
743
|
# should not eliminate any node
|
|
744
744
|
assert new_collection[-3].sets_italics_off()
|
|
745
745
|
assert new_collection[-2].is_text_node()
|
|
@@ -66,7 +66,7 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
66
66
|
assert cue.end == 18737000
|
|
67
67
|
|
|
68
68
|
def test_webvtt_cue_components_removed_from_text(self):
|
|
69
|
-
result = self.reader.
|
|
69
|
+
result = self.reader._decode(
|
|
70
70
|
"<c vIntro><b>Wikipedia</b> is a great adventure. <i>It may have "
|
|
71
71
|
"its shortcomings</i>, but it is<u> the largest</u> collective "
|
|
72
72
|
"knowledge construction endevour</c> <ruby>base text <rt>"
|
|
@@ -88,8 +88,7 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
88
88
|
# todo: same assert w/ different arguments -> this can be parametrized;
|
|
89
89
|
with pytest.raises(CaptionReadError):
|
|
90
90
|
WebVTTReader(ignore_timing_errors=False).read(
|
|
91
|
-
"\n" "00:00:20.000 --> 00:00:10.000\n"
|
|
92
|
-
"foo bar baz"
|
|
91
|
+
"\n" "00:00:20.000 --> 00:00:10.000\n" "foo bar baz"
|
|
93
92
|
)
|
|
94
93
|
|
|
95
94
|
with pytest.raises(CaptionReadError):
|
|
@@ -111,15 +110,13 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
111
110
|
# Even if timing errors are ignored, this has to raise an exception
|
|
112
111
|
with pytest.raises(CaptionReadSyntaxError):
|
|
113
112
|
WebVTTReader().read(
|
|
114
|
-
"\nNOTE invalid cue stamp\n"
|
|
115
|
-
"00:00:20.000 --> \nfoo bar baz\n"
|
|
113
|
+
"\nNOTE invalid cue stamp\n" "00:00:20.000 --> \nfoo bar baz\n"
|
|
116
114
|
)
|
|
117
115
|
|
|
118
116
|
# And this too
|
|
119
117
|
with pytest.raises(CaptionReadSyntaxError):
|
|
120
118
|
WebVTTReader().read(
|
|
121
|
-
"\n00:00:20,000 --> 00:00:22,000\n"
|
|
122
|
-
"Note the comma instead of point.\n"
|
|
119
|
+
"\n00:00:20,000 --> 00:00:22,000\n" "Note the comma instead of point.\n"
|
|
123
120
|
)
|
|
124
121
|
|
|
125
122
|
# todo: at this point it can be split into 2 separate tests
|
|
@@ -147,8 +144,7 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
147
144
|
def test_invalid_files(self):
|
|
148
145
|
with pytest.raises(CaptionReadError):
|
|
149
146
|
WebVTTReader(ignore_timing_errors=False).read(
|
|
150
|
-
"00:00:20.000 --> 00:00:10.000\n"
|
|
151
|
-
"Start time is greater than end time."
|
|
147
|
+
"00:00:20.000 --> 00:00:10.000\n" "Start time is greater than end time."
|
|
152
148
|
)
|
|
153
149
|
|
|
154
150
|
with pytest.raises(CaptionReadError):
|
|
@@ -181,9 +177,7 @@ class TestWebVTTWriter:
|
|
|
181
177
|
assert sample_webvtt_double_br == results
|
|
182
178
|
|
|
183
179
|
def test_break_node_positioning_is_ignored(
|
|
184
|
-
self,
|
|
185
|
-
webvtt_from_dfxp_with_conflicting_align,
|
|
186
|
-
dfxp_style_region_align_conflict
|
|
180
|
+
self, webvtt_from_dfxp_with_conflicting_align, dfxp_style_region_align_conflict
|
|
187
181
|
):
|
|
188
182
|
caption_set = DFXPReader().read(dfxp_style_region_align_conflict)
|
|
189
183
|
results = WebVTTWriter().write(caption_set)
|
|
@@ -202,3 +196,183 @@ class TestWebVTTWriter:
|
|
|
202
196
|
assert sample_webvtt_multi_lang_de == results
|
|
203
197
|
results = WebVTTWriter().write(caption_set, "en-US")
|
|
204
198
|
assert sample_webvtt_multi_lang_en == results
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TestWebVTTRegionParsing:
|
|
202
|
+
def setup_method(self):
|
|
203
|
+
self.reader = WebVTTReader()
|
|
204
|
+
|
|
205
|
+
def test_region_block_parsed_into_layout(self):
|
|
206
|
+
vtt = (
|
|
207
|
+
"WEBVTT\n\n"
|
|
208
|
+
"REGION\n"
|
|
209
|
+
"id:subtitle_area\n"
|
|
210
|
+
"width:50%\n"
|
|
211
|
+
"lines:3\n"
|
|
212
|
+
"regionanchor:0%,100%\n"
|
|
213
|
+
"viewportanchor:10%,90%\n"
|
|
214
|
+
"scroll:up\n\n"
|
|
215
|
+
"00:00:01.000 --> 00:00:03.000 region:subtitle_area\n"
|
|
216
|
+
"Hello world\n"
|
|
217
|
+
)
|
|
218
|
+
captions = self.reader.read(vtt)
|
|
219
|
+
cue = captions.get_captions("en-US")[0]
|
|
220
|
+
assert cue.layout_info is not None
|
|
221
|
+
assert cue.layout_info.origin is not None
|
|
222
|
+
assert cue.layout_info.extent is not None
|
|
223
|
+
|
|
224
|
+
def test_region_origin_calculation(self):
|
|
225
|
+
"""origin_x = viewportanchor_x - (regionanchor_x / 100 * width)
|
|
226
|
+
origin_y = viewportanchor_y - (regionanchor_y / 100 * height)
|
|
227
|
+
height = lines * 5.33
|
|
228
|
+
"""
|
|
229
|
+
vtt = (
|
|
230
|
+
"WEBVTT\n\n"
|
|
231
|
+
"REGION\n"
|
|
232
|
+
"id:box\n"
|
|
233
|
+
"width:50%\n"
|
|
234
|
+
"lines:3\n"
|
|
235
|
+
"regionanchor:0%,100%\n"
|
|
236
|
+
"viewportanchor:10%,90%\n\n"
|
|
237
|
+
"00:00:01.000 --> 00:00:03.000 region:box\n"
|
|
238
|
+
"Test\n"
|
|
239
|
+
)
|
|
240
|
+
captions = self.reader.read(vtt)
|
|
241
|
+
cue = captions.get_captions("en-US")[0]
|
|
242
|
+
layout = cue.layout_info
|
|
243
|
+
# origin_x = 10 - (0/100 * 50) = 10.0
|
|
244
|
+
assert layout.origin.x.value == pytest.approx(10.0)
|
|
245
|
+
# height = 3 * 5.33 = 15.99
|
|
246
|
+
# origin_y = 90 - (100/100 * 15.99) = 74.01
|
|
247
|
+
assert layout.origin.y.value == pytest.approx(74.01)
|
|
248
|
+
# extent = width=50%, height=15.99%
|
|
249
|
+
assert layout.extent.horizontal.value == pytest.approx(50.0)
|
|
250
|
+
assert layout.extent.vertical.value == pytest.approx(15.99)
|
|
251
|
+
|
|
252
|
+
def test_region_defaults(self):
|
|
253
|
+
"""width=100%, lines=3, regionanchor=0%,100%, viewportanchor=0%,100%"""
|
|
254
|
+
vtt = (
|
|
255
|
+
"WEBVTT\n\n"
|
|
256
|
+
"REGION\n"
|
|
257
|
+
"id:minimal\n\n"
|
|
258
|
+
"00:00:01.000 --> 00:00:03.000 region:minimal\n"
|
|
259
|
+
"Test\n"
|
|
260
|
+
)
|
|
261
|
+
captions = self.reader.read(vtt)
|
|
262
|
+
cue = captions.get_captions("en-US")[0]
|
|
263
|
+
layout = cue.layout_info
|
|
264
|
+
# width=100, lines=3, height=15.99
|
|
265
|
+
# regionanchor=0,100 viewportanchor=0,100
|
|
266
|
+
# origin_x = 0 - (0/100 * 100) = 0
|
|
267
|
+
# origin_y = 100 - (100/100 * 15.99) = 84.01
|
|
268
|
+
assert layout.origin.x.value == pytest.approx(0.0)
|
|
269
|
+
assert layout.origin.y.value == pytest.approx(84.01)
|
|
270
|
+
assert layout.extent.horizontal.value == pytest.approx(100.0)
|
|
271
|
+
assert layout.extent.vertical.value == pytest.approx(15.99)
|
|
272
|
+
|
|
273
|
+
def test_region_webvtt_positioning_passthrough(self):
|
|
274
|
+
"""VTT->VTT round-trip: cue settings string preserved."""
|
|
275
|
+
vtt = (
|
|
276
|
+
"WEBVTT\n\n"
|
|
277
|
+
"REGION\n"
|
|
278
|
+
"id:r1\n"
|
|
279
|
+
"width:50%\n\n"
|
|
280
|
+
"00:00:01.000 --> 00:00:03.000 region:r1\n"
|
|
281
|
+
"Hello\n"
|
|
282
|
+
)
|
|
283
|
+
captions = self.reader.read(vtt)
|
|
284
|
+
cue = captions.get_captions("en-US")[0]
|
|
285
|
+
assert cue.layout_info.webvtt_positioning == "region:r1"
|
|
286
|
+
|
|
287
|
+
def test_invalid_region_reference_ignored(self):
|
|
288
|
+
vtt = (
|
|
289
|
+
"WEBVTT\n\n" "00:00:01.000 --> 00:00:03.000 region:nonexistent\n" "Hello\n"
|
|
290
|
+
)
|
|
291
|
+
captions = self.reader.read(vtt)
|
|
292
|
+
cue = captions.get_captions("en-US")[0]
|
|
293
|
+
# Falls back to raw positioning passthrough
|
|
294
|
+
assert cue.layout_info.webvtt_positioning == "region:nonexistent"
|
|
295
|
+
assert cue.layout_info.origin is None
|
|
296
|
+
|
|
297
|
+
def test_duplicate_region_id_first_wins(self):
|
|
298
|
+
vtt = (
|
|
299
|
+
"WEBVTT\n\n"
|
|
300
|
+
"REGION\n"
|
|
301
|
+
"id:dup\n"
|
|
302
|
+
"width:40%\n\n"
|
|
303
|
+
"REGION\n"
|
|
304
|
+
"id:dup\n"
|
|
305
|
+
"width:80%\n\n"
|
|
306
|
+
"00:00:01.000 --> 00:00:03.000 region:dup\n"
|
|
307
|
+
"Test\n"
|
|
308
|
+
)
|
|
309
|
+
captions = self.reader.read(vtt)
|
|
310
|
+
cue = captions.get_captions("en-US")[0]
|
|
311
|
+
assert cue.layout_info.extent.horizontal.value == pytest.approx(40.0)
|
|
312
|
+
|
|
313
|
+
def test_multiple_regions(self):
|
|
314
|
+
vtt = (
|
|
315
|
+
"WEBVTT\n\n"
|
|
316
|
+
"REGION\n"
|
|
317
|
+
"id:top\n"
|
|
318
|
+
"width:100%\n"
|
|
319
|
+
"lines:2\n"
|
|
320
|
+
"viewportanchor:0%,10%\n"
|
|
321
|
+
"regionanchor:0%,0%\n\n"
|
|
322
|
+
"REGION\n"
|
|
323
|
+
"id:bottom\n"
|
|
324
|
+
"width:100%\n"
|
|
325
|
+
"lines:2\n"
|
|
326
|
+
"viewportanchor:0%,90%\n"
|
|
327
|
+
"regionanchor:0%,0%\n\n"
|
|
328
|
+
"00:00:01.000 --> 00:00:03.000 region:top\n"
|
|
329
|
+
"Top caption\n\n"
|
|
330
|
+
"00:00:01.000 --> 00:00:03.000 region:bottom\n"
|
|
331
|
+
"Bottom caption\n"
|
|
332
|
+
)
|
|
333
|
+
captions = self.reader.read(vtt)
|
|
334
|
+
cues = captions.get_captions("en-US")
|
|
335
|
+
# top: origin_y = 10 - (0/100 * 10.66) = 10.0
|
|
336
|
+
assert cues[0].layout_info.origin.y.value == pytest.approx(10.0)
|
|
337
|
+
# bottom: origin_y = 90 - (0/100 * 10.66) = 90.0
|
|
338
|
+
assert cues[1].layout_info.origin.y.value == pytest.approx(90.0)
|
|
339
|
+
|
|
340
|
+
def test_region_without_id_ignored(self):
|
|
341
|
+
vtt = (
|
|
342
|
+
"WEBVTT\n\n"
|
|
343
|
+
"REGION\n"
|
|
344
|
+
"width:50%\n"
|
|
345
|
+
"lines:3\n\n"
|
|
346
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
347
|
+
"No region reference\n"
|
|
348
|
+
)
|
|
349
|
+
captions = self.reader.read(vtt)
|
|
350
|
+
cue = captions.get_captions("en-US")[0]
|
|
351
|
+
assert cue.layout_info is None
|
|
352
|
+
|
|
353
|
+
def test_duplicate_setting_in_region_first_wins(self):
|
|
354
|
+
vtt = (
|
|
355
|
+
"WEBVTT\n\n"
|
|
356
|
+
"REGION\n"
|
|
357
|
+
"id:r1\n"
|
|
358
|
+
"width:40%\n"
|
|
359
|
+
"width:80%\n\n"
|
|
360
|
+
"00:00:01.000 --> 00:00:03.000 region:r1\n"
|
|
361
|
+
"Test\n"
|
|
362
|
+
)
|
|
363
|
+
captions = self.reader.read(vtt)
|
|
364
|
+
cue = captions.get_captions("en-US")[0]
|
|
365
|
+
assert cue.layout_info.extent.horizontal.value == pytest.approx(40.0)
|
|
366
|
+
|
|
367
|
+
def test_cue_without_region_unaffected(self):
|
|
368
|
+
vtt = (
|
|
369
|
+
"WEBVTT\n\n"
|
|
370
|
+
"REGION\n"
|
|
371
|
+
"id:r1\n"
|
|
372
|
+
"width:50%\n\n"
|
|
373
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
374
|
+
"No region\n"
|
|
375
|
+
)
|
|
376
|
+
captions = self.reader.read(vtt)
|
|
377
|
+
cue = captions.get_captions("en-US")[0]
|
|
378
|
+
assert cue.layout_info is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|