pycaption 2.3.0__tar.gz → 2.3.2__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.0/pycaption.egg-info → pycaption-2.3.2}/PKG-INFO +1 -1
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/base.py +107 -72
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/constants.py +12 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/extras.py +8 -5
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/reader.py +58 -41
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/writer.py +70 -20
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/geometry.py +127 -201
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/microdvd.py +37 -23
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/reader.py +13 -8
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/writer.py +98 -23
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/reader.py +83 -123
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/specialized_collections.py +143 -194
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/state_machines.py +4 -5
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/writer.py +91 -82
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/srt.py +17 -28
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/transcript.py +6 -4
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/webvtt/reader.py +34 -33
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/webvtt/writer.py +32 -40
- {pycaption-2.3.0 → pycaption-2.3.2/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.0 → pycaption-2.3.2}/setup.py +1 -1
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/webvtt.py +4 -4
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/mixins.py +11 -5
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_dfxp_conversion.py +7 -1
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_sami.py +1 -1
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_sami_conversion.py +22 -3
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_scc.py +8 -4
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_scc_conversion.py +22 -11
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_webvtt_conversion.py +10 -5
- {pycaption-2.3.0 → pycaption-2.3.2}/LICENSE +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/MANIFEST.in +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/README.rst +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/__init__.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/parser.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/utils.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/webvtt/constants.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/setup.cfg +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/__init__.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/conftest.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/scc.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_base.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_dfxp.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_functions.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_geometry.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_srt.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.3.0 → pycaption-2.3.2}/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:
|
|
@@ -107,6 +111,28 @@ class BaseWriter:
|
|
|
107
111
|
self.video_height = video_height
|
|
108
112
|
self.fit_to_screen = fit_to_screen
|
|
109
113
|
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _get_visual_alignment_default(caption_set):
|
|
116
|
+
"""Return the source format's visual alignment default from a CaptionSet.
|
|
117
|
+
|
|
118
|
+
Per SMPTE RP 2052-10, caption formats have different implicit visual
|
|
119
|
+
defaults when no alignment is specified:
|
|
120
|
+
- CENTER: WebVTT, SRT, SCC, MicroDVD
|
|
121
|
+
- LEFT/START: DFXP/TTML, SAMI
|
|
122
|
+
|
|
123
|
+
When converting between formats with mismatched defaults, the source
|
|
124
|
+
default must be made explicit in the output to prevent visual
|
|
125
|
+
regression (e.g. centered text silently becoming left-aligned).
|
|
126
|
+
|
|
127
|
+
Readers declare their default via CaptionSet.visual_alignment_default.
|
|
128
|
+
Writers compare it to their own format's default to decide whether
|
|
129
|
+
to emit explicit alignment.
|
|
130
|
+
|
|
131
|
+
:type caption_set: CaptionSet
|
|
132
|
+
:rtype: HorizontalAlignmentEnum | None
|
|
133
|
+
"""
|
|
134
|
+
return caption_set.visual_alignment_default if caption_set else None
|
|
135
|
+
|
|
110
136
|
def _relativize_and_fit_to_screen(self, layout_info):
|
|
111
137
|
"""Apply relativization and fit-to-screen adjustments to a Layout.
|
|
112
138
|
|
|
@@ -124,13 +150,14 @@ class BaseWriter:
|
|
|
124
150
|
layout_info = layout_info.fit_to_screen()
|
|
125
151
|
return layout_info
|
|
126
152
|
|
|
127
|
-
def write(self,
|
|
153
|
+
def write(self, caption_set, **kwargs):
|
|
128
154
|
"""Serialize a CaptionSet. Subclasses override this.
|
|
129
155
|
|
|
130
|
-
:type
|
|
156
|
+
:type caption_set: CaptionSet
|
|
157
|
+
:param kwargs: Format-specific options (e.g. force, lang).
|
|
131
158
|
:rtype: str
|
|
132
159
|
"""
|
|
133
|
-
return
|
|
160
|
+
return caption_set
|
|
134
161
|
|
|
135
162
|
|
|
136
163
|
class CaptionNode:
|
|
@@ -146,8 +173,6 @@ class CaptionNode:
|
|
|
146
173
|
"""
|
|
147
174
|
|
|
148
175
|
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
176
|
STYLE = 2
|
|
152
177
|
BREAK = 3
|
|
153
178
|
|
|
@@ -216,9 +241,9 @@ class Caption:
|
|
|
216
241
|
"""
|
|
217
242
|
Initialize the Caption object
|
|
218
243
|
:param start: The start time in microseconds
|
|
219
|
-
:type start:
|
|
244
|
+
:type start: int
|
|
220
245
|
:param end: The end time in microseconds
|
|
221
|
-
:type end:
|
|
246
|
+
:type end: int
|
|
222
247
|
:param nodes: A list of CaptionNodes
|
|
223
248
|
:type nodes: list
|
|
224
249
|
:param style: A dictionary with CSS-like styling rules
|
|
@@ -242,10 +267,8 @@ class Caption:
|
|
|
242
267
|
self.nodes = nodes
|
|
243
268
|
self.style = style or {}
|
|
244
269
|
self.layout_info = layout_info
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"""Return True if this caption has no nodes."""
|
|
248
|
-
return not self.nodes
|
|
270
|
+
self.caption_mode = None
|
|
271
|
+
self.roll_up_rows = None
|
|
249
272
|
|
|
250
273
|
def format_start(self, msec_separator=None):
|
|
251
274
|
"""Format start time as HH:MM:SS.mmm string.
|
|
@@ -273,33 +296,30 @@ class Caption:
|
|
|
273
296
|
|
|
274
297
|
:rtype: list[str]
|
|
275
298
|
"""
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
result.append("\n")
|
|
282
|
-
return result
|
|
299
|
+
return [
|
|
300
|
+
node.content if node.type_ == CaptionNode.TEXT else "\n"
|
|
301
|
+
for node in self.nodes
|
|
302
|
+
if node.type_ in (CaptionNode.TEXT, CaptionNode.BREAK)
|
|
303
|
+
]
|
|
283
304
|
|
|
284
305
|
def get_text(self):
|
|
285
306
|
"""Return the plain text content of this caption (no markup).
|
|
286
307
|
|
|
287
308
|
:rtype: str
|
|
288
309
|
"""
|
|
289
|
-
|
|
290
|
-
return "".join(text_nodes).strip()
|
|
310
|
+
return "".join(self.get_text_nodes()).strip()
|
|
291
311
|
|
|
292
|
-
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _format_timestamp(microseconds, msec_separator=None):
|
|
293
314
|
"""Convert microseconds to HH:MM:SS{sep}mmm string."""
|
|
294
315
|
duration = timedelta(microseconds=microseconds)
|
|
295
316
|
hours, rem = divmod(duration.seconds, 3600)
|
|
296
317
|
minutes, seconds = divmod(rem, 60)
|
|
297
|
-
milliseconds =
|
|
298
|
-
|
|
318
|
+
milliseconds = duration.microseconds // 1000
|
|
319
|
+
return (
|
|
299
320
|
f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
300
|
-
f"{msec_separator or '.'}{milliseconds
|
|
321
|
+
f"{msec_separator or '.'}{milliseconds:03d}"
|
|
301
322
|
)
|
|
302
|
-
return timestamp
|
|
303
323
|
|
|
304
324
|
|
|
305
325
|
class CaptionList(list):
|
|
@@ -320,14 +340,14 @@ class CaptionList(list):
|
|
|
320
340
|
return item
|
|
321
341
|
return CaptionList(item, layout_info=self.layout_info)
|
|
322
342
|
|
|
323
|
-
def __add__(self,
|
|
343
|
+
def __add__(self, value):
|
|
324
344
|
add_is_safe = (
|
|
325
|
-
not hasattr(
|
|
326
|
-
or not
|
|
327
|
-
or self.layout_info ==
|
|
345
|
+
not hasattr(value, "layout_info")
|
|
346
|
+
or not value.layout_info
|
|
347
|
+
or self.layout_info == value.layout_info
|
|
328
348
|
)
|
|
329
349
|
if add_is_safe:
|
|
330
|
-
return CaptionList(list.__add__(self,
|
|
350
|
+
return CaptionList(list.__add__(self, value), layout_info=self.layout_info)
|
|
331
351
|
else:
|
|
332
352
|
raise ValueError(
|
|
333
353
|
"Cannot add CaptionList objects with different layout_info"
|
|
@@ -348,17 +368,27 @@ class CaptionSet:
|
|
|
348
368
|
by all the children.
|
|
349
369
|
"""
|
|
350
370
|
|
|
351
|
-
def __init__(
|
|
371
|
+
def __init__(
|
|
372
|
+
self, captions, styles=None, layout_info=None, regions=None,
|
|
373
|
+
visual_alignment_default=None,
|
|
374
|
+
):
|
|
352
375
|
"""
|
|
353
376
|
:param captions: A dictionary of the format {'language': CaptionList}
|
|
354
377
|
:param styles: A dictionary with CSS-like styling rules
|
|
355
378
|
:param Layout layout_info: A Layout object with the positioning info
|
|
356
379
|
:param regions: A dictionary mapping region id to raw settings dict
|
|
380
|
+
:param visual_alignment_default: The source format's implicit text
|
|
381
|
+
alignment when no explicit alignment is specified. Per SMPTE
|
|
382
|
+
RP 2052-10, writers targeting a format with a different visual
|
|
383
|
+
default must emit this alignment explicitly to prevent visual
|
|
384
|
+
regression. Use HorizontalAlignmentEnum values.
|
|
385
|
+
:type visual_alignment_default: HorizontalAlignmentEnum | None
|
|
357
386
|
"""
|
|
358
387
|
self._captions = captions
|
|
359
388
|
self._styles = styles or {}
|
|
360
389
|
self._regions = regions or {}
|
|
361
390
|
self.layout_info = layout_info
|
|
391
|
+
self.visual_alignment_default = visual_alignment_default
|
|
362
392
|
|
|
363
393
|
def set_captions(self, lang, captions):
|
|
364
394
|
"""Replace the caption list for a given language.
|
|
@@ -429,7 +459,10 @@ class CaptionSet:
|
|
|
429
459
|
|
|
430
460
|
def is_empty(self):
|
|
431
461
|
"""Return True if no language contains any captions."""
|
|
432
|
-
|
|
462
|
+
for captions in self._captions.values():
|
|
463
|
+
if len(captions) > 0:
|
|
464
|
+
return False
|
|
465
|
+
return True
|
|
433
466
|
|
|
434
467
|
def set_layout_info(self, lang, layout_info):
|
|
435
468
|
"""Set the layout_info on the CaptionList for a given language.
|
|
@@ -469,44 +502,46 @@ class CaptionSet:
|
|
|
469
502
|
self.set_captions(lang, out_captions)
|
|
470
503
|
|
|
471
504
|
|
|
472
|
-
# Functions
|
|
473
505
|
def merge_concurrent_captions(caption_set):
|
|
474
506
|
"""Merge captions that have the same start and end times"""
|
|
475
507
|
for lang in caption_set.get_languages():
|
|
476
508
|
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)
|
|
509
|
+
merged = merge_caption_list(captions)
|
|
510
|
+
if merged:
|
|
511
|
+
caption_set.set_captions(lang, merged)
|
|
497
512
|
return caption_set
|
|
498
513
|
|
|
499
514
|
|
|
500
|
-
def
|
|
501
|
-
"""
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
515
|
+
def merge_caption_list(captions):
|
|
516
|
+
"""Merge consecutive captions with identical start/end times into one."""
|
|
517
|
+
if not captions:
|
|
518
|
+
return CaptionList()
|
|
519
|
+
last_caption = None
|
|
520
|
+
concurrent_captions = CaptionList()
|
|
521
|
+
merged_captions = CaptionList()
|
|
522
|
+
for caption in captions:
|
|
523
|
+
if last_caption:
|
|
524
|
+
last_timespan = last_caption.start, last_caption.end
|
|
525
|
+
current_timespan = caption.start, caption.end
|
|
526
|
+
if current_timespan == last_timespan:
|
|
527
|
+
concurrent_captions.append(caption)
|
|
528
|
+
last_caption = caption
|
|
529
|
+
continue
|
|
530
|
+
else:
|
|
531
|
+
merged_captions.append(_merge_group(concurrent_captions))
|
|
532
|
+
concurrent_captions = [caption]
|
|
533
|
+
last_caption = caption
|
|
534
|
+
|
|
535
|
+
if concurrent_captions:
|
|
536
|
+
merged_captions.append(_merge_group(concurrent_captions))
|
|
537
|
+
return merged_captions
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _merge_group(captions):
|
|
541
|
+
"""Merge a group of captions into one, keeping the first caption's timing."""
|
|
505
542
|
new_nodes = []
|
|
506
543
|
for caption in captions:
|
|
507
544
|
if new_nodes:
|
|
508
545
|
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
|
|
546
|
+
new_nodes.extend(caption.nodes)
|
|
547
|
+
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
|
|
|
@@ -25,6 +25,7 @@ from ..exceptions import (
|
|
|
25
25
|
)
|
|
26
26
|
from ..geometry import (
|
|
27
27
|
Alignment,
|
|
28
|
+
HorizontalAlignmentEnum,
|
|
28
29
|
Layout,
|
|
29
30
|
Padding,
|
|
30
31
|
Point,
|
|
@@ -129,44 +130,7 @@ class DFXPReader(BaseReader):
|
|
|
129
130
|
)
|
|
130
131
|
|
|
131
132
|
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)
|
|
133
|
+
self._resolve_timing_parameters(tt_attrs)
|
|
170
134
|
|
|
171
135
|
caption_dict = {}
|
|
172
136
|
style_dict = {}
|
|
@@ -180,18 +144,71 @@ class DFXPReader(BaseReader):
|
|
|
180
144
|
for style in dfxp_document.find_all("style"):
|
|
181
145
|
id_ = style.attrs.get(DFXP_ATTR_XML_ID) or style.attrs.get("id")
|
|
182
146
|
if id_:
|
|
183
|
-
# Styles nested inside <region> tags are region-scoped and
|
|
184
|
-
# should not appear as document-level styles.
|
|
185
147
|
if "region" not in [parent_.name for parent_ in style.parents]:
|
|
186
148
|
style_dict[id_] = self._convert_style(style)
|
|
187
149
|
|
|
188
|
-
caption_set = CaptionSet(
|
|
150
|
+
caption_set = CaptionSet(
|
|
151
|
+
caption_dict, styles=style_dict,
|
|
152
|
+
visual_alignment_default=HorizontalAlignmentEnum.START,
|
|
153
|
+
)
|
|
189
154
|
|
|
190
155
|
if caption_set.is_empty():
|
|
191
156
|
raise CaptionReadNoCaptions("empty caption file")
|
|
192
157
|
|
|
193
158
|
return caption_set
|
|
194
159
|
|
|
160
|
+
def _resolve_timing_parameters(self, tt_attrs):
|
|
161
|
+
"""Extract framerate and tickrate from <tt> attributes.
|
|
162
|
+
|
|
163
|
+
Sets self.framerate and self.tickrate per TTML spec sections 8.2.8,
|
|
164
|
+
8.2.11, and 8.2.12.
|
|
165
|
+
"""
|
|
166
|
+
framerate_str = tt_attrs.get("ttp:framerate", str(DFXP_DEFAULT_FRAMERATE))
|
|
167
|
+
multiplier_str = tt_attrs.get(
|
|
168
|
+
"ttp:frameratemultiplier", DFXP_DEFAULT_FRAMERATE_MULTIPLIER
|
|
169
|
+
)
|
|
170
|
+
self.framerate = self._get_effective_framerate(framerate_str, multiplier_str)
|
|
171
|
+
|
|
172
|
+
if "ttp:tickrate" in tt_attrs:
|
|
173
|
+
self._resolve_explicit_tickrate(tt_attrs)
|
|
174
|
+
else:
|
|
175
|
+
self._resolve_default_tickrate(tt_attrs, framerate_str)
|
|
176
|
+
|
|
177
|
+
def _resolve_explicit_tickrate(self, tt_attrs):
|
|
178
|
+
"""Parse an explicit ttp:tickRate attribute."""
|
|
179
|
+
try:
|
|
180
|
+
tickrate = float(tt_attrs["ttp:tickrate"])
|
|
181
|
+
except ValueError:
|
|
182
|
+
raise CaptionReadSyntaxError(
|
|
183
|
+
f"ttp:tickRate must be a number, "
|
|
184
|
+
f"got '{tt_attrs['ttp:tickrate']}'"
|
|
185
|
+
)
|
|
186
|
+
if tickrate <= 0:
|
|
187
|
+
raise CaptionReadSyntaxError(
|
|
188
|
+
f"ttp:tickRate must be positive, got '{tt_attrs['ttp:tickrate']}'"
|
|
189
|
+
)
|
|
190
|
+
self.tickrate = tickrate
|
|
191
|
+
|
|
192
|
+
def _resolve_default_tickrate(self, tt_attrs, framerate_str):
|
|
193
|
+
"""Compute default tickRate = frameRate × subFrameRate (TTML 8.2.12)."""
|
|
194
|
+
try:
|
|
195
|
+
sub_framerate = int(
|
|
196
|
+
tt_attrs.get("ttp:subframerate", DFXP_DEFAULT_SUBFRAMERATE)
|
|
197
|
+
)
|
|
198
|
+
except ValueError:
|
|
199
|
+
raise CaptionReadSyntaxError(
|
|
200
|
+
f"ttp:subFrameRate must be a positive integer, "
|
|
201
|
+
f"got '{tt_attrs['ttp:subframerate']}'"
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
framerate_int = int(framerate_str)
|
|
205
|
+
except ValueError:
|
|
206
|
+
raise CaptionReadSyntaxError(
|
|
207
|
+
f"ttp:frameRate must be a positive integer, "
|
|
208
|
+
f"got '{framerate_str}'"
|
|
209
|
+
)
|
|
210
|
+
self.tickrate = float(framerate_int * sub_framerate)
|
|
211
|
+
|
|
195
212
|
def _convert_div_to_caption_list(self, div):
|
|
196
213
|
"""Convert a <div> element into a CaptionList for one language.
|
|
197
214
|
|