pycaption 2.3.1__tar.gz → 2.3.3__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.1/pycaption.egg-info → pycaption-2.3.3}/PKG-INFO +1 -1
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/base.py +134 -72
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/constants.py +12 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/extras.py +8 -5
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/reader.py +61 -45
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/writer.py +70 -20
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/geometry.py +127 -201
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/microdvd.py +39 -26
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/reader.py +15 -11
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/writer.py +96 -22
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/reader.py +86 -127
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/specialized_collections.py +143 -194
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/state_machines.py +4 -5
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/writer.py +91 -82
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/srt.py +20 -31
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/transcript.py +6 -4
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/webvtt/reader.py +36 -50
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/webvtt/writer.py +32 -40
- {pycaption-2.3.1 → pycaption-2.3.3/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.1 → pycaption-2.3.3}/setup.py +1 -1
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/webvtt.py +4 -4
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/mixins.py +23 -7
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_dfxp_conversion.py +7 -1
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_sami.py +1 -1
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_sami_conversion.py +8 -4
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_scc.py +8 -4
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_scc_conversion.py +22 -11
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_webvtt_conversion.py +10 -5
- {pycaption-2.3.1 → pycaption-2.3.3}/LICENSE +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/MANIFEST.in +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/README.rst +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/__init__.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/parser.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/utils.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/webvtt/constants.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/setup.cfg +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/__init__.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/conftest.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/scc.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_base.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_dfxp.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_functions.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_geometry.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_srt.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_webvtt.py +0 -0
|
@@ -9,7 +9,7 @@ import os
|
|
|
9
9
|
from datetime import timedelta
|
|
10
10
|
from numbers import Number
|
|
11
11
|
|
|
12
|
-
from .exceptions import CaptionReadError, CaptionReadTimingError
|
|
12
|
+
from .exceptions import CaptionReadError, CaptionReadTimingError, InvalidInputError
|
|
13
13
|
|
|
14
14
|
# `und` a special identifier for an undetermined language according to ISO 639-2
|
|
15
15
|
DEFAULT_LANGUAGE_CODE = os.getenv("PYCAPTION_DEFAULT_LANG", "und")
|
|
@@ -26,7 +26,7 @@ class CaptionConverter:
|
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
def __init__(self, captions=None):
|
|
29
|
-
self.captions = captions
|
|
29
|
+
self.captions = captions
|
|
30
30
|
|
|
31
31
|
def read(self, content, caption_reader):
|
|
32
32
|
"""Parse caption content using the given reader.
|
|
@@ -35,10 +35,12 @@ class CaptionConverter:
|
|
|
35
35
|
:param caption_reader: A BaseReader subclass instance.
|
|
36
36
|
:returns: self (for chaining).
|
|
37
37
|
"""
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
if not hasattr(caption_reader, "read"):
|
|
39
|
+
raise InvalidInputError(
|
|
40
|
+
"The caption_reader must be a BaseReader instance "
|
|
41
|
+
"with a read() method."
|
|
42
|
+
)
|
|
43
|
+
self.captions = caption_reader.read(content)
|
|
42
44
|
return self
|
|
43
45
|
|
|
44
46
|
def write(self, caption_writer):
|
|
@@ -48,10 +50,12 @@ class CaptionConverter:
|
|
|
48
50
|
:returns: The serialized caption string.
|
|
49
51
|
:rtype: str
|
|
50
52
|
"""
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
if not hasattr(caption_writer, "write"):
|
|
54
|
+
raise InvalidInputError(
|
|
55
|
+
"The caption_writer must be a BaseWriter instance "
|
|
56
|
+
"with a write() method."
|
|
57
|
+
)
|
|
58
|
+
return caption_writer.write(self.captions)
|
|
55
59
|
|
|
56
60
|
|
|
57
61
|
class BaseReader:
|
|
@@ -60,6 +64,33 @@ class BaseReader:
|
|
|
60
64
|
def __init__(self, *args, **kwargs):
|
|
61
65
|
pass
|
|
62
66
|
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _decode_content(content):
|
|
69
|
+
"""Decode bytes to str (UTF-8 with BOM handling).
|
|
70
|
+
|
|
71
|
+
:param content: str or bytes input.
|
|
72
|
+
:returns: decoded str with BOM stripped.
|
|
73
|
+
:raises InvalidInputError: if content is not str/bytes, is empty
|
|
74
|
+
bytes, or is not valid UTF-8.
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(content, bytes):
|
|
77
|
+
if not content:
|
|
78
|
+
raise InvalidInputError("The content is empty.")
|
|
79
|
+
try:
|
|
80
|
+
content = content.decode("utf-8-sig")
|
|
81
|
+
except UnicodeDecodeError as e:
|
|
82
|
+
raise InvalidInputError(
|
|
83
|
+
f"Content is not valid UTF-8: {e}"
|
|
84
|
+
) from e
|
|
85
|
+
elif isinstance(content, str):
|
|
86
|
+
if content.startswith(""):
|
|
87
|
+
content = content[1:]
|
|
88
|
+
else:
|
|
89
|
+
raise InvalidInputError(
|
|
90
|
+
"The content must be a unicode string or UTF-8 bytes."
|
|
91
|
+
)
|
|
92
|
+
return content
|
|
93
|
+
|
|
63
94
|
def detect(self, content):
|
|
64
95
|
"""Return True if content appears to be in this reader's format.
|
|
65
96
|
|
|
@@ -107,6 +138,28 @@ class BaseWriter:
|
|
|
107
138
|
self.video_height = video_height
|
|
108
139
|
self.fit_to_screen = fit_to_screen
|
|
109
140
|
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _get_visual_alignment_default(caption_set):
|
|
143
|
+
"""Return the source format's visual alignment default from a CaptionSet.
|
|
144
|
+
|
|
145
|
+
Per SMPTE RP 2052-10, caption formats have different implicit visual
|
|
146
|
+
defaults when no alignment is specified:
|
|
147
|
+
- CENTER: WebVTT, SRT, SCC, MicroDVD
|
|
148
|
+
- LEFT/START: DFXP/TTML, SAMI
|
|
149
|
+
|
|
150
|
+
When converting between formats with mismatched defaults, the source
|
|
151
|
+
default must be made explicit in the output to prevent visual
|
|
152
|
+
regression (e.g. centered text silently becoming left-aligned).
|
|
153
|
+
|
|
154
|
+
Readers declare their default via CaptionSet.visual_alignment_default.
|
|
155
|
+
Writers compare it to their own format's default to decide whether
|
|
156
|
+
to emit explicit alignment.
|
|
157
|
+
|
|
158
|
+
:type caption_set: CaptionSet
|
|
159
|
+
:rtype: HorizontalAlignmentEnum | None
|
|
160
|
+
"""
|
|
161
|
+
return caption_set.visual_alignment_default if caption_set else None
|
|
162
|
+
|
|
110
163
|
def _relativize_and_fit_to_screen(self, layout_info):
|
|
111
164
|
"""Apply relativization and fit-to-screen adjustments to a Layout.
|
|
112
165
|
|
|
@@ -124,13 +177,14 @@ class BaseWriter:
|
|
|
124
177
|
layout_info = layout_info.fit_to_screen()
|
|
125
178
|
return layout_info
|
|
126
179
|
|
|
127
|
-
def write(self,
|
|
180
|
+
def write(self, caption_set, **kwargs):
|
|
128
181
|
"""Serialize a CaptionSet. Subclasses override this.
|
|
129
182
|
|
|
130
|
-
:type
|
|
183
|
+
:type caption_set: CaptionSet
|
|
184
|
+
:param kwargs: Format-specific options (e.g. force, lang).
|
|
131
185
|
:rtype: str
|
|
132
186
|
"""
|
|
133
|
-
return
|
|
187
|
+
return caption_set
|
|
134
188
|
|
|
135
189
|
|
|
136
190
|
class CaptionNode:
|
|
@@ -146,8 +200,6 @@ class CaptionNode:
|
|
|
146
200
|
"""
|
|
147
201
|
|
|
148
202
|
TEXT = 1
|
|
149
|
-
# When and if this is extended, it might be better to turn it into a
|
|
150
|
-
# property of the node, not a type of node itself.
|
|
151
203
|
STYLE = 2
|
|
152
204
|
BREAK = 3
|
|
153
205
|
|
|
@@ -216,9 +268,9 @@ class Caption:
|
|
|
216
268
|
"""
|
|
217
269
|
Initialize the Caption object
|
|
218
270
|
:param start: The start time in microseconds
|
|
219
|
-
:type start:
|
|
271
|
+
:type start: int
|
|
220
272
|
:param end: The end time in microseconds
|
|
221
|
-
:type end:
|
|
273
|
+
:type end: int
|
|
222
274
|
:param nodes: A list of CaptionNodes
|
|
223
275
|
:type nodes: list
|
|
224
276
|
:param style: A dictionary with CSS-like styling rules
|
|
@@ -242,10 +294,8 @@ class Caption:
|
|
|
242
294
|
self.nodes = nodes
|
|
243
295
|
self.style = style or {}
|
|
244
296
|
self.layout_info = layout_info
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"""Return True if this caption has no nodes."""
|
|
248
|
-
return not self.nodes
|
|
297
|
+
self.caption_mode = None
|
|
298
|
+
self.roll_up_rows = None
|
|
249
299
|
|
|
250
300
|
def format_start(self, msec_separator=None):
|
|
251
301
|
"""Format start time as HH:MM:SS.mmm string.
|
|
@@ -273,33 +323,30 @@ class Caption:
|
|
|
273
323
|
|
|
274
324
|
:rtype: list[str]
|
|
275
325
|
"""
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
result.append("\n")
|
|
282
|
-
return result
|
|
326
|
+
return [
|
|
327
|
+
node.content if node.type_ == CaptionNode.TEXT else "\n"
|
|
328
|
+
for node in self.nodes
|
|
329
|
+
if node.type_ in (CaptionNode.TEXT, CaptionNode.BREAK)
|
|
330
|
+
]
|
|
283
331
|
|
|
284
332
|
def get_text(self):
|
|
285
333
|
"""Return the plain text content of this caption (no markup).
|
|
286
334
|
|
|
287
335
|
:rtype: str
|
|
288
336
|
"""
|
|
289
|
-
|
|
290
|
-
return "".join(text_nodes).strip()
|
|
337
|
+
return "".join(self.get_text_nodes()).strip()
|
|
291
338
|
|
|
292
|
-
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _format_timestamp(microseconds, msec_separator=None):
|
|
293
341
|
"""Convert microseconds to HH:MM:SS{sep}mmm string."""
|
|
294
342
|
duration = timedelta(microseconds=microseconds)
|
|
295
343
|
hours, rem = divmod(duration.seconds, 3600)
|
|
296
344
|
minutes, seconds = divmod(rem, 60)
|
|
297
|
-
milliseconds =
|
|
298
|
-
|
|
345
|
+
milliseconds = duration.microseconds // 1000
|
|
346
|
+
return (
|
|
299
347
|
f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
300
|
-
f"{msec_separator or '.'}{milliseconds
|
|
348
|
+
f"{msec_separator or '.'}{milliseconds:03d}"
|
|
301
349
|
)
|
|
302
|
-
return timestamp
|
|
303
350
|
|
|
304
351
|
|
|
305
352
|
class CaptionList(list):
|
|
@@ -320,14 +367,14 @@ class CaptionList(list):
|
|
|
320
367
|
return item
|
|
321
368
|
return CaptionList(item, layout_info=self.layout_info)
|
|
322
369
|
|
|
323
|
-
def __add__(self,
|
|
370
|
+
def __add__(self, value):
|
|
324
371
|
add_is_safe = (
|
|
325
|
-
not hasattr(
|
|
326
|
-
or not
|
|
327
|
-
or self.layout_info ==
|
|
372
|
+
not hasattr(value, "layout_info")
|
|
373
|
+
or not value.layout_info
|
|
374
|
+
or self.layout_info == value.layout_info
|
|
328
375
|
)
|
|
329
376
|
if add_is_safe:
|
|
330
|
-
return CaptionList(list.__add__(self,
|
|
377
|
+
return CaptionList(list.__add__(self, value), layout_info=self.layout_info)
|
|
331
378
|
else:
|
|
332
379
|
raise ValueError(
|
|
333
380
|
"Cannot add CaptionList objects with different layout_info"
|
|
@@ -348,17 +395,27 @@ class CaptionSet:
|
|
|
348
395
|
by all the children.
|
|
349
396
|
"""
|
|
350
397
|
|
|
351
|
-
def __init__(
|
|
398
|
+
def __init__(
|
|
399
|
+
self, captions, styles=None, layout_info=None, regions=None,
|
|
400
|
+
visual_alignment_default=None,
|
|
401
|
+
):
|
|
352
402
|
"""
|
|
353
403
|
:param captions: A dictionary of the format {'language': CaptionList}
|
|
354
404
|
:param styles: A dictionary with CSS-like styling rules
|
|
355
405
|
:param Layout layout_info: A Layout object with the positioning info
|
|
356
406
|
:param regions: A dictionary mapping region id to raw settings dict
|
|
407
|
+
:param visual_alignment_default: The source format's implicit text
|
|
408
|
+
alignment when no explicit alignment is specified. Per SMPTE
|
|
409
|
+
RP 2052-10, writers targeting a format with a different visual
|
|
410
|
+
default must emit this alignment explicitly to prevent visual
|
|
411
|
+
regression. Use HorizontalAlignmentEnum values.
|
|
412
|
+
:type visual_alignment_default: HorizontalAlignmentEnum | None
|
|
357
413
|
"""
|
|
358
414
|
self._captions = captions
|
|
359
415
|
self._styles = styles or {}
|
|
360
416
|
self._regions = regions or {}
|
|
361
417
|
self.layout_info = layout_info
|
|
418
|
+
self.visual_alignment_default = visual_alignment_default
|
|
362
419
|
|
|
363
420
|
def set_captions(self, lang, captions):
|
|
364
421
|
"""Replace the caption list for a given language.
|
|
@@ -429,7 +486,10 @@ class CaptionSet:
|
|
|
429
486
|
|
|
430
487
|
def is_empty(self):
|
|
431
488
|
"""Return True if no language contains any captions."""
|
|
432
|
-
|
|
489
|
+
for captions in self._captions.values():
|
|
490
|
+
if len(captions) > 0:
|
|
491
|
+
return False
|
|
492
|
+
return True
|
|
433
493
|
|
|
434
494
|
def set_layout_info(self, lang, layout_info):
|
|
435
495
|
"""Set the layout_info on the CaptionList for a given language.
|
|
@@ -469,44 +529,46 @@ class CaptionSet:
|
|
|
469
529
|
self.set_captions(lang, out_captions)
|
|
470
530
|
|
|
471
531
|
|
|
472
|
-
# Functions
|
|
473
532
|
def merge_concurrent_captions(caption_set):
|
|
474
533
|
"""Merge captions that have the same start and end times"""
|
|
475
534
|
for lang in caption_set.get_languages():
|
|
476
535
|
captions = caption_set.get_captions(lang)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
for caption in captions:
|
|
481
|
-
if last_caption:
|
|
482
|
-
last_timespan = last_caption.start, last_caption.end
|
|
483
|
-
current_timespan = caption.start, caption.end
|
|
484
|
-
if current_timespan == last_timespan:
|
|
485
|
-
concurrent_captions.append(caption)
|
|
486
|
-
last_caption = caption
|
|
487
|
-
continue
|
|
488
|
-
else:
|
|
489
|
-
merged_captions.append(merge(concurrent_captions))
|
|
490
|
-
concurrent_captions = [caption]
|
|
491
|
-
last_caption = caption
|
|
492
|
-
|
|
493
|
-
if concurrent_captions:
|
|
494
|
-
merged_captions.append(merge(concurrent_captions))
|
|
495
|
-
if merged_captions:
|
|
496
|
-
caption_set.set_captions(lang, merged_captions)
|
|
536
|
+
merged = merge_caption_list(captions)
|
|
537
|
+
if merged:
|
|
538
|
+
caption_set.set_captions(lang, merged)
|
|
497
539
|
return caption_set
|
|
498
540
|
|
|
499
541
|
|
|
500
|
-
def
|
|
501
|
-
"""
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
542
|
+
def merge_caption_list(captions):
|
|
543
|
+
"""Merge consecutive captions with identical start/end times into one."""
|
|
544
|
+
if not captions:
|
|
545
|
+
return CaptionList()
|
|
546
|
+
last_caption = None
|
|
547
|
+
concurrent_captions = CaptionList()
|
|
548
|
+
merged_captions = CaptionList()
|
|
549
|
+
for caption in captions:
|
|
550
|
+
if last_caption:
|
|
551
|
+
last_timespan = last_caption.start, last_caption.end
|
|
552
|
+
current_timespan = caption.start, caption.end
|
|
553
|
+
if current_timespan == last_timespan:
|
|
554
|
+
concurrent_captions.append(caption)
|
|
555
|
+
last_caption = caption
|
|
556
|
+
continue
|
|
557
|
+
else:
|
|
558
|
+
merged_captions.append(_merge_group(concurrent_captions))
|
|
559
|
+
concurrent_captions = [caption]
|
|
560
|
+
last_caption = caption
|
|
561
|
+
|
|
562
|
+
if concurrent_captions:
|
|
563
|
+
merged_captions.append(_merge_group(concurrent_captions))
|
|
564
|
+
return merged_captions
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _merge_group(captions):
|
|
568
|
+
"""Merge a group of captions into one, keeping the first caption's timing."""
|
|
505
569
|
new_nodes = []
|
|
506
570
|
for caption in captions:
|
|
507
571
|
if new_nodes:
|
|
508
572
|
new_nodes.append(CaptionNode.create_break())
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
caption = Caption(captions[0].start, captions[0].end, new_nodes, captions[0].style)
|
|
512
|
-
return caption
|
|
573
|
+
new_nodes.extend(caption.nodes)
|
|
574
|
+
return Caption(captions[0].start, captions[0].end, new_nodes, captions[0].style)
|
|
@@ -25,10 +25,22 @@ DFXP_DEFAULT_STYLE = {
|
|
|
25
25
|
"font-size": "1c",
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
# Reader default: DFXP spec mandates START/BOTTOM for round-trip fidelity.
|
|
28
29
|
DFXP_DEFAULT_REGION = Layout(
|
|
29
30
|
alignment=Alignment(HorizontalAlignmentEnum.START, VerticalAlignmentEnum.BOTTOM)
|
|
30
31
|
)
|
|
31
32
|
|
|
33
|
+
# Writer fallback alignment used when layout is None or SCC positional.
|
|
34
|
+
DFXP_WRITER_FALLBACK_ALIGNMENT = Alignment(
|
|
35
|
+
HorizontalAlignmentEnum.CENTER, VerticalAlignmentEnum.BOTTOM
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Writer default region for sources without positioning (VTT/SRT);
|
|
39
|
+
# uses CENTER per RP 2052-10 rather than the spec's START default.
|
|
40
|
+
DFXP_WRITER_DEFAULT_REGION = Layout(
|
|
41
|
+
alignment=Alignment(HorizontalAlignmentEnum.CENTER, VerticalAlignmentEnum.BOTTOM)
|
|
42
|
+
)
|
|
43
|
+
|
|
32
44
|
DFXP_DEFAULT_STYLE_ID = "default"
|
|
33
45
|
DFXP_DEFAULT_REGION_ID = "bottom"
|
|
34
46
|
|
|
@@ -46,18 +46,19 @@ class SinglePositioningDFXPWriter(DFXPWriter):
|
|
|
46
46
|
super().__init__(*args, **kwargs)
|
|
47
47
|
self.default_positioning = default_positioning
|
|
48
48
|
|
|
49
|
-
def write(self, captions_set,
|
|
49
|
+
def write(self, captions_set, **kwargs):
|
|
50
50
|
"""Writes a DFXP file using the positioning provided in the initializer
|
|
51
51
|
|
|
52
52
|
:type captions_set: pycaption.base.CaptionSet
|
|
53
|
-
:param
|
|
53
|
+
:param kwargs:
|
|
54
|
+
force (str): only write this language, if available in the CaptionSet
|
|
54
55
|
:rtype: str
|
|
55
56
|
"""
|
|
56
57
|
captions_set = self._create_single_positioning_caption_set(
|
|
57
58
|
captions_set, self.default_positioning
|
|
58
59
|
)
|
|
59
60
|
|
|
60
|
-
return super().write(captions_set,
|
|
61
|
+
return super().write(captions_set, **kwargs)
|
|
61
62
|
|
|
62
63
|
@staticmethod
|
|
63
64
|
def _create_single_positioning_caption_set(caption_set, positioning):
|
|
@@ -98,13 +99,15 @@ class LegacyDFXPWriter(BaseWriter):
|
|
|
98
99
|
def __init__(self, *args, **kw):
|
|
99
100
|
self.open_span = False
|
|
100
101
|
|
|
101
|
-
def write(self, caption_set,
|
|
102
|
+
def write(self, caption_set, **kwargs):
|
|
102
103
|
"""Serialize a CaptionSet into legacy DFXP format.
|
|
103
104
|
|
|
104
105
|
:type caption_set: CaptionSet
|
|
105
|
-
:param
|
|
106
|
+
:param kwargs:
|
|
107
|
+
force (str): if set, output only this language (falls back to last)
|
|
106
108
|
:rtype: str
|
|
107
109
|
"""
|
|
110
|
+
force = kwargs.get("force", "")
|
|
108
111
|
caption_set = deepcopy(caption_set)
|
|
109
112
|
caption_set = merge_concurrent_captions(caption_set)
|
|
110
113
|
|
|
@@ -21,10 +21,10 @@ from ..exceptions import (
|
|
|
21
21
|
CaptionReadNoCaptions,
|
|
22
22
|
CaptionReadSyntaxError,
|
|
23
23
|
CaptionReadTimingError,
|
|
24
|
-
InvalidInputError,
|
|
25
24
|
)
|
|
26
25
|
from ..geometry import (
|
|
27
26
|
Alignment,
|
|
27
|
+
HorizontalAlignmentEnum,
|
|
28
28
|
Layout,
|
|
29
29
|
Padding,
|
|
30
30
|
Point,
|
|
@@ -107,9 +107,10 @@ class DFXPReader(BaseReader):
|
|
|
107
107
|
def detect(self, content):
|
|
108
108
|
"""Return True if content looks like a DFXP/TTML document.
|
|
109
109
|
|
|
110
|
-
:type content: str
|
|
110
|
+
:type content: str or bytes
|
|
111
111
|
:rtype: bool
|
|
112
112
|
"""
|
|
113
|
+
content = self._decode_content(content)
|
|
113
114
|
lowered = content.lower()
|
|
114
115
|
return bool(re.search(r"<tt[\s>]", lowered)) and "</tt>" in lowered
|
|
115
116
|
|
|
@@ -121,52 +122,14 @@ class DFXPReader(BaseReader):
|
|
|
121
122
|
:raises InvalidInputError: if content is not a string
|
|
122
123
|
:raises CaptionReadNoCaptions: if no captions are found
|
|
123
124
|
"""
|
|
124
|
-
|
|
125
|
-
raise InvalidInputError("The content is not a unicode string.")
|
|
125
|
+
content = self._decode_content(content)
|
|
126
126
|
|
|
127
127
|
dfxp_document = LayoutAwareDFXPParser(
|
|
128
128
|
content, read_invalid_positioning=self.read_invalid_positioning
|
|
129
129
|
)
|
|
130
130
|
|
|
131
131
|
tt_attrs = dfxp_document.tt.attrs if dfxp_document.tt else {}
|
|
132
|
-
|
|
133
|
-
multiplier_str = tt_attrs.get(
|
|
134
|
-
"ttp:frameratemultiplier", DFXP_DEFAULT_FRAMERATE_MULTIPLIER
|
|
135
|
-
)
|
|
136
|
-
self.framerate = self._get_effective_framerate(framerate_str, multiplier_str)
|
|
137
|
-
|
|
138
|
-
if "ttp:tickrate" in tt_attrs:
|
|
139
|
-
try:
|
|
140
|
-
tickrate = float(tt_attrs["ttp:tickrate"])
|
|
141
|
-
except ValueError:
|
|
142
|
-
raise CaptionReadSyntaxError(
|
|
143
|
-
f"ttp:tickRate must be a number, "
|
|
144
|
-
f"got '{tt_attrs['ttp:tickrate']}'"
|
|
145
|
-
)
|
|
146
|
-
if tickrate <= 0:
|
|
147
|
-
raise CaptionReadSyntaxError(
|
|
148
|
-
f"ttp:tickRate must be positive, got '{tt_attrs['ttp:tickrate']}'"
|
|
149
|
-
)
|
|
150
|
-
self.tickrate = tickrate
|
|
151
|
-
else:
|
|
152
|
-
# TTML spec 8.2.12: default tickRate = frameRate × subFrameRate
|
|
153
|
-
try:
|
|
154
|
-
sub_framerate = int(
|
|
155
|
-
tt_attrs.get("ttp:subframerate", DFXP_DEFAULT_SUBFRAMERATE)
|
|
156
|
-
)
|
|
157
|
-
except ValueError:
|
|
158
|
-
raise CaptionReadSyntaxError(
|
|
159
|
-
f"ttp:subFrameRate must be a positive integer, "
|
|
160
|
-
f"got '{tt_attrs['ttp:subframerate']}'"
|
|
161
|
-
)
|
|
162
|
-
try:
|
|
163
|
-
framerate_int = int(framerate_str)
|
|
164
|
-
except ValueError:
|
|
165
|
-
raise CaptionReadSyntaxError(
|
|
166
|
-
f"ttp:frameRate must be a positive integer, "
|
|
167
|
-
f"got '{framerate_str}'"
|
|
168
|
-
)
|
|
169
|
-
self.tickrate = float(framerate_int * sub_framerate)
|
|
132
|
+
self._resolve_timing_parameters(tt_attrs)
|
|
170
133
|
|
|
171
134
|
caption_dict = {}
|
|
172
135
|
style_dict = {}
|
|
@@ -180,18 +143,71 @@ class DFXPReader(BaseReader):
|
|
|
180
143
|
for style in dfxp_document.find_all("style"):
|
|
181
144
|
id_ = style.attrs.get(DFXP_ATTR_XML_ID) or style.attrs.get("id")
|
|
182
145
|
if id_:
|
|
183
|
-
# Styles nested inside <region> tags are region-scoped and
|
|
184
|
-
# should not appear as document-level styles.
|
|
185
146
|
if "region" not in [parent_.name for parent_ in style.parents]:
|
|
186
147
|
style_dict[id_] = self._convert_style(style)
|
|
187
148
|
|
|
188
|
-
caption_set = CaptionSet(
|
|
149
|
+
caption_set = CaptionSet(
|
|
150
|
+
caption_dict, styles=style_dict,
|
|
151
|
+
visual_alignment_default=HorizontalAlignmentEnum.START,
|
|
152
|
+
)
|
|
189
153
|
|
|
190
154
|
if caption_set.is_empty():
|
|
191
155
|
raise CaptionReadNoCaptions("empty caption file")
|
|
192
156
|
|
|
193
157
|
return caption_set
|
|
194
158
|
|
|
159
|
+
def _resolve_timing_parameters(self, tt_attrs):
|
|
160
|
+
"""Extract framerate and tickrate from <tt> attributes.
|
|
161
|
+
|
|
162
|
+
Sets self.framerate and self.tickrate per TTML spec sections 8.2.8,
|
|
163
|
+
8.2.11, and 8.2.12.
|
|
164
|
+
"""
|
|
165
|
+
framerate_str = tt_attrs.get("ttp:framerate", str(DFXP_DEFAULT_FRAMERATE))
|
|
166
|
+
multiplier_str = tt_attrs.get(
|
|
167
|
+
"ttp:frameratemultiplier", DFXP_DEFAULT_FRAMERATE_MULTIPLIER
|
|
168
|
+
)
|
|
169
|
+
self.framerate = self._get_effective_framerate(framerate_str, multiplier_str)
|
|
170
|
+
|
|
171
|
+
if "ttp:tickrate" in tt_attrs:
|
|
172
|
+
self._resolve_explicit_tickrate(tt_attrs)
|
|
173
|
+
else:
|
|
174
|
+
self._resolve_default_tickrate(tt_attrs, framerate_str)
|
|
175
|
+
|
|
176
|
+
def _resolve_explicit_tickrate(self, tt_attrs):
|
|
177
|
+
"""Parse an explicit ttp:tickRate attribute."""
|
|
178
|
+
try:
|
|
179
|
+
tickrate = float(tt_attrs["ttp:tickrate"])
|
|
180
|
+
except ValueError:
|
|
181
|
+
raise CaptionReadSyntaxError(
|
|
182
|
+
f"ttp:tickRate must be a number, "
|
|
183
|
+
f"got '{tt_attrs['ttp:tickrate']}'"
|
|
184
|
+
)
|
|
185
|
+
if tickrate <= 0:
|
|
186
|
+
raise CaptionReadSyntaxError(
|
|
187
|
+
f"ttp:tickRate must be positive, got '{tt_attrs['ttp:tickrate']}'"
|
|
188
|
+
)
|
|
189
|
+
self.tickrate = tickrate
|
|
190
|
+
|
|
191
|
+
def _resolve_default_tickrate(self, tt_attrs, framerate_str):
|
|
192
|
+
"""Compute default tickRate = frameRate × subFrameRate (TTML 8.2.12)."""
|
|
193
|
+
try:
|
|
194
|
+
sub_framerate = int(
|
|
195
|
+
tt_attrs.get("ttp:subframerate", DFXP_DEFAULT_SUBFRAMERATE)
|
|
196
|
+
)
|
|
197
|
+
except ValueError:
|
|
198
|
+
raise CaptionReadSyntaxError(
|
|
199
|
+
f"ttp:subFrameRate must be a positive integer, "
|
|
200
|
+
f"got '{tt_attrs['ttp:subframerate']}'"
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
framerate_int = int(framerate_str)
|
|
204
|
+
except ValueError:
|
|
205
|
+
raise CaptionReadSyntaxError(
|
|
206
|
+
f"ttp:frameRate must be a positive integer, "
|
|
207
|
+
f"got '{framerate_str}'"
|
|
208
|
+
)
|
|
209
|
+
self.tickrate = float(framerate_int * sub_framerate)
|
|
210
|
+
|
|
195
211
|
def _convert_div_to_caption_list(self, div):
|
|
196
212
|
"""Convert a <div> element into a CaptionList for one language.
|
|
197
213
|
|