pycaption 2.2.26__tar.gz → 2.2.27__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.26/pycaption.egg-info → pycaption-2.2.27}/PKG-INFO +1 -1
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/webvtt.py +257 -24
- {pycaption-2.2.26 → pycaption-2.2.27/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.2.26 → pycaption-2.2.27}/setup.py +1 -1
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/conftest.py +2 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/webvtt.py +35 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_webvtt.py +339 -3
- pycaption-2.2.27/tests/test_webvtt_conversion.py +252 -0
- pycaption-2.2.26/tests/test_webvtt_conversion.py +0 -116
- {pycaption-2.2.26 → pycaption-2.2.27}/LICENSE +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/MANIFEST.in +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/README.rst +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/__init__.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/base.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/dfxp/base.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/exceptions.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/geometry.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/microdvd.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/sami.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/constants.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/specialized_collections.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/translator.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/srt.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/transcript.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/utils.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/setup.cfg +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/__init__.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/sami.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/scc.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/srt.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/mixins.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_base.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_dfxp.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_functions.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_geometry.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_microdvd.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_sami.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_scc.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_scc_translator.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_scc_writer.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_srt.py +0 -0
- {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_srt_conversion.py +0 -0
|
@@ -16,11 +16,37 @@ from .geometry import HorizontalAlignmentEnum, Layout, Point, Size, Stretch, Uni
|
|
|
16
16
|
# (referred to as 'cue settings' in the documentation)
|
|
17
17
|
# The following pattern captures [start], [end] and [cue settings] if existent
|
|
18
18
|
TIMING_LINE_PATTERN = re.compile(r"^(\S+)\s+-->\s+(\S+)(?:\s+(.*?))?\s*$")
|
|
19
|
+
"""
|
|
20
|
+
Captures [start_timestamp], [end_timestamp], and optional [cue_settings]
|
|
21
|
+
from a WebVTT timing line.
|
|
22
|
+
00:00:01.000 --> 00:00:03.000 align:start position:10%
|
|
23
|
+
"""
|
|
19
24
|
TIMESTAMP_PATTERN = re.compile(r"^(\d+):(\d{2})(:\d{2})?\.(\d{3})")
|
|
25
|
+
"""
|
|
26
|
+
Parses a single WebVTT timestamp into its components.
|
|
27
|
+
Captures: hours (or minutes), minutes (or seconds),
|
|
28
|
+
optional :seconds, milliseconds.
|
|
29
|
+
00:01:23.456 or 01:23.456
|
|
30
|
+
"""
|
|
20
31
|
VOICE_SPAN_PATTERN = re.compile("<v(\\.\\w+)* ([^>]*)>")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
32
|
+
"""
|
|
33
|
+
Matches a voice span opening tag, capturing the speaker annotation.
|
|
34
|
+
The speaker name is baked into cue text as "Speaker: " prefix.
|
|
35
|
+
<v Roger Bingham> or <v.loud Speaker>
|
|
36
|
+
"""
|
|
37
|
+
TAG_SPLIT_PATTERN = re.compile(r"(<[^>]+>)")
|
|
38
|
+
"""
|
|
39
|
+
Splits cue text into alternating [text, tag, text, tag, ...] segments.
|
|
40
|
+
The capturing group ensures matched tags are retained in the split result.
|
|
41
|
+
"""
|
|
42
|
+
KNOWN_TAGS = frozenset({"i", "b", "u", "c", "v", "lang", "ruby", "rt"})
|
|
43
|
+
"""
|
|
44
|
+
The set of recognized WebVTT inline tag names.
|
|
45
|
+
Used for classifying opening (<i>, <c.yellow>, <lang en>) and
|
|
46
|
+
closing (</i>, </c>, </v>, </lang>) tags during cue text parsing.
|
|
47
|
+
Note: "v" is included so </v> is consumed as a closing style node
|
|
48
|
+
(the opening <v> is already handled by VOICE_SPAN_PATTERN).
|
|
49
|
+
"""
|
|
24
50
|
REGION_SETTING_PATTERN = re.compile(r"^([\w]+):(.+)$")
|
|
25
51
|
"""
|
|
26
52
|
Matches a setting name (word chars) followed by colon and a value:
|
|
@@ -88,6 +114,7 @@ class WebVTTReader(BaseReader):
|
|
|
88
114
|
start = None
|
|
89
115
|
end = None
|
|
90
116
|
nodes = []
|
|
117
|
+
open_tags = []
|
|
91
118
|
layout_info = None
|
|
92
119
|
found_timing = False
|
|
93
120
|
|
|
@@ -115,16 +142,18 @@ class WebVTTReader(BaseReader):
|
|
|
115
142
|
# If we were collecting a cue, finalize and store it.
|
|
116
143
|
if found_timing and nodes:
|
|
117
144
|
found_timing = False
|
|
145
|
+
self._close_unclosed_tags(nodes, open_tags)
|
|
118
146
|
caption = Caption(start, end, nodes, layout_info=layout_info)
|
|
119
147
|
captions.append(caption)
|
|
120
148
|
nodes = []
|
|
149
|
+
open_tags = []
|
|
121
150
|
else:
|
|
122
151
|
if found_timing:
|
|
123
152
|
# We're inside a cue — this line is cue text.
|
|
124
153
|
# Add a line break between multi-line cue text.
|
|
125
154
|
if nodes:
|
|
126
155
|
nodes.append(CaptionNode.create_break())
|
|
127
|
-
nodes.
|
|
156
|
+
nodes.extend(self._parse_cue_text(line, open_tags))
|
|
128
157
|
else:
|
|
129
158
|
# Outside a cue: cue identifiers, NOTE blocks,
|
|
130
159
|
# or other metadata — skip silently.
|
|
@@ -132,6 +161,7 @@ class WebVTTReader(BaseReader):
|
|
|
132
161
|
|
|
133
162
|
# File may not end with a blank line — emit any remaining cue
|
|
134
163
|
if nodes:
|
|
164
|
+
self._close_unclosed_tags(nodes, open_tags)
|
|
135
165
|
caption = Caption(start, end, nodes, layout_info=layout_info)
|
|
136
166
|
captions.append(caption)
|
|
137
167
|
|
|
@@ -200,26 +230,194 @@ class WebVTTReader(BaseReader):
|
|
|
200
230
|
# Timestamp of the form [minutes]:[seconds].[milliseconds]
|
|
201
231
|
return microseconds(0, m[0], m[1], m[3])
|
|
202
232
|
|
|
203
|
-
def
|
|
233
|
+
def _parse_cue_text(self, line, open_tags=None):
|
|
234
|
+
"""Parse a single line of WebVTT cue text into a list of CaptionNodes.
|
|
235
|
+
|
|
236
|
+
Converts inline markup tags into CaptionNode.STYLE open/close pairs
|
|
237
|
+
and text content into CaptionNode.TEXT nodes.
|
|
238
|
+
|
|
239
|
+
Voice tags are handled before splitting (baked into text as
|
|
240
|
+
"Speaker: " prefix), matching the legacy behavior.
|
|
241
|
+
|
|
242
|
+
:param line: A single line of cue text (raw WebVTT)
|
|
243
|
+
:param open_tags: Mutable list tracking unclosed tag content dicts
|
|
244
|
+
across lines within a cue. Callers pass the same list for each
|
|
245
|
+
line so unclosed tags can be auto-closed at cue end.
|
|
246
|
+
:returns: list of CaptionNode
|
|
204
247
|
"""
|
|
205
|
-
|
|
206
|
-
|
|
248
|
+
line = line.strip()
|
|
249
|
+
# \2 is the speaker name capture group; replaces the full <v ...> tag
|
|
250
|
+
# with "Speaker: " so voice identity is preserved as plain text.
|
|
251
|
+
line = VOICE_SPAN_PATTERN.sub(r"\2: ", line)
|
|
252
|
+
|
|
253
|
+
nodes = []
|
|
254
|
+
# re.split() with a capturing group guarantees alternating segments:
|
|
255
|
+
# even indices = text (possibly ""), odd indices = captured tags.
|
|
256
|
+
# e.g. "Hello <i>world</i>" -> ["Hello ", "<i>", "world", "</i>", ""]
|
|
257
|
+
parts = TAG_SPLIT_PATTERN.split(line)
|
|
258
|
+
|
|
259
|
+
for i, part in enumerate(parts):
|
|
260
|
+
if not part:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
if i % 2 == 0:
|
|
264
|
+
# Even indices are plain text — decode entities and store
|
|
265
|
+
text = self._decode_entities(part)
|
|
266
|
+
if text:
|
|
267
|
+
nodes.append(CaptionNode.create_text(text))
|
|
268
|
+
else:
|
|
269
|
+
# Odd indices are tags — classify into style/text nodes
|
|
270
|
+
node = self._classify_tag(part)
|
|
271
|
+
if node is not None:
|
|
272
|
+
nodes.append(node)
|
|
273
|
+
if open_tags is not None and node.type_ == CaptionNode.STYLE:
|
|
274
|
+
if "timestamp" in node.content:
|
|
275
|
+
pass
|
|
276
|
+
elif node.start:
|
|
277
|
+
open_tags.append(node.content)
|
|
278
|
+
else:
|
|
279
|
+
self._pop_matching_tag(open_tags, node.content)
|
|
280
|
+
|
|
281
|
+
return nodes
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _pop_matching_tag(open_tags, content):
|
|
285
|
+
"""Remove the most recent matching open tag from the stack.
|
|
286
|
+
|
|
287
|
+
Matches by tag-type key (e.g. "classes", "lang") rather than full
|
|
288
|
+
dict equality, because closing tags lack the opener's value
|
|
289
|
+
(</c> -> {'classes': []} vs <c.yellow> -> {'classes': ['yellow']}).
|
|
207
290
|
"""
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
291
|
+
keys = set(content.keys())
|
|
292
|
+
for j in range(len(open_tags) - 1, -1, -1):
|
|
293
|
+
if set(open_tags[j].keys()) == keys:
|
|
294
|
+
open_tags.pop(j)
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _close_unclosed_tags(nodes, open_tags):
|
|
299
|
+
"""Emit closing STYLE nodes for any tags left open at cue end.
|
|
300
|
+
|
|
301
|
+
Per W3C WebVTT spec, unclosed tags are implicitly closed at the
|
|
302
|
+
end of the cue. Closing order is reverse of opening (LIFO).
|
|
303
|
+
"""
|
|
304
|
+
for content in reversed(open_tags):
|
|
305
|
+
nodes.append(CaptionNode.create_style(False, content))
|
|
306
|
+
|
|
307
|
+
def _classify_tag(self, tag_str):
|
|
308
|
+
"""Classify a captured tag string and return the appropriate
|
|
309
|
+
CaptionNode.
|
|
310
|
+
|
|
311
|
+
Returns a STYLE node for recognized tags, a TEXT node for
|
|
312
|
+
unrecognized angle-bracket content (e.g. "<LAUGHING>"), so that
|
|
313
|
+
arbitrary text in angle brackets is preserved rather than dropped.
|
|
314
|
+
|
|
315
|
+
:param tag_str: The raw tag string, e.g. "<i>", "</b>", "<c.yellow>"
|
|
316
|
+
:returns: CaptionNode or None
|
|
317
|
+
"""
|
|
318
|
+
# Strip the angle brackets: "<i>" -> "i", "</b>" -> "/b"
|
|
319
|
+
inner = tag_str[1:-1]
|
|
320
|
+
|
|
321
|
+
# Closing tag: starts with "/"
|
|
322
|
+
if inner.startswith("/"):
|
|
323
|
+
tag_name = inner[1:]
|
|
324
|
+
if tag_name in KNOWN_TAGS:
|
|
325
|
+
content = self._tag_content(tag_name)
|
|
326
|
+
if not content:
|
|
327
|
+
return None
|
|
328
|
+
return CaptionNode.create_style(False, content)
|
|
329
|
+
else:
|
|
330
|
+
text = self._decode_entities(tag_str)
|
|
331
|
+
return CaptionNode.create_text(text)
|
|
332
|
+
|
|
333
|
+
# Timestamp tag: e.g. "00:01:23.456"
|
|
334
|
+
m = TIMESTAMP_PATTERN.match(inner)
|
|
335
|
+
if m:
|
|
336
|
+
groups = m.groups()
|
|
337
|
+
if groups[2] is not None:
|
|
338
|
+
secs = groups[2].replace(":", "")
|
|
339
|
+
us = microseconds(
|
|
340
|
+
groups[0], groups[1], secs, groups[3]
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
us = microseconds(0, groups[0], groups[1], groups[3])
|
|
344
|
+
return CaptionNode.create_style(True, {"timestamp": us})
|
|
345
|
+
|
|
346
|
+
# Opening tag: extract tag name, optional .classes, optional annotation
|
|
347
|
+
# Examples: "i", "c.yellow", "lang en"
|
|
348
|
+
tag_name, class_suffix, annotation = self._parse_opening_tag(inner)
|
|
349
|
+
if tag_name in KNOWN_TAGS:
|
|
350
|
+
content = self._tag_content(tag_name, class_suffix, annotation)
|
|
351
|
+
return CaptionNode.create_style(True, content)
|
|
352
|
+
|
|
353
|
+
# Unrecognized — treat as literal text (e.g. "<LAUGHING & WHOOPS!>")
|
|
354
|
+
text = self._decode_entities(tag_str)
|
|
355
|
+
return CaptionNode.create_text(text)
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def _parse_opening_tag(inner):
|
|
359
|
+
"""Parse the inside of an opening tag into
|
|
360
|
+
(name, class_suffix, annotation).
|
|
361
|
+
|
|
362
|
+
"i" -> ("i", None, None)
|
|
363
|
+
"c.yellow" -> ("c", "yellow", None)
|
|
364
|
+
"lang en" -> ("lang", None, "en")
|
|
365
|
+
"c.a.b" -> ("c", "a.b", None)
|
|
366
|
+
|
|
367
|
+
:param inner: Tag content without angle brackets
|
|
368
|
+
:returns: tuple (tag_name, class_suffix, annotation)
|
|
369
|
+
"""
|
|
370
|
+
# Split on first space for annotation (e.g. "lang en-US")
|
|
371
|
+
if " " in inner:
|
|
372
|
+
tag_part, annotation = inner.split(" ", 1)
|
|
373
|
+
else:
|
|
374
|
+
tag_part, annotation = inner, None
|
|
375
|
+
|
|
376
|
+
# Split on first dot for class suffix (e.g. "c.yellow.highlight")
|
|
377
|
+
if "." in tag_part:
|
|
378
|
+
tag_name, class_suffix = tag_part.split(".", 1)
|
|
379
|
+
else:
|
|
380
|
+
tag_name, class_suffix = tag_part, None
|
|
381
|
+
|
|
382
|
+
return tag_name, class_suffix, annotation
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _tag_content(tag_name, class_suffix=None, annotation=None):
|
|
386
|
+
"""Build the style content dict for a tag.
|
|
387
|
+
|
|
388
|
+
:returns: dict
|
|
389
|
+
"""
|
|
390
|
+
if tag_name == "i":
|
|
391
|
+
return {"italics": True}
|
|
392
|
+
elif tag_name == "b":
|
|
393
|
+
return {"bold": True}
|
|
394
|
+
elif tag_name == "u":
|
|
395
|
+
return {"underline": True}
|
|
396
|
+
elif tag_name == "c":
|
|
397
|
+
classes = class_suffix.split(".") if class_suffix else []
|
|
398
|
+
return {"classes": classes}
|
|
399
|
+
elif tag_name == "lang":
|
|
400
|
+
return {"lang": annotation.strip() if annotation else ""}
|
|
401
|
+
elif tag_name == "ruby":
|
|
402
|
+
return {"ruby": True}
|
|
403
|
+
elif tag_name == "rt":
|
|
404
|
+
return {"ruby_text": True}
|
|
405
|
+
return {}
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _decode_entities(text):
|
|
409
|
+
"""Decode WebVTT character entities in a text segment.
|
|
410
|
+
|
|
411
|
+
:type text: str
|
|
412
|
+
:rtype: str
|
|
413
|
+
"""
|
|
414
|
+
text = text.replace("<", "<")
|
|
415
|
+
text = text.replace(">", ">")
|
|
416
|
+
text = text.replace("‎", "")
|
|
417
|
+
text = text.replace("‏", "")
|
|
418
|
+
text = text.replace(" ", " ")
|
|
419
|
+
text = text.replace("&", "&")
|
|
420
|
+
return text
|
|
223
421
|
|
|
224
422
|
def _parse_regions(self, lines):
|
|
225
423
|
"""Parse REGION blocks from the file header area.
|
|
@@ -574,20 +772,24 @@ class WebVTTWriter(BaseWriter):
|
|
|
574
772
|
node.content, caption_set
|
|
575
773
|
)
|
|
576
774
|
|
|
775
|
+
has_text_style = False
|
|
577
776
|
styles = ["italics", "underline", "bold"]
|
|
578
777
|
if not node.start:
|
|
579
778
|
styles.reverse()
|
|
580
779
|
|
|
581
780
|
for style in styles:
|
|
582
781
|
if style in resulting_style and resulting_style[style]:
|
|
782
|
+
has_text_style = True
|
|
583
783
|
tags = self._convert_style_to_text_tag(style)
|
|
584
784
|
if node.start:
|
|
585
785
|
s += tags[0]
|
|
586
786
|
else:
|
|
587
787
|
s += tags[1]
|
|
588
788
|
|
|
589
|
-
|
|
590
|
-
|
|
789
|
+
if not has_text_style:
|
|
790
|
+
s += self._convert_structural_tag(
|
|
791
|
+
node.content, node.start
|
|
792
|
+
)
|
|
591
793
|
elif node.type_ == CaptionNode.BREAK:
|
|
592
794
|
if i > 0 and nodes[i - 1].type_ != CaptionNode.TEXT:
|
|
593
795
|
s += " "
|
|
@@ -622,3 +824,34 @@ class WebVTTWriter(BaseWriter):
|
|
|
622
824
|
# s = s.replace('\u200f', '‏')
|
|
623
825
|
# s = s.replace('\u00a0', ' ')
|
|
624
826
|
return s
|
|
827
|
+
|
|
828
|
+
def _convert_structural_tag(self, content, is_start):
|
|
829
|
+
"""Convert a structural style node back into a WebVTT tag string.
|
|
830
|
+
|
|
831
|
+
Structural tags are WebVTT-specific (class, lang, ruby, timestamp).
|
|
832
|
+
Other writers silently ignore these keys.
|
|
833
|
+
|
|
834
|
+
:param content: The style node's content dict
|
|
835
|
+
:param is_start: True for opening tag, False for closing
|
|
836
|
+
:returns: str
|
|
837
|
+
"""
|
|
838
|
+
if "lang" in content:
|
|
839
|
+
if is_start:
|
|
840
|
+
lang = content["lang"]
|
|
841
|
+
return f"<lang {lang}>" if lang else "<lang>"
|
|
842
|
+
return "</lang>"
|
|
843
|
+
elif "classes" in content:
|
|
844
|
+
if is_start:
|
|
845
|
+
classes = content["classes"]
|
|
846
|
+
class_str = "." + ".".join(classes) if classes else ""
|
|
847
|
+
return f"<c{class_str}>"
|
|
848
|
+
return "</c>"
|
|
849
|
+
elif "ruby" in content:
|
|
850
|
+
return "<ruby>" if is_start else "</ruby>"
|
|
851
|
+
elif "ruby_text" in content:
|
|
852
|
+
return "<rt>" if is_start else "</rt>"
|
|
853
|
+
elif "timestamp" in content:
|
|
854
|
+
if is_start:
|
|
855
|
+
return f"<{self._timestamp(content['timestamp'])}>"
|
|
856
|
+
return ""
|
|
857
|
+
return ""
|
|
@@ -169,5 +169,7 @@ from tests.fixtures.webvtt import ( # noqa: F401
|
|
|
169
169
|
sample_webvtt_output_long_cue,
|
|
170
170
|
sample_webvtt_timestamps,
|
|
171
171
|
sample_webvtt_with_cue_settings,
|
|
172
|
+
sample_webvtt_with_inline_style,
|
|
173
|
+
sample_webvtt_with_structural_tags,
|
|
172
174
|
webvtt_from_dfxp_with_conflicting_align,
|
|
173
175
|
)
|
|
@@ -380,3 +380,38 @@ Test zero padded and two digit timestamps without hours
|
|
|
380
380
|
|
|
381
381
|
01:01:01.001 --> 10:10:10.100
|
|
382
382
|
Test zero padded and two digit timestamps without hours"""
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@pytest.fixture(scope="session")
|
|
386
|
+
def sample_webvtt_with_inline_style():
|
|
387
|
+
return """\
|
|
388
|
+
WEBVTT
|
|
389
|
+
|
|
390
|
+
00:00:01.000 --> 00:00:03.000
|
|
391
|
+
Hello <i>world</i>
|
|
392
|
+
|
|
393
|
+
00:00:04.000 --> 00:00:06.000
|
|
394
|
+
<b>bold</b> and <u>underlined</u>
|
|
395
|
+
|
|
396
|
+
00:00:07.000 --> 00:00:09.000
|
|
397
|
+
<b><i>nested bold italic</i></b>
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@pytest.fixture(scope="session")
|
|
402
|
+
def sample_webvtt_with_structural_tags():
|
|
403
|
+
return """\
|
|
404
|
+
WEBVTT
|
|
405
|
+
|
|
406
|
+
00:00:01.000 --> 00:00:03.000
|
|
407
|
+
<c.yellow>colored text</c>
|
|
408
|
+
|
|
409
|
+
00:00:04.000 --> 00:00:06.000
|
|
410
|
+
<lang fr>Bonjour le monde</lang>
|
|
411
|
+
|
|
412
|
+
00:00:07.000 --> 00:00:09.000
|
|
413
|
+
<ruby>base text<rt>annotation</rt></ruby>
|
|
414
|
+
|
|
415
|
+
00:00:10.000 --> 00:00:15.000
|
|
416
|
+
Hello <00:00:12.000>world
|
|
417
|
+
"""
|