pycaption 2.3.7__tar.gz → 2.3.8__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.7/pycaption.egg-info → pycaption-2.3.8}/PKG-INFO +1 -1
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/writer.py +53 -3
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/geometry.py +22 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/reader.py +1 -1
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/webvtt/constants.py +11 -2
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/webvtt/reader.py +61 -8
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/webvtt/writer.py +12 -1
- {pycaption-2.3.7 → pycaption-2.3.8/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.7 → pycaption-2.3.8}/setup.py +1 -1
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_dfxp.py +3 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_sami.py +16 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_scc.py +4 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_webvtt.py +299 -2
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_webvtt_conversion.py +133 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/LICENSE +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/MANIFEST.in +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/README.rst +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/base.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/constants.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/reader.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/microdvd.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/parser.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/writer.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/reader.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/specialized_collections.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/writer.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/srt.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/transcript.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/utils.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/setup.cfg +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/conftest.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/scc.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/mixins.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_base.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_bytes_input.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_double_encoding.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_functions.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_geometry.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_srt.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_srt_conversion.py +0 -0
|
@@ -4,13 +4,14 @@ Serializes pycaption CaptionSet objects into DFXP/TTML XML documents,
|
|
|
4
4
|
including style elements, region-based positioning, and writing direction.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import re
|
|
7
8
|
from copy import deepcopy
|
|
8
9
|
from xml.sax.saxutils import escape
|
|
9
10
|
|
|
10
11
|
from bs4 import BeautifulSoup
|
|
11
12
|
|
|
12
13
|
from ..base import BaseWriter, CaptionNode
|
|
13
|
-
from ..geometry import HorizontalAlignmentEnum, WritingDirectionEnum
|
|
14
|
+
from ..geometry import HorizontalAlignmentEnum, LineAlignmentEnum, WritingDirectionEnum
|
|
14
15
|
from .constants import (
|
|
15
16
|
DFXP_ATTR_XML_ID,
|
|
16
17
|
DFXP_ATTR_XML_LANG,
|
|
@@ -30,6 +31,12 @@ _WRITING_DIRECTION_TO_DFXP = {
|
|
|
30
31
|
WritingDirectionEnum.VERTICAL_LR: "tblr",
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
_LINE_ALIGNMENT_TO_DISPLAY_ALIGN = {
|
|
35
|
+
LineAlignmentEnum.START: "before",
|
|
36
|
+
LineAlignmentEnum.CENTER: "center",
|
|
37
|
+
LineAlignmentEnum.END: "after",
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
|
|
34
41
|
class DFXPWriter(BaseWriter):
|
|
35
42
|
"""Converts a CaptionSet to DFXP/TTML format.
|
|
@@ -454,12 +461,42 @@ class RegionCreator:
|
|
|
454
461
|
region.extract()
|
|
455
462
|
|
|
456
463
|
|
|
464
|
+
_CSS_LENGTH_RE = re.compile(r"^-?[\d.]+(?:px|em|%|pt|rem|c)?$")
|
|
465
|
+
_CSS_COLOR_RE = re.compile(r"^(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-zA-Z]{3,})$")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _text_shadow_to_outline(value):
|
|
469
|
+
"""Best-effort conversion of CSS text-shadow to tts:textOutline.
|
|
470
|
+
|
|
471
|
+
Extracts the color and a representative thickness from the first shadow
|
|
472
|
+
in the list. Returns "color thickness" or None if unparseable.
|
|
473
|
+
"""
|
|
474
|
+
first_shadow = value.split(",")[0].strip()
|
|
475
|
+
tokens = first_shadow.split()
|
|
476
|
+
color = None
|
|
477
|
+
lengths = []
|
|
478
|
+
for token in tokens:
|
|
479
|
+
if _CSS_LENGTH_RE.match(token):
|
|
480
|
+
lengths.append(token)
|
|
481
|
+
elif _CSS_COLOR_RE.match(token):
|
|
482
|
+
color = token
|
|
483
|
+
if not lengths:
|
|
484
|
+
return None
|
|
485
|
+
if len(lengths) >= 3:
|
|
486
|
+
thickness = lengths[2]
|
|
487
|
+
else:
|
|
488
|
+
thickness = lengths[0]
|
|
489
|
+
if color:
|
|
490
|
+
return f"{color} {thickness}"
|
|
491
|
+
return thickness
|
|
492
|
+
|
|
493
|
+
|
|
457
494
|
def _recreate_style(content, dfxp):
|
|
458
495
|
"""Convert an internal style dict to DFXP/TTS style attributes.
|
|
459
496
|
|
|
460
497
|
Maps pycaption's internal keys (class, italics, bold, underline, color,
|
|
461
|
-
background-color, font-family, font-size, text-
|
|
462
|
-
to their tts: namespace equivalents.
|
|
498
|
+
background-color, font-family, font-size, text-shadow, text-align,
|
|
499
|
+
display-align) to their tts: namespace equivalents.
|
|
463
500
|
|
|
464
501
|
:param content: internal style dictionary
|
|
465
502
|
:type content: dict
|
|
@@ -487,10 +524,18 @@ def _recreate_style(content, dfxp):
|
|
|
487
524
|
dfxp_style["tts:fontFamily"] = content["font-family"]
|
|
488
525
|
if "font-size" in content:
|
|
489
526
|
dfxp_style["tts:fontSize"] = content["font-size"]
|
|
527
|
+
if "text-shadow" in content:
|
|
528
|
+
outline = _text_shadow_to_outline(content["text-shadow"])
|
|
529
|
+
if outline:
|
|
530
|
+
dfxp_style["tts:textOutline"] = outline
|
|
490
531
|
if "color" in content:
|
|
491
532
|
dfxp_style["tts:color"] = content["color"]
|
|
492
533
|
if "background-color" in content:
|
|
493
534
|
dfxp_style["tts:backgroundColor"] = content["background-color"]
|
|
535
|
+
if "opacity" in content:
|
|
536
|
+
dfxp_style["tts:opacity"] = content["opacity"]
|
|
537
|
+
if "line-height" in content:
|
|
538
|
+
dfxp_style["tts:lineHeight"] = content["line-height"]
|
|
494
539
|
if "display-align" in content:
|
|
495
540
|
dfxp_style["tts:displayAlign"] = content["display-align"]
|
|
496
541
|
|
|
@@ -536,4 +581,9 @@ def _convert_layout_to_attributes(layout, fallback_alignment=None):
|
|
|
536
581
|
if writing_mode:
|
|
537
582
|
result["tts:writingMode"] = writing_mode
|
|
538
583
|
|
|
584
|
+
if layout.line_alignment:
|
|
585
|
+
display_align = _LINE_ALIGNMENT_TO_DISPLAY_ALIGN.get(layout.line_alignment)
|
|
586
|
+
if display_align:
|
|
587
|
+
result["tts:displayAlign"] = display_align
|
|
588
|
+
|
|
539
589
|
return result
|
|
@@ -68,6 +68,15 @@ class PositionAlignmentEnum(Enum):
|
|
|
68
68
|
LINE_RIGHT = "line-right"
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
class LineAlignmentEnum(Enum):
|
|
72
|
+
"""WebVTT line alignment: how the cue box is positioned relative to
|
|
73
|
+
the line setting value (start, center, or end of the cue box)."""
|
|
74
|
+
|
|
75
|
+
START = "start"
|
|
76
|
+
CENTER = "center"
|
|
77
|
+
END = "end"
|
|
78
|
+
|
|
79
|
+
|
|
71
80
|
class WritingDirectionEnum(Enum):
|
|
72
81
|
"""Specifies WebVTT writing direction (vertical cue setting)."""
|
|
73
82
|
|
|
@@ -609,6 +618,7 @@ class Layout:
|
|
|
609
618
|
webvtt_positioning=None,
|
|
610
619
|
writing_direction=None,
|
|
611
620
|
position_alignment=None,
|
|
621
|
+
line_alignment=None,
|
|
612
622
|
inherit_from=None,
|
|
613
623
|
):
|
|
614
624
|
"""
|
|
@@ -638,6 +648,10 @@ class Layout:
|
|
|
638
648
|
:param position_alignment: Which edge of the cue box the position
|
|
639
649
|
percentage anchors to (line-left, center, or line-right).
|
|
640
650
|
|
|
651
|
+
:type line_alignment: LineAlignmentEnum
|
|
652
|
+
:param line_alignment: How the cue box is vertically aligned relative
|
|
653
|
+
to the line position (start, center, or end).
|
|
654
|
+
|
|
641
655
|
:type inherit_from: Layout
|
|
642
656
|
:param inherit_from: A Layout with the positioning parameters to be
|
|
643
657
|
used if not specified by the positioning arguments,
|
|
@@ -650,6 +664,7 @@ class Layout:
|
|
|
650
664
|
self.webvtt_positioning = webvtt_positioning
|
|
651
665
|
self.writing_direction = writing_direction
|
|
652
666
|
self.position_alignment = position_alignment
|
|
667
|
+
self.line_alignment = line_alignment
|
|
653
668
|
|
|
654
669
|
if inherit_from:
|
|
655
670
|
for attr_name in [
|
|
@@ -659,6 +674,7 @@ class Layout:
|
|
|
659
674
|
"alignment",
|
|
660
675
|
"writing_direction",
|
|
661
676
|
"position_alignment",
|
|
677
|
+
"line_alignment",
|
|
662
678
|
]:
|
|
663
679
|
attr = getattr(self, attr_name)
|
|
664
680
|
if not attr:
|
|
@@ -674,6 +690,7 @@ class Layout:
|
|
|
674
690
|
self.webvtt_positioning,
|
|
675
691
|
self.writing_direction,
|
|
676
692
|
self.position_alignment,
|
|
693
|
+
self.line_alignment,
|
|
677
694
|
)
|
|
678
695
|
)
|
|
679
696
|
|
|
@@ -692,6 +709,7 @@ class Layout:
|
|
|
692
709
|
None if not self.alignment else self.alignment.serialized(),
|
|
693
710
|
self.writing_direction,
|
|
694
711
|
self.position_alignment,
|
|
712
|
+
self.line_alignment,
|
|
695
713
|
)
|
|
696
714
|
|
|
697
715
|
def __eq__(self, other: object) -> bool:
|
|
@@ -704,6 +722,7 @@ class Layout:
|
|
|
704
722
|
and self.alignment == other.alignment
|
|
705
723
|
and self.writing_direction == other.writing_direction
|
|
706
724
|
and self.position_alignment == other.position_alignment
|
|
725
|
+
and self.line_alignment == other.line_alignment
|
|
707
726
|
)
|
|
708
727
|
|
|
709
728
|
def __hash__(self):
|
|
@@ -714,6 +733,7 @@ class Layout:
|
|
|
714
733
|
+ hash(self.alignment) * 5
|
|
715
734
|
+ hash(self.writing_direction) * 19
|
|
716
735
|
+ hash(self.position_alignment) * 23
|
|
736
|
+
+ hash(self.line_alignment) * 29
|
|
717
737
|
+ 17
|
|
718
738
|
)
|
|
719
739
|
|
|
@@ -735,6 +755,7 @@ class Layout:
|
|
|
735
755
|
"alignment": self.alignment,
|
|
736
756
|
"writing_direction": self.writing_direction,
|
|
737
757
|
"position_alignment": self.position_alignment,
|
|
758
|
+
"line_alignment": self.line_alignment,
|
|
738
759
|
}
|
|
739
760
|
for attr_name in ["origin", "extent", "padding"]:
|
|
740
761
|
attr = getattr(self, attr_name)
|
|
@@ -786,6 +807,7 @@ class Layout:
|
|
|
786
807
|
padding=self.padding,
|
|
787
808
|
alignment=self.alignment,
|
|
788
809
|
writing_direction=self.writing_direction,
|
|
810
|
+
line_alignment=self.line_alignment,
|
|
789
811
|
)
|
|
790
812
|
|
|
791
813
|
def _resolve_position_alignment(self):
|
|
@@ -263,7 +263,7 @@ class SAMIReader(BaseReader):
|
|
|
263
263
|
for style in styles:
|
|
264
264
|
style = style.split(":")
|
|
265
265
|
if len(style) == 2:
|
|
266
|
-
css_property, value = style
|
|
266
|
+
css_property, value = style[0].strip(), style[1]
|
|
267
267
|
else:
|
|
268
268
|
continue
|
|
269
269
|
if css_property == "text-align":
|
|
@@ -6,7 +6,7 @@ docstrings below each pattern explain what they capture and give examples.
|
|
|
6
6
|
|
|
7
7
|
import re
|
|
8
8
|
|
|
9
|
-
from ..geometry import HorizontalAlignmentEnum, PositionAlignmentEnum
|
|
9
|
+
from ..geometry import HorizontalAlignmentEnum, LineAlignmentEnum, PositionAlignmentEnum
|
|
10
10
|
|
|
11
11
|
TIMING_LINE_PATTERN = re.compile(r"^(\S+)\s+-->\s+(\S+)(?:\s+(.*?))?\s*$")
|
|
12
12
|
"""
|
|
@@ -60,7 +60,9 @@ Matches individual cue settings from the timing line, including optional
|
|
|
60
60
|
comma-separated alignment qualifiers per W3C WebVTT spec:
|
|
61
61
|
position:50% position:50%,line-left line:80%,center align:center
|
|
62
62
|
"""
|
|
63
|
-
STYLE_SELECTOR_PATTERN = re.compile(
|
|
63
|
+
STYLE_SELECTOR_PATTERN = re.compile(
|
|
64
|
+
r"::cue(?:\((\.[\w-]+(?:\.[\w-]+)*|[\w-]+)\))?\s*\{([^}]*)\}"
|
|
65
|
+
)
|
|
64
66
|
"""
|
|
65
67
|
Matches ::cue selectors with their declaration blocks:
|
|
66
68
|
::cue { color: white } -> group(1)=None
|
|
@@ -111,6 +113,13 @@ POSITION_ALIGN_MAP = {
|
|
|
111
113
|
}
|
|
112
114
|
"""Maps WebVTT positionAlign qualifier strings to PositionAlignmentEnum."""
|
|
113
115
|
|
|
116
|
+
LINE_ALIGN_MAP = {
|
|
117
|
+
"start": LineAlignmentEnum.START,
|
|
118
|
+
"center": LineAlignmentEnum.CENTER,
|
|
119
|
+
"end": LineAlignmentEnum.END,
|
|
120
|
+
}
|
|
121
|
+
"""Maps WebVTT lineAlign qualifier strings to LineAlignmentEnum."""
|
|
122
|
+
|
|
114
123
|
|
|
115
124
|
def _is_note_start(line):
|
|
116
125
|
"""Return True if the line begins a WebVTT NOTE block."""
|
|
@@ -30,6 +30,7 @@ from .constants import (
|
|
|
30
30
|
ALIGN_SETTING_MAP,
|
|
31
31
|
CUE_SETTING_PATTERN,
|
|
32
32
|
KNOWN_TAGS,
|
|
33
|
+
LINE_ALIGN_MAP,
|
|
33
34
|
LINE_GRID_SIZE,
|
|
34
35
|
LINE_HEIGHT_VH,
|
|
35
36
|
POSITION_ALIGN_MAP,
|
|
@@ -354,10 +355,35 @@ class WebVTTReader(BaseReader):
|
|
|
354
355
|
:returns: Caption instance.
|
|
355
356
|
"""
|
|
356
357
|
self._close_unclosed_tags(nodes, open_tags)
|
|
358
|
+
self._adjust_default_line_position(nodes, layout_info)
|
|
357
359
|
caption = Caption(start, end, nodes, layout_info=layout_info)
|
|
358
360
|
self._check_line_overflow(caption)
|
|
359
361
|
return caption
|
|
360
362
|
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _adjust_default_line_position(nodes, layout_info):
|
|
365
|
+
"""Adjust the default origin.y for multi-line cues.
|
|
366
|
+
|
|
367
|
+
When position: is set but line: is absent, origin.y defaults to
|
|
368
|
+
the last grid row. For multi-line cues this would extend beyond
|
|
369
|
+
the viewport. Push the top edge up by one row per extra line so
|
|
370
|
+
the bottom line stays at the viewport bottom.
|
|
371
|
+
"""
|
|
372
|
+
if not layout_info or not layout_info.origin:
|
|
373
|
+
return
|
|
374
|
+
if not layout_info.webvtt_positioning:
|
|
375
|
+
return
|
|
376
|
+
if "line:" in layout_info.webvtt_positioning:
|
|
377
|
+
return
|
|
378
|
+
num_lines = sum(1 for n in nodes if n.type_ == CaptionNode.BREAK) + 1
|
|
379
|
+
if num_lines <= 1:
|
|
380
|
+
return
|
|
381
|
+
adjusted_y = (LINE_GRID_SIZE - num_lines) / LINE_GRID_SIZE * 100
|
|
382
|
+
layout_info.origin = Point(
|
|
383
|
+
layout_info.origin.x,
|
|
384
|
+
Size(max(0, adjusted_y), UnitEnum.PERCENT),
|
|
385
|
+
)
|
|
386
|
+
|
|
361
387
|
@staticmethod
|
|
362
388
|
def _check_line_overflow(caption):
|
|
363
389
|
"""Emit a CaptionReadWarning if a multiline cue's line:%
|
|
@@ -368,6 +394,8 @@ class WebVTTReader(BaseReader):
|
|
|
368
394
|
layout = caption.layout_info
|
|
369
395
|
if not layout or not layout.origin:
|
|
370
396
|
return
|
|
397
|
+
if not layout.webvtt_positioning or "line:" not in layout.webvtt_positioning:
|
|
398
|
+
return
|
|
371
399
|
origin_y = layout.origin.y
|
|
372
400
|
if origin_y is None or origin_y.unit != UnitEnum.PERCENT:
|
|
373
401
|
return
|
|
@@ -822,7 +850,7 @@ class WebVTTReader(BaseReader):
|
|
|
822
850
|
position_value, position_align = WebVTTReader._split_alignment(
|
|
823
851
|
parsed.get("position", "")
|
|
824
852
|
)
|
|
825
|
-
line_value,
|
|
853
|
+
line_value, line_align = WebVTTReader._split_alignment(parsed.get("line", ""))
|
|
826
854
|
|
|
827
855
|
origin_x = WebVTTReader._parse_percent_value(position_value)
|
|
828
856
|
origin_y = WebVTTReader._parse_line_value(line_value)
|
|
@@ -834,11 +862,17 @@ class WebVTTReader(BaseReader):
|
|
|
834
862
|
pos_alignment = (
|
|
835
863
|
POSITION_ALIGN_MAP.get(position_align) if position_align else None
|
|
836
864
|
)
|
|
865
|
+
ln_alignment = LINE_ALIGN_MAP.get(line_align) if line_align else None
|
|
837
866
|
|
|
838
867
|
origin = None
|
|
839
|
-
if origin_y is not None:
|
|
868
|
+
if origin_y is not None or origin_x is not None:
|
|
840
869
|
x = origin_x if origin_x is not None else Size(0, UnitEnum.PERCENT)
|
|
841
|
-
|
|
870
|
+
y = (
|
|
871
|
+
origin_y
|
|
872
|
+
if origin_y is not None
|
|
873
|
+
else Size((LINE_GRID_SIZE - 1) / LINE_GRID_SIZE * 100, UnitEnum.PERCENT)
|
|
874
|
+
)
|
|
875
|
+
origin = Point(x, y)
|
|
842
876
|
|
|
843
877
|
extent = None
|
|
844
878
|
if extent_horizontal is not None:
|
|
@@ -850,6 +884,7 @@ class WebVTTReader(BaseReader):
|
|
|
850
884
|
alignment=alignment,
|
|
851
885
|
writing_direction=writing_direction,
|
|
852
886
|
position_alignment=pos_alignment,
|
|
887
|
+
line_alignment=ln_alignment,
|
|
853
888
|
webvtt_positioning=cue_settings,
|
|
854
889
|
inherit_from=inherit_from,
|
|
855
890
|
)
|
|
@@ -1006,9 +1041,8 @@ class WebVTTReader(BaseReader):
|
|
|
1006
1041
|
def _parse_css_declarations(declarations):
|
|
1007
1042
|
"""Parse CSS declaration text into pycaption's style dict.
|
|
1008
1043
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
All other properties are silently ignored.
|
|
1044
|
+
Maps recognized CSS properties to pycaption's internal style dict.
|
|
1045
|
+
Unrecognized properties are silently ignored.
|
|
1012
1046
|
|
|
1013
1047
|
:param declarations: CSS declarations string (between braces).
|
|
1014
1048
|
:returns: Dict of recognized style properties.
|
|
@@ -1034,6 +1068,16 @@ class WebVTTReader(BaseReader):
|
|
|
1034
1068
|
props["color"] = prop_value
|
|
1035
1069
|
elif prop_name == "background-color":
|
|
1036
1070
|
props["background-color"] = prop_value
|
|
1071
|
+
elif prop_name == "font-family":
|
|
1072
|
+
props["font-family"] = prop_value.replace('"', "").replace("'", "")
|
|
1073
|
+
elif prop_name == "font-size":
|
|
1074
|
+
props["font-size"] = prop_value
|
|
1075
|
+
elif prop_name == "text-shadow":
|
|
1076
|
+
props["text-shadow"] = prop_value
|
|
1077
|
+
elif prop_name == "opacity":
|
|
1078
|
+
props["opacity"] = prop_value
|
|
1079
|
+
elif prop_name == "line-height":
|
|
1080
|
+
props["line-height"] = prop_value
|
|
1037
1081
|
|
|
1038
1082
|
return props
|
|
1039
1083
|
|
|
@@ -1066,8 +1110,8 @@ class WebVTTReader(BaseReader):
|
|
|
1066
1110
|
def _apply_cascade(content, styles, base_style):
|
|
1067
1111
|
"""Merge base and class-resolved styles into a node's content dict.
|
|
1068
1112
|
|
|
1069
|
-
Resolution order: base (::cue) →
|
|
1070
|
-
keys in content are not overwritten (inline > cascade).
|
|
1113
|
+
Resolution order: base (::cue) → single class → compound selectors.
|
|
1114
|
+
Existing keys in content are not overwritten (inline > cascade).
|
|
1071
1115
|
|
|
1072
1116
|
:param content: The STYLE node's content dict (mutated in place).
|
|
1073
1117
|
:param styles: Full styles dict with class entries.
|
|
@@ -1085,6 +1129,12 @@ class WebVTTReader(BaseReader):
|
|
|
1085
1129
|
if class_style:
|
|
1086
1130
|
resolved.update(class_style)
|
|
1087
1131
|
|
|
1132
|
+
if len(classes) > 1:
|
|
1133
|
+
class_set = set(classes)
|
|
1134
|
+
for key, style in styles.items():
|
|
1135
|
+
if "." in key and set(key.split(".")).issubset(class_set):
|
|
1136
|
+
resolved.update(style)
|
|
1137
|
+
|
|
1088
1138
|
for key, value in resolved.items():
|
|
1089
1139
|
if key not in content:
|
|
1090
1140
|
content[key] = value
|
|
@@ -1108,6 +1158,9 @@ class WebVTTReader(BaseReader):
|
|
|
1108
1158
|
"background-color",
|
|
1109
1159
|
"font-family",
|
|
1110
1160
|
"font-size",
|
|
1161
|
+
"text-shadow",
|
|
1162
|
+
"opacity",
|
|
1163
|
+
"line-height",
|
|
1111
1164
|
}
|
|
1112
1165
|
base_text_props = {
|
|
1113
1166
|
k: v for k, v in base_style.items() if k in _VISUAL_KEYS and v
|
|
@@ -229,9 +229,17 @@ class WebVTTWriter(BaseWriter):
|
|
|
229
229
|
css_prop, css_val = cls._INTERNAL_TO_CSS[k]
|
|
230
230
|
declarations.append(f"{css_prop}: {css_val}")
|
|
231
231
|
elif k not in cls._INTERNAL_STYLE_KEYS:
|
|
232
|
+
if k == "font-family":
|
|
233
|
+
v = cls._quote_font_family(v)
|
|
232
234
|
declarations.append(f"{k}: {v}")
|
|
233
235
|
return "; ".join(declarations)
|
|
234
236
|
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _quote_font_family(value):
|
|
239
|
+
"""Re-quote multi-word font names for valid CSS output."""
|
|
240
|
+
parts = [p.strip() for p in value.split(",")]
|
|
241
|
+
return ", ".join(f'"{p}"' if " " in p else p for p in parts)
|
|
242
|
+
|
|
235
243
|
_STYLE_TO_TAG = {
|
|
236
244
|
"italics": ("<i>", "</i>"),
|
|
237
245
|
"underline": ("<u>", "</u>"),
|
|
@@ -442,7 +450,10 @@ class WebVTTWriter(BaseWriter):
|
|
|
442
450
|
if left_offset is not None:
|
|
443
451
|
cue_settings += f" position:{left_offset}"
|
|
444
452
|
if top_offset is not None:
|
|
445
|
-
|
|
453
|
+
line_part = f" line:{top_offset}"
|
|
454
|
+
if layout.line_alignment:
|
|
455
|
+
line_part += f",{layout.line_alignment.value}"
|
|
456
|
+
cue_settings += line_part
|
|
446
457
|
if cue_width is not None:
|
|
447
458
|
cue_settings += f" size:{cue_width}"
|
|
448
459
|
|
|
@@ -183,6 +183,7 @@ class TestDFXPReader(ReaderTestingMixIn):
|
|
|
183
183
|
(HorizontalAlignmentEnum.START, VerticalAlignmentEnum.BOTTOM),
|
|
184
184
|
None,
|
|
185
185
|
None,
|
|
186
|
+
None,
|
|
186
187
|
),
|
|
187
188
|
(
|
|
188
189
|
((40, UnitEnum.PERCENT), (40, UnitEnum.PERCENT)),
|
|
@@ -191,6 +192,7 @@ class TestDFXPReader(ReaderTestingMixIn):
|
|
|
191
192
|
(HorizontalAlignmentEnum.START, VerticalAlignmentEnum.BOTTOM),
|
|
192
193
|
None,
|
|
193
194
|
None,
|
|
195
|
+
None,
|
|
194
196
|
),
|
|
195
197
|
(
|
|
196
198
|
((10, UnitEnum.PERCENT), (70, UnitEnum.PERCENT)),
|
|
@@ -199,6 +201,7 @@ class TestDFXPReader(ReaderTestingMixIn):
|
|
|
199
201
|
(HorizontalAlignmentEnum.START, VerticalAlignmentEnum.BOTTOM),
|
|
200
202
|
None,
|
|
201
203
|
None,
|
|
204
|
+
None,
|
|
202
205
|
),
|
|
203
206
|
]
|
|
204
207
|
actual_layouts = [
|
|
@@ -121,6 +121,22 @@ class TestSAMIReader(ReaderTestingMixIn):
|
|
|
121
121
|
|
|
122
122
|
assert caption.layout_info.alignment.horizontal == HorizontalAlignmentEnum.LEFT
|
|
123
123
|
|
|
124
|
+
def test_text_align_recognized_after_other_property(self):
|
|
125
|
+
sami = (
|
|
126
|
+
'<SAMI><HEAD><STYLE TYPE="Text/css">\n'
|
|
127
|
+
"P { margin-left: 20px; }\n"
|
|
128
|
+
".ENCC { Name: 'Subtitles'; Lang: en-US; SAMIType: CC; }\n"
|
|
129
|
+
"</STYLE></HEAD><BODY>\n"
|
|
130
|
+
'<SYNC start="100">\n'
|
|
131
|
+
'<P class="ENCC" Style="color:white; text-align:right;">'
|
|
132
|
+
"Hello</P>\n"
|
|
133
|
+
"</SYNC></BODY></SAMI>"
|
|
134
|
+
)
|
|
135
|
+
caption_set = self.reader.read(sami)
|
|
136
|
+
caption = caption_set.get_captions("en-US")[0]
|
|
137
|
+
|
|
138
|
+
assert caption.layout_info.alignment.horizontal == HorizontalAlignmentEnum.RIGHT
|
|
139
|
+
|
|
124
140
|
def test_sami_including_hexadecimal_charref(
|
|
125
141
|
self, sample_sami_including_hexadecimal_charref
|
|
126
142
|
):
|
|
@@ -173,6 +173,7 @@ class TestSCCReader(ReaderTestingMixIn):
|
|
|
173
173
|
(HorizontalAlignmentEnum.LEFT, VerticalAlignmentEnum.TOP),
|
|
174
174
|
None,
|
|
175
175
|
None,
|
|
176
|
+
None,
|
|
176
177
|
),
|
|
177
178
|
(
|
|
178
179
|
((10.0, UnitEnum.PERCENT), (83.0, UnitEnum.PERCENT)),
|
|
@@ -181,6 +182,7 @@ class TestSCCReader(ReaderTestingMixIn):
|
|
|
181
182
|
(HorizontalAlignmentEnum.LEFT, VerticalAlignmentEnum.TOP),
|
|
182
183
|
None,
|
|
183
184
|
None,
|
|
185
|
+
None,
|
|
184
186
|
),
|
|
185
187
|
]
|
|
186
188
|
|
|
@@ -199,6 +201,7 @@ class TestSCCReader(ReaderTestingMixIn):
|
|
|
199
201
|
(HorizontalAlignmentEnum.LEFT, VerticalAlignmentEnum.TOP),
|
|
200
202
|
None,
|
|
201
203
|
None,
|
|
204
|
+
None,
|
|
202
205
|
)
|
|
203
206
|
]
|
|
204
207
|
},
|
|
@@ -211,6 +214,7 @@ class TestSCCReader(ReaderTestingMixIn):
|
|
|
211
214
|
(HorizontalAlignmentEnum.LEFT, VerticalAlignmentEnum.TOP),
|
|
212
215
|
None,
|
|
213
216
|
None,
|
|
217
|
+
None,
|
|
214
218
|
)
|
|
215
219
|
]
|
|
216
220
|
},
|
|
@@ -10,12 +10,14 @@ from pycaption import (
|
|
|
10
10
|
DFXPReader,
|
|
11
11
|
DFXPWriter,
|
|
12
12
|
SAMIReader,
|
|
13
|
+
SAMIWriter,
|
|
13
14
|
WebVTTReader,
|
|
14
15
|
WebVTTWriter,
|
|
15
16
|
)
|
|
16
17
|
from pycaption.base import CaptionNode
|
|
17
18
|
from pycaption.geometry import (
|
|
18
19
|
HorizontalAlignmentEnum,
|
|
20
|
+
LineAlignmentEnum,
|
|
19
21
|
PositionAlignmentEnum,
|
|
20
22
|
Size,
|
|
21
23
|
UnitEnum,
|
|
@@ -781,13 +783,17 @@ class TestWebVTTCueSettingsParsing:
|
|
|
781
783
|
expected = "position:10% line:80% size:60% align:start"
|
|
782
784
|
assert layout.webvtt_positioning == expected
|
|
783
785
|
|
|
784
|
-
def
|
|
786
|
+
def test_position_only_uses_default_y(self):
|
|
785
787
|
vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 position:50%\nHello\n"
|
|
786
788
|
captions = self.reader.read(vtt)
|
|
787
789
|
cue = captions.get_captions("en-US")[0]
|
|
788
790
|
layout = cue.layout_info
|
|
789
791
|
|
|
790
|
-
|
|
792
|
+
expected_y = (15 - 1) / 15 * 100 # ~93.33%
|
|
793
|
+
assert layout.origin is not None
|
|
794
|
+
assert layout.origin.x == Size(50, UnitEnum.PERCENT)
|
|
795
|
+
assert layout.origin.y.unit == UnitEnum.PERCENT
|
|
796
|
+
assert abs(layout.origin.y.value - expected_y) < 0.01
|
|
791
797
|
assert layout.webvtt_positioning == "position:50%"
|
|
792
798
|
|
|
793
799
|
def test_region_with_cue_override(self):
|
|
@@ -958,6 +964,90 @@ Hello
|
|
|
958
964
|
layout = cue.layout_info
|
|
959
965
|
|
|
960
966
|
assert layout.origin.y == Size(80, UnitEnum.PERCENT)
|
|
967
|
+
assert layout.line_alignment == LineAlignmentEnum.CENTER
|
|
968
|
+
|
|
969
|
+
def test_line_alignment_end_stored(self):
|
|
970
|
+
vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:80%,end\nHello\n"
|
|
971
|
+
captions = self.reader.read(vtt)
|
|
972
|
+
cue = captions.get_captions("en-US")[0]
|
|
973
|
+
|
|
974
|
+
assert cue.layout_info.line_alignment == LineAlignmentEnum.END
|
|
975
|
+
|
|
976
|
+
def test_line_alignment_none_when_absent(self):
|
|
977
|
+
vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:80%\nHello\n"
|
|
978
|
+
captions = self.reader.read(vtt)
|
|
979
|
+
cue = captions.get_captions("en-US")[0]
|
|
980
|
+
|
|
981
|
+
assert cue.layout_info.line_alignment is None
|
|
982
|
+
|
|
983
|
+
def test_default_line_adjusts_for_multiline_cue(self):
|
|
984
|
+
vtt = (
|
|
985
|
+
"WEBVTT\n\n"
|
|
986
|
+
"00:00:01.000 --> 00:00:03.000 position:50%\n"
|
|
987
|
+
"Line one\nLine two\nLine three\n"
|
|
988
|
+
)
|
|
989
|
+
captions = self.reader.read(vtt)
|
|
990
|
+
cue = captions.get_captions("en-US")[0]
|
|
991
|
+
|
|
992
|
+
assert cue.layout_info.origin.y == Size(80, UnitEnum.PERCENT)
|
|
993
|
+
|
|
994
|
+
def test_default_line_unchanged_for_single_line_cue(self):
|
|
995
|
+
vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 position:50%\nHello\n"
|
|
996
|
+
captions = self.reader.read(vtt)
|
|
997
|
+
cue = captions.get_captions("en-US")[0]
|
|
998
|
+
|
|
999
|
+
assert cue.layout_info.origin.y == Size((14 / 15) * 100, UnitEnum.PERCENT)
|
|
1000
|
+
|
|
1001
|
+
def test_line_only_origin_x_defaults_to_zero(self):
|
|
1002
|
+
vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:80%\nHello\n"
|
|
1003
|
+
captions = self.reader.read(vtt)
|
|
1004
|
+
cue = captions.get_captions("en-US")[0]
|
|
1005
|
+
layout = cue.layout_info
|
|
1006
|
+
|
|
1007
|
+
assert layout.origin is not None
|
|
1008
|
+
assert layout.origin.x == Size(0, UnitEnum.PERCENT)
|
|
1009
|
+
assert layout.origin.y == Size(80, UnitEnum.PERCENT)
|
|
1010
|
+
|
|
1011
|
+
def test_neither_position_nor_line_origin_is_none(self):
|
|
1012
|
+
vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nHello\n"
|
|
1013
|
+
captions = self.reader.read(vtt)
|
|
1014
|
+
cue = captions.get_captions("en-US")[0]
|
|
1015
|
+
|
|
1016
|
+
assert cue.layout_info is None or cue.layout_info.origin is None
|
|
1017
|
+
|
|
1018
|
+
def test_position_only_dfxp_output_has_horizontal(self):
|
|
1019
|
+
vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 position:70%\nHello\n"
|
|
1020
|
+
captions = self.reader.read(vtt)
|
|
1021
|
+
output = DFXPWriter().write(captions)
|
|
1022
|
+
|
|
1023
|
+
assert "70%" in output
|
|
1024
|
+
assert "tts:origin" in output
|
|
1025
|
+
|
|
1026
|
+
def test_position_only_sami_output_has_margin_left(self):
|
|
1027
|
+
vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 position:70%\nHello\n"
|
|
1028
|
+
captions = self.reader.read(vtt)
|
|
1029
|
+
output = SAMIWriter().write(captions)
|
|
1030
|
+
|
|
1031
|
+
assert "margin-left" in output
|
|
1032
|
+
|
|
1033
|
+
def test_position_only_no_false_overflow_warning(self):
|
|
1034
|
+
vtt = (
|
|
1035
|
+
"WEBVTT\n\n"
|
|
1036
|
+
"00:00:01.000 --> 00:00:03.000 position:70%\n"
|
|
1037
|
+
"Line one\n"
|
|
1038
|
+
"Line two\n"
|
|
1039
|
+
)
|
|
1040
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
1041
|
+
warnings.simplefilter("always")
|
|
1042
|
+
self.reader.read(vtt)
|
|
1043
|
+
|
|
1044
|
+
overflow_warnings = [
|
|
1045
|
+
w
|
|
1046
|
+
for w in caught
|
|
1047
|
+
if issubclass(w.category, CaptionReadWarning)
|
|
1048
|
+
and "extends beyond viewport" in str(w.message)
|
|
1049
|
+
]
|
|
1050
|
+
assert len(overflow_warnings) == 0
|
|
961
1051
|
|
|
962
1052
|
|
|
963
1053
|
class TestWebVTTStyleBlockParsing:
|
|
@@ -985,6 +1075,213 @@ class TestWebVTTStyleBlockParsing:
|
|
|
985
1075
|
assert "yellow" in styles
|
|
986
1076
|
assert styles["yellow"] == {"color": "yellow", "italics": True}
|
|
987
1077
|
|
|
1078
|
+
def test_font_family_comma_separated_parsed(self):
|
|
1079
|
+
vtt = (
|
|
1080
|
+
"WEBVTT\n\n"
|
|
1081
|
+
"STYLE\n"
|
|
1082
|
+
'::cue { font-family: Arial, "Helvetica Neue", sans-serif }\n\n'
|
|
1083
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1084
|
+
"<c.any>Hello</c>\n"
|
|
1085
|
+
)
|
|
1086
|
+
captions = self.reader.read(vtt)
|
|
1087
|
+
styles = dict(captions.get_styles())
|
|
1088
|
+
|
|
1089
|
+
assert styles["::cue"] == {"font-family": "Arial, Helvetica Neue, sans-serif"}
|
|
1090
|
+
|
|
1091
|
+
def test_quoted_font_family_roundtrips_with_quotes(self):
|
|
1092
|
+
from pycaption import WebVTTWriter
|
|
1093
|
+
|
|
1094
|
+
vtt = (
|
|
1095
|
+
"WEBVTT\n\n"
|
|
1096
|
+
"STYLE\n"
|
|
1097
|
+
'::cue(.fancy) { font-family: "Courier New", monospace }\n\n'
|
|
1098
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1099
|
+
"<c.fancy>Hello</c>\n"
|
|
1100
|
+
)
|
|
1101
|
+
captions = self.reader.read(vtt)
|
|
1102
|
+
result = WebVTTWriter().write(captions)
|
|
1103
|
+
|
|
1104
|
+
assert 'font-family: "Courier New", monospace' in result
|
|
1105
|
+
|
|
1106
|
+
def test_font_size_parsed(self):
|
|
1107
|
+
vtt = (
|
|
1108
|
+
"WEBVTT\n\n"
|
|
1109
|
+
"STYLE\n"
|
|
1110
|
+
"::cue { font-size: 120% }\n\n"
|
|
1111
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1112
|
+
"<c.any>Hello</c>\n"
|
|
1113
|
+
)
|
|
1114
|
+
captions = self.reader.read(vtt)
|
|
1115
|
+
styles = dict(captions.get_styles())
|
|
1116
|
+
|
|
1117
|
+
assert styles["::cue"] == {"font-size": "120%"}
|
|
1118
|
+
|
|
1119
|
+
def test_class_font_properties(self):
|
|
1120
|
+
vtt = (
|
|
1121
|
+
"WEBVTT\n\n"
|
|
1122
|
+
"STYLE\n"
|
|
1123
|
+
"::cue(.custom) { font-family: Arial; font-size: 1.2em }\n\n"
|
|
1124
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1125
|
+
"<c.custom>Hello</c>\n"
|
|
1126
|
+
)
|
|
1127
|
+
captions = self.reader.read(vtt)
|
|
1128
|
+
cue = captions.get_captions("en-US")[0]
|
|
1129
|
+
|
|
1130
|
+
for node in cue.nodes:
|
|
1131
|
+
if node.type_ == CaptionNode.STYLE and node.start:
|
|
1132
|
+
assert node.content["font-family"] == "Arial"
|
|
1133
|
+
assert node.content["font-size"] == "1.2em"
|
|
1134
|
+
break
|
|
1135
|
+
|
|
1136
|
+
def test_text_shadow_parsed(self):
|
|
1137
|
+
vtt = (
|
|
1138
|
+
"WEBVTT\n\n"
|
|
1139
|
+
"STYLE\n"
|
|
1140
|
+
"::cue { text-shadow: 2px 2px 4px black }\n\n"
|
|
1141
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1142
|
+
"<c.any>Hello</c>\n"
|
|
1143
|
+
)
|
|
1144
|
+
captions = self.reader.read(vtt)
|
|
1145
|
+
styles = dict(captions.get_styles())
|
|
1146
|
+
|
|
1147
|
+
assert styles["::cue"] == {"text-shadow": "2px 2px 4px black"}
|
|
1148
|
+
|
|
1149
|
+
def test_opacity_parsed(self):
|
|
1150
|
+
vtt = (
|
|
1151
|
+
"WEBVTT\n\n"
|
|
1152
|
+
"STYLE\n"
|
|
1153
|
+
"::cue { opacity: 0.8 }\n\n"
|
|
1154
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1155
|
+
"<c.any>Hello</c>\n"
|
|
1156
|
+
)
|
|
1157
|
+
captions = self.reader.read(vtt)
|
|
1158
|
+
styles = dict(captions.get_styles())
|
|
1159
|
+
|
|
1160
|
+
assert styles["::cue"] == {"opacity": "0.8"}
|
|
1161
|
+
|
|
1162
|
+
def test_line_height_parsed(self):
|
|
1163
|
+
vtt = (
|
|
1164
|
+
"WEBVTT\n\n"
|
|
1165
|
+
"STYLE\n"
|
|
1166
|
+
"::cue { line-height: 1.5 }\n\n"
|
|
1167
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1168
|
+
"<c.any>Hello</c>\n"
|
|
1169
|
+
)
|
|
1170
|
+
captions = self.reader.read(vtt)
|
|
1171
|
+
styles = dict(captions.get_styles())
|
|
1172
|
+
|
|
1173
|
+
assert styles["::cue"] == {"line-height": "1.5"}
|
|
1174
|
+
|
|
1175
|
+
def test_compound_selector_parsed(self):
|
|
1176
|
+
vtt = (
|
|
1177
|
+
"WEBVTT\n\n"
|
|
1178
|
+
"STYLE\n"
|
|
1179
|
+
"::cue(.bold.yellow) { color: yellow }\n\n"
|
|
1180
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1181
|
+
"<c.bold.yellow>Hello</c>\n"
|
|
1182
|
+
)
|
|
1183
|
+
captions = self.reader.read(vtt)
|
|
1184
|
+
styles = dict(captions.get_styles())
|
|
1185
|
+
|
|
1186
|
+
assert "bold.yellow" in styles
|
|
1187
|
+
assert styles["bold.yellow"] == {"color": "yellow"}
|
|
1188
|
+
|
|
1189
|
+
def test_compound_selector_cascades_to_span(self):
|
|
1190
|
+
vtt = (
|
|
1191
|
+
"WEBVTT\n\n"
|
|
1192
|
+
"STYLE\n"
|
|
1193
|
+
"::cue(.bold.yellow) { color: yellow }\n\n"
|
|
1194
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1195
|
+
"<c.bold.yellow>Hello</c>\n"
|
|
1196
|
+
)
|
|
1197
|
+
captions = self.reader.read(vtt)
|
|
1198
|
+
cue = captions.get_captions("en-US")[0]
|
|
1199
|
+
|
|
1200
|
+
for node in cue.nodes:
|
|
1201
|
+
if node.type_ == CaptionNode.STYLE and node.start:
|
|
1202
|
+
assert node.content.get("color") == "yellow"
|
|
1203
|
+
break
|
|
1204
|
+
|
|
1205
|
+
def test_compound_selector_no_partial_match(self):
|
|
1206
|
+
vtt = (
|
|
1207
|
+
"WEBVTT\n\n"
|
|
1208
|
+
"STYLE\n"
|
|
1209
|
+
"::cue(.bold.yellow) { color: yellow }\n\n"
|
|
1210
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1211
|
+
"<c.bold>Hello</c>\n"
|
|
1212
|
+
)
|
|
1213
|
+
captions = self.reader.read(vtt)
|
|
1214
|
+
cue = captions.get_captions("en-US")[0]
|
|
1215
|
+
|
|
1216
|
+
for node in cue.nodes:
|
|
1217
|
+
if node.type_ == CaptionNode.STYLE and node.start:
|
|
1218
|
+
assert "color" not in node.content
|
|
1219
|
+
break
|
|
1220
|
+
|
|
1221
|
+
def test_compound_selector_vtt_roundtrip(self):
|
|
1222
|
+
from pycaption import WebVTTWriter
|
|
1223
|
+
|
|
1224
|
+
vtt = (
|
|
1225
|
+
"WEBVTT\n\n"
|
|
1226
|
+
"STYLE\n"
|
|
1227
|
+
"::cue(.bold.yellow) { color: yellow }\n\n"
|
|
1228
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1229
|
+
"<c.bold.yellow>Hello</c>\n"
|
|
1230
|
+
)
|
|
1231
|
+
captions = self.reader.read(vtt)
|
|
1232
|
+
result = WebVTTWriter().write(captions)
|
|
1233
|
+
|
|
1234
|
+
assert "::cue(.bold.yellow)" in result
|
|
1235
|
+
|
|
1236
|
+
def test_compound_selector_three_classes(self):
|
|
1237
|
+
vtt = (
|
|
1238
|
+
"WEBVTT\n\n"
|
|
1239
|
+
"STYLE\n"
|
|
1240
|
+
"::cue(.a.b.c) { color: red }\n\n"
|
|
1241
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1242
|
+
"<c.a.b.c>Hello</c>\n"
|
|
1243
|
+
)
|
|
1244
|
+
captions = self.reader.read(vtt)
|
|
1245
|
+
cue = captions.get_captions("en-US")[0]
|
|
1246
|
+
|
|
1247
|
+
for node in cue.nodes:
|
|
1248
|
+
if node.type_ == CaptionNode.STYLE and node.start:
|
|
1249
|
+
assert node.content.get("color") == "red"
|
|
1250
|
+
break
|
|
1251
|
+
|
|
1252
|
+
def test_compound_selector_superset_span(self):
|
|
1253
|
+
vtt = (
|
|
1254
|
+
"WEBVTT\n\n"
|
|
1255
|
+
"STYLE\n"
|
|
1256
|
+
"::cue(.bold.yellow) { color: gold }\n\n"
|
|
1257
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1258
|
+
"<c.bold.yellow.italic>Hello</c>\n"
|
|
1259
|
+
)
|
|
1260
|
+
captions = self.reader.read(vtt)
|
|
1261
|
+
cue = captions.get_captions("en-US")[0]
|
|
1262
|
+
|
|
1263
|
+
for node in cue.nodes:
|
|
1264
|
+
if node.type_ == CaptionNode.STYLE and node.start:
|
|
1265
|
+
assert node.content.get("color") == "gold"
|
|
1266
|
+
break
|
|
1267
|
+
|
|
1268
|
+
def test_compound_selector_overrides_single(self):
|
|
1269
|
+
vtt = (
|
|
1270
|
+
"WEBVTT\n\n"
|
|
1271
|
+
"STYLE\n"
|
|
1272
|
+
"::cue(.bold) { color: white }\n"
|
|
1273
|
+
"::cue(.bold.yellow) { color: gold }\n\n"
|
|
1274
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
1275
|
+
"<c.bold.yellow>Hello</c>\n"
|
|
1276
|
+
)
|
|
1277
|
+
captions = self.reader.read(vtt)
|
|
1278
|
+
cue = captions.get_captions("en-US")[0]
|
|
1279
|
+
|
|
1280
|
+
for node in cue.nodes:
|
|
1281
|
+
if node.type_ == CaptionNode.STYLE and node.start:
|
|
1282
|
+
assert node.content.get("color") == "gold"
|
|
1283
|
+
break
|
|
1284
|
+
|
|
988
1285
|
def test_cascade_specificity(self, sample_webvtt_with_style_block_cascade):
|
|
989
1286
|
captions = self.reader.read(sample_webvtt_with_style_block_cascade)
|
|
990
1287
|
cue = captions.get_captions("en-US")[0]
|
|
@@ -303,6 +303,32 @@ class TestWebVTTCueSettingsConversion:
|
|
|
303
303
|
|
|
304
304
|
assert "position:50% line:80% align:center" in result
|
|
305
305
|
|
|
306
|
+
def test_line_alignment_center_to_dfxp(self):
|
|
307
|
+
vtt = "WEBVTT\n\n" "00:00:01.000 --> 00:00:03.000 line:50%,center\n" "Hello\n"
|
|
308
|
+
caption_set = WebVTTReader().read(vtt)
|
|
309
|
+
result = DFXPWriter().write(caption_set)
|
|
310
|
+
|
|
311
|
+
assert 'tts:displayAlign="center"' in result
|
|
312
|
+
|
|
313
|
+
def test_line_alignment_vtt_non_passthrough_roundtrip(self):
|
|
314
|
+
vtt = "WEBVTT\n\n" "00:00:01.000 --> 00:00:03.000 line:80%,center\n" "Hello\n"
|
|
315
|
+
caption_set = WebVTTReader().read(vtt)
|
|
316
|
+
for caption in caption_set.get_captions("en-US"):
|
|
317
|
+
caption.layout_info.webvtt_positioning = None
|
|
318
|
+
result = WebVTTWriter().write(caption_set)
|
|
319
|
+
|
|
320
|
+
assert "line:80%,center" in result
|
|
321
|
+
|
|
322
|
+
def test_line_alignment_absent_no_qualifier_in_vtt_output(self):
|
|
323
|
+
vtt = "WEBVTT\n\n" "00:00:01.000 --> 00:00:03.000 line:80%\n" "Hello\n"
|
|
324
|
+
caption_set = WebVTTReader().read(vtt)
|
|
325
|
+
for caption in caption_set.get_captions("en-US"):
|
|
326
|
+
caption.layout_info.webvtt_positioning = None
|
|
327
|
+
result = WebVTTWriter().write(caption_set)
|
|
328
|
+
|
|
329
|
+
assert "line:80%" in result
|
|
330
|
+
assert "line:80%," not in result
|
|
331
|
+
|
|
306
332
|
def test_style_block_to_sami_no_invalid_selector(self):
|
|
307
333
|
vtt = (
|
|
308
334
|
"WEBVTT\n\n"
|
|
@@ -537,6 +563,113 @@ class TestWebVTTtoDFXPStyles:
|
|
|
537
563
|
|
|
538
564
|
assert 'tts:textDecoration="underline"' in result
|
|
539
565
|
|
|
566
|
+
def test_font_family_to_dfxp(self):
|
|
567
|
+
vtt = (
|
|
568
|
+
"WEBVTT\n\n"
|
|
569
|
+
"STYLE\n"
|
|
570
|
+
"::cue(.fancy) { font-family: Arial }\n\n"
|
|
571
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
572
|
+
"<c.fancy>Hello styled</c>\n"
|
|
573
|
+
)
|
|
574
|
+
caption_set = WebVTTReader().read(vtt)
|
|
575
|
+
result = DFXPWriter().write(caption_set)
|
|
576
|
+
|
|
577
|
+
assert 'tts:fontFamily="Arial"' in result
|
|
578
|
+
|
|
579
|
+
def test_quoted_font_family_to_valid_dfxp(self):
|
|
580
|
+
vtt = (
|
|
581
|
+
"WEBVTT\n\n"
|
|
582
|
+
"STYLE\n"
|
|
583
|
+
'::cue(.fancy) { font-family: "Helvetica Neue", Arial, sans-serif }\n\n'
|
|
584
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
585
|
+
"<c.fancy>Hello styled</c>\n"
|
|
586
|
+
)
|
|
587
|
+
caption_set = WebVTTReader().read(vtt)
|
|
588
|
+
result = DFXPWriter().write(caption_set)
|
|
589
|
+
|
|
590
|
+
assert 'tts:fontFamily="Helvetica Neue, Arial, sans-serif"' in result
|
|
591
|
+
from lxml import etree
|
|
592
|
+
|
|
593
|
+
etree.fromstring(result.encode("utf-8"))
|
|
594
|
+
|
|
595
|
+
def test_font_size_to_dfxp(self):
|
|
596
|
+
vtt = (
|
|
597
|
+
"WEBVTT\n\n"
|
|
598
|
+
"STYLE\n"
|
|
599
|
+
"::cue(.big) { font-size: 120% }\n\n"
|
|
600
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
601
|
+
"<c.big>Hello styled</c>\n"
|
|
602
|
+
)
|
|
603
|
+
caption_set = WebVTTReader().read(vtt)
|
|
604
|
+
result = DFXPWriter().write(caption_set)
|
|
605
|
+
|
|
606
|
+
assert 'tts:fontSize="120%"' in result
|
|
607
|
+
|
|
608
|
+
def test_text_shadow_to_dfxp(self):
|
|
609
|
+
vtt = (
|
|
610
|
+
"WEBVTT\n\n"
|
|
611
|
+
"STYLE\n"
|
|
612
|
+
"::cue(.shadow) { text-shadow: 2px 2px 4px black }\n\n"
|
|
613
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
614
|
+
"<c.shadow>Hello styled</c>\n"
|
|
615
|
+
)
|
|
616
|
+
caption_set = WebVTTReader().read(vtt)
|
|
617
|
+
result = DFXPWriter().write(caption_set)
|
|
618
|
+
|
|
619
|
+
assert 'tts:textOutline="black 4px"' in result
|
|
620
|
+
|
|
621
|
+
def test_text_shadow_no_blur_to_dfxp(self):
|
|
622
|
+
vtt = (
|
|
623
|
+
"WEBVTT\n\n"
|
|
624
|
+
"STYLE\n"
|
|
625
|
+
"::cue(.shadow) { text-shadow: 1px 1px black }\n\n"
|
|
626
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
627
|
+
"<c.shadow>Hello styled</c>\n"
|
|
628
|
+
)
|
|
629
|
+
caption_set = WebVTTReader().read(vtt)
|
|
630
|
+
result = DFXPWriter().write(caption_set)
|
|
631
|
+
|
|
632
|
+
assert 'tts:textOutline="black 1px"' in result
|
|
633
|
+
|
|
634
|
+
def test_opacity_to_dfxp(self):
|
|
635
|
+
vtt = (
|
|
636
|
+
"WEBVTT\n\n"
|
|
637
|
+
"STYLE\n"
|
|
638
|
+
"::cue(.faded) { opacity: 0.8 }\n\n"
|
|
639
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
640
|
+
"<c.faded>Hello styled</c>\n"
|
|
641
|
+
)
|
|
642
|
+
caption_set = WebVTTReader().read(vtt)
|
|
643
|
+
result = DFXPWriter().write(caption_set)
|
|
644
|
+
|
|
645
|
+
assert 'tts:opacity="0.8"' in result
|
|
646
|
+
|
|
647
|
+
def test_line_height_to_dfxp(self):
|
|
648
|
+
vtt = (
|
|
649
|
+
"WEBVTT\n\n"
|
|
650
|
+
"STYLE\n"
|
|
651
|
+
"::cue(.spaced) { line-height: 1.5 }\n\n"
|
|
652
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
653
|
+
"<c.spaced>Hello styled</c>\n"
|
|
654
|
+
)
|
|
655
|
+
caption_set = WebVTTReader().read(vtt)
|
|
656
|
+
result = DFXPWriter().write(caption_set)
|
|
657
|
+
|
|
658
|
+
assert 'tts:lineHeight="1.5"' in result
|
|
659
|
+
|
|
660
|
+
def test_compound_selector_to_dfxp(self):
|
|
661
|
+
vtt = (
|
|
662
|
+
"WEBVTT\n\n"
|
|
663
|
+
"STYLE\n"
|
|
664
|
+
"::cue(.bold.yellow) { color: yellow }\n\n"
|
|
665
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
666
|
+
"<c.bold.yellow>Hello styled</c>\n"
|
|
667
|
+
)
|
|
668
|
+
caption_set = WebVTTReader().read(vtt)
|
|
669
|
+
result = DFXPWriter().write(caption_set)
|
|
670
|
+
|
|
671
|
+
assert 'tts:color="yellow"' in result
|
|
672
|
+
|
|
540
673
|
def test_classes_not_in_dfxp_output(self):
|
|
541
674
|
from lxml import etree
|
|
542
675
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|